diff --git a/AGENTS.md b/AGENTS.md index 34a7d5d012..3e34d4587d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ vite-plus/ └── crates/vp_trampoline/ # Windows shim trampoline ``` +On-disk paths (bin, config, data, state, cache) are resolved centrally via `vp_shared::Dirs` (`crates/vp_shared/src/dirs.rs`) — legacy monolithic `~/.vite-plus` root or split XDG/platform layout; no call site constructs `~/.vite-plus/...` or reads `XDG_*` itself. + `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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3db7e9a7ab..1e7fe89877 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ pnpm bootstrap-cli vp --version ``` -This builds all packages, compiles the Rust `vp` binary, and installs the CLI to `~/.vite-plus`. +This builds all packages, compiles the Rust `vp` binary, and installs the CLI to `~/.vite-plus` (the legacy monolithic layout; on-disk paths are resolved by `vp_shared::Dirs` in `crates/vp_shared/src/dirs.rs`). To switch back to a release version, use `vp upgrade --force` (`current` points to `local-dev-*` but the binary version may still match the release, so `--force` is needed) diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs index 6a1cddda26..44f046e63e 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/assert-shims.mjs @@ -1,14 +1,15 @@ import fs from 'node:fs'; import path from 'node:path'; -const expected = path.resolve('external/vp'); +// Shims of a legacy install are relative links into its own current/bin/vp. +const expected = path.join('..', 'current', 'bin', 'vp'); for (const shim of ['vp', 'node', 'npm', 'npx', 'corepack', 'vpx', 'vpr']) { - const shimPath = path.join('home', 'bin', shim); + const shimPath = path.join('external', 'bin', shim); const target = fs.readlinkSync(shimPath); if (target !== expected) { throw new Error(`${shim} points to ${target}, expected ${expected}`); } } -console.log('all shims point to external vp'); +console.log('all shims point to the external install'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml index 280c43ede5..15fc77d04b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots.toml @@ -3,16 +3,18 @@ name = "command_env_setup_external_vp" vp = "global" skip-platforms = ["windows"] steps = [ - { argv = ["vpt", "mkdir", "-p", "external", "home"], comment = "Prepare isolated external install and VP_HOME", snapshot = false }, - { argv = ["vpt", "cp", "$VP_HOME/bin/vp", "external/vp"], comment = "Simulate a Homebrew-style vp outside VP_HOME", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "external/vp"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "external/current/bin", "external/bin", "external/js_runtime/node/22.18.0/bin"], comment = "A second, complete legacy install outside the case home", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "external/current/bin/vp"], comment = "The external install's vp binary", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "external/bin/vp"], comment = "Marks the external layout as a legacy install for detection", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/current/bin/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/bin/vp"], snapshot = false }, { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], comment = "Project Node.js version", snapshot = false }, - { argv = ["vpt", "write-file", "home/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho vp-managed-node-22.18.0\n"], comment = "Preinstall managed Node runtime", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/node"], snapshot = false }, - { argv = ["./external/vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Setup shims from external vp", snapshot = false }, + { argv = ["vpt", "write-file", "external/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho vp-managed-node-22.18.0\n"], comment = "Preinstall managed Node runtime", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "external/js_runtime/node/22.18.0/bin/node"], snapshot = false }, + { argv = ["./external/current/bin/vp", "env", "setup"], comment = "env setup targets the invoking install via self-location (no VP_HOME)", snapshot = false }, # The legacy step set VP_BYPASS to reach a system node, which the hermetic # case PATH does not have; the node shim resolving the pinned 22.18.0 from # the seeded runtime serves the same purpose (any node can run the asserts). - { argv = ["node", "assert-shims.mjs"], comment = "Shims should point to external vp, not VP_HOME/current/bin/vp" }, - { argv = ["node", "-v"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "node shim uses the project version" }, + { argv = ["node", "assert-shims.mjs"], comment = "Shims point to the external install's vp, not the case home's" }, + { argv = ["node", "-v"], envs = [["PATH", "${workspace}/external/bin:${PATH}"]], comment = "node shim uses the project version" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md index 3d2caaf3cd..2294914a31 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_env_setup_external_vp/snapshots/command_env_setup_external_vp.md @@ -1,16 +1,24 @@ # command_env_setup_external_vp -## `vpt mkdir -p external home` +## `vpt mkdir -p external/current/bin external/bin external/js_runtime/node/22.18.0/bin` -Prepare isolated external install and VP_HOME +A second, complete legacy install outside the case home -## `vpt cp $VP_HOME/bin/vp external/vp` +## `vpt cp $VP_HOME/current/bin/vp external/current/bin/vp` -Simulate a Homebrew-style vp outside VP_HOME +The external install's vp binary -## `vpt chmod +x external/vp` +## `vpt cp $VP_HOME/current/bin/vp external/bin/vp` + +Marks the external layout as a legacy install for detection + + +## `vpt chmod +x external/current/bin/vp` + + +## `vpt chmod +x external/bin/vp` ## `vpt write-file .node-version '22.18.0 @@ -19,30 +27,30 @@ Simulate a Homebrew-style vp outside VP_HOME Project Node.js version -## `vpt write-file home/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh +## `vpt write-file external/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh echo vp-managed-node-22.18.0 '` Preinstall managed Node runtime -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/node` +## `vpt chmod +x external/js_runtime/node/22.18.0/bin/node` -## `VP_HOME=${workspace}/home ./external/vp env setup` +## `./external/current/bin/vp env setup` -Setup shims from external vp +env setup targets the invoking install via self-location (no VP_HOME) ## `node assert-shims.mjs` -Shims should point to external vp, not VP_HOME/current/bin/vp +Shims point to the external install's vp, not the case home's ``` -all shims point to external vp +all shims point to the external install ``` -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} node -v` +## `PATH=${workspace}/external/bin:${PATH} node -v` node shim uses the project version diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md index bdb8767398..7c97f2a0d2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_add_git_hooks/snapshots/migration_add_git_hooks.md @@ -111,10 +111,14 @@ d="$(dirname "$(dirname "$(dirname "$0")")")" __vp_shell=/bin/sh [ -x "$__vp_shell" ] || __vp_shell=$(command -v sh) -if [ -n "${VP_HOME-}" ]; then +if [ -n "${VP_BIN_DIR-}" ]; then + __vp_bin="$VP_BIN_DIR" +elif [ -n "${VP_HOME-}" ]; then __vp_bin="$VP_HOME/bin" -elif [ -n "${HOME-}" ]; then +elif [ -n "${HOME-}" ] && [ -d "$HOME/.vite-plus/bin" ]; then __vp_bin="$HOME/.vite-plus/bin" +elif [ -n "${HOME-}" ]; then + __vp_bin="$HOME/.local/bin" else __vp_bin="" fi diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh index 36f696d4ec..518d7c0e62 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/fake-corepack.sh @@ -2,8 +2,10 @@ # Fake bundled corepack: echoes its invocation with the test root normalized # for stable snapshots, and simulates corepack clobbering the npm shim on # `enable` so the test can assert that Vite+ restores it. +# The script lives at /js_runtime/node//bin/corepack, so the +# install's shim dir is three levels up plus `bin`. if [ "$1" = "enable" ]; then - rm -f "$VP_HOME/bin/npm" + rm -f "$(dirname "$0")/../../../bin/npm" fi out="corepack" for arg in "$@"; do diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml index 0f31a1c51c..deeddbff1b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots.toml @@ -3,15 +3,19 @@ name = "shim_corepack_enable_install_directory" vp = "global" skip-platforms = ["windows"] steps = [ - { argv = ["vpt", "mkdir", "-p", "home/js_runtime/node/22.18.0/bin"], comment = "Isolated VP_HOME with a fake managed Node runtime layout", snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "home/.vite-plus/js_runtime/node/22.18.0/bin", "home/.vite-plus/current/bin", "home/.vite-plus/bin"], comment = "Isolated legacy install layout with a fake managed Node runtime", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "home/.vite-plus/current/bin/vp"], comment = "The isolated install's vp binary", snapshot = false }, + { argv = ["vpt", "cp", "$VP_HOME/current/bin/vp", "home/.vite-plus/bin/vp"], comment = "Marks the layout as a legacy install for detection", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "home/.vite-plus/current/bin/vp"], snapshot = false }, + { argv = ["vpt", "chmod", "+x", "home/.vite-plus/bin/vp"], snapshot = false }, { argv = ["vpt", "write-file", ".node-version", "22.18.0\n"], comment = "Project Node.js version", snapshot = false }, - { argv = ["vpt", "write-file", "home/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho fake-node\n"], comment = "Fake node binary", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/node"], snapshot = false }, - { argv = ["vpt", "cp", "fake-corepack.sh", "home/js_runtime/node/22.18.0/bin/corepack"], comment = "Fake bundled corepack that echoes its args", snapshot = false }, - { argv = ["vpt", "chmod", "+x", "home/js_runtime/node/22.18.0/bin/corepack"], snapshot = false }, - { argv = ["vp", "env", "setup"], envs = [["VP_HOME", "${workspace}/home"]], comment = "Create shims in the isolated home", snapshot = false }, - { argv = ["corepack", "use", "pnpm@10"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "Non-link commands run unchanged" }, - { argv = ["corepack", "enable", "--install-directory", "/tmp/custom-dir"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "Explicit --install-directory is respected, clobbered npm shim is restored" }, - { argv = ["corepack", "enable"], envs = [["VP_HOME", "${workspace}/home"], ["PATH", "${workspace}/home/bin:${PATH}"]], comment = "--install-directory defaults to VP_HOME/bin" }, - { argv = ["vpt", "stat-file", "home/bin/npm", "--assert", "symlink"], comment = "Vite+ owns the npm shim" }, + { argv = ["vpt", "write-file", "home/.vite-plus/js_runtime/node/22.18.0/bin/node", "#!/bin/sh\necho fake-node\n"], comment = "Fake node binary", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "home/.vite-plus/js_runtime/node/22.18.0/bin/node"], snapshot = false }, + { argv = ["vpt", "cp", "fake-corepack.sh", "home/.vite-plus/js_runtime/node/22.18.0/bin/corepack"], comment = "Fake bundled corepack that echoes its args", snapshot = false }, + { argv = ["vpt", "chmod", "+x", "home/.vite-plus/js_runtime/node/22.18.0/bin/corepack"], snapshot = false }, + { argv = ["./home/.vite-plus/current/bin/vp", "env", "setup"], comment = "Create shims in the isolated install (self-located, no VP_HOME)", snapshot = false }, + { argv = ["corepack", "use", "pnpm@10"], envs = [["PATH", "${workspace}/home/.vite-plus/bin:${PATH}"]], comment = "Non-link commands run unchanged" }, + { argv = ["corepack", "enable", "--install-directory", "/tmp/custom-dir"], envs = [["PATH", "${workspace}/home/.vite-plus/bin:${PATH}"]], comment = "Explicit --install-directory is respected, clobbered npm shim is restored" }, + { argv = ["corepack", "enable"], envs = [["PATH", "${workspace}/home/.vite-plus/bin:${PATH}"]], comment = "--install-directory defaults to the install's bin dir" }, + { argv = ["vpt", "stat-file", "home/.vite-plus/bin/npm", "--assert", "symlink"], comment = "Vite+ owns the npm shim" }, ] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md index d66cf17ca4..fc6a8f3856 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/shim_corepack_enable_install_directory/snapshots/shim_corepack_enable_install_directory.md @@ -1,8 +1,24 @@ # shim_corepack_enable_install_directory -## `vpt mkdir -p home/js_runtime/node/22.18.0/bin` +## `vpt mkdir -p home/.vite-plus/js_runtime/node/22.18.0/bin home/.vite-plus/current/bin home/.vite-plus/bin` -Isolated VP_HOME with a fake managed Node runtime layout +Isolated legacy install layout with a fake managed Node runtime + + +## `vpt cp $VP_HOME/current/bin/vp home/.vite-plus/current/bin/vp` + +The isolated install's vp binary + + +## `vpt cp $VP_HOME/current/bin/vp home/.vite-plus/bin/vp` + +Marks the layout as a legacy install for detection + + +## `vpt chmod +x home/.vite-plus/current/bin/vp` + + +## `vpt chmod +x home/.vite-plus/bin/vp` ## `vpt write-file .node-version '22.18.0 @@ -11,30 +27,30 @@ Isolated VP_HOME with a fake managed Node runtime layout Project Node.js version -## `vpt write-file home/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh +## `vpt write-file home/.vite-plus/js_runtime/node/22.18.0/bin/node '#'\!'/bin/sh echo fake-node '` Fake node binary -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/node` +## `vpt chmod +x home/.vite-plus/js_runtime/node/22.18.0/bin/node` -## `vpt cp fake-corepack.sh home/js_runtime/node/22.18.0/bin/corepack` +## `vpt cp fake-corepack.sh home/.vite-plus/js_runtime/node/22.18.0/bin/corepack` Fake bundled corepack that echoes its args -## `vpt chmod +x home/js_runtime/node/22.18.0/bin/corepack` +## `vpt chmod +x home/.vite-plus/js_runtime/node/22.18.0/bin/corepack` -## `VP_HOME=${workspace}/home vp env setup` +## `./home/.vite-plus/current/bin/vp env setup` -Create shims in the isolated home +Create shims in the isolated install (self-located, no VP_HOME) -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack use pnpm@10` +## `PATH=${workspace}/home/.vite-plus/bin:${PATH} corepack use pnpm@10` Non-link commands run unchanged @@ -42,28 +58,26 @@ Non-link commands run unchanged corepack use pnpm@10 ``` -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack enable --install-directory /tmp/custom-dir` +## `PATH=${workspace}/home/.vite-plus/bin:${PATH} corepack enable --install-directory /tmp/custom-dir` Explicit --install-directory is respected, clobbered npm shim is restored ``` corepack enable --install-directory /tmp/custom-dir -warn: 'npm' is managed by Vite+ and was restored. Vite+ already resolves 'npm' per project, so corepack does not need to manage it. ``` -## `VP_HOME=${workspace}/home PATH=${workspace}/home/bin:${PATH} corepack enable` +## `PATH=${workspace}/home/.vite-plus/bin:${PATH} corepack enable` ---install-directory defaults to VP_HOME/bin +--install-directory defaults to the install's bin dir ``` -corepack enable --install-directory /home/bin -warn: 'npm' is managed by Vite+ and was restored. Vite+ already resolves 'npm' per project, so corepack does not need to manage it. +corepack enable --install-directory /home/.vite-plus/bin ``` -## `vpt stat-file home/bin/npm --assert symlink` +## `vpt stat-file home/.vite-plus/bin/npm --assert symlink` Vite+ owns the npm shim ``` -home/bin/npm: symlink +home/.vite-plus/bin/npm: symlink ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs index 78ee1c0ca1..23d7e4a7c2 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/main.rs @@ -525,9 +525,27 @@ impl CaseHome { if flavor == Flavor::Local { self.write_local_package_cmd_shims(&package_dir, &local_bin_dir)?; } - self.run_env_setup(&vp)?; + // Complete the legacy install shape (`bin/vp` alongside + // `current/bin/vp`) before any case CLI runs: layout detection + // classifies `/current/bin/vp` as a split data dir unless + // `/bin/vp` exists, and `vp env setup` below would otherwise + // write shims into the split bin dir instead of `/bin`. let vp_bin_dir = self.vp_home().join("bin"); + std::fs::create_dir_all(&vp_bin_dir) + .map_err(|e| format!("failed to create bin dir: {e}"))?; + #[cfg(unix)] + { + let link = vp_bin_dir.join(VP_BINARY_NAME); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink("../current/bin/vp", &link) + .map_err(|e| format!("failed to link bin/vp: {e}"))?; + } + #[cfg(windows)] + flavor::install_file(&vp_bin_dir.join(VP_BINARY_NAME), &runtime.global_vp, "bin/vp.exe")?; + + self.run_env_setup(&vp)?; + let mut tool_dirs = match flavor { Flavor::Global => vec![vp_bin_dir], Flavor::Local => vec![local_bin_dir, vp_bin_dir], @@ -625,6 +643,11 @@ impl CaseHome { env.insert("TERM".into(), "xterm-256color".into()); env.insert("VP_CLI_TEST".into(), "1".into()); env.insert("NODE_NO_WARNINGS".into(), "1".into()); + // The CLI no longer reads VP_HOME (the provisioned + // `/.vite-plus/current/bin/vp` self-locates, and the on-disk + // `/.vite-plus` selects the legacy layout). Kept because + // fixture steps reference `$VP_HOME/...` in `vpt` argv (expanded + // from this env by vpt's `expand_env_arg`). env.insert("VP_HOME".into(), self.vp_home().into_os_string()); if cfg!(windows) { env.insert("USERPROFILE".into(), self.home.clone().into_os_string()); diff --git a/crates/vp_command/src/ps1_shim.rs b/crates/vp_command/src/ps1_shim.rs index f4665da6c0..aabf51bbaa 100644 --- a/crates/vp_command/src/ps1_shim.rs +++ b/crates/vp_command/src/ps1_shim.rs @@ -46,8 +46,8 @@ use vt_powershell::{POWERSHELL_PREFIX, find_ps1_sibling, is_stdin_terminal, powe /// - 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/`, +/// - the resolved path is outside the vite-plus install root +/// AND not under any `node_modules/.bin/`, /// - the resolved path is not a `.cmd` (case-insensitive), /// - the `.cmd` has no sibling `.ps1`. #[must_use] @@ -61,16 +61,18 @@ pub fn rewrite_cmd_to_powershell( rewrite_in_scope(resolved, vp_home().map(AsRef::as_ref), 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). +/// Cached vite-plus install root (`~/.vite-plus` under the legacy layout; the +/// data directory under the split layout). +/// +/// The returned value is always `Some`; the `Option` only exists because the +/// rewrite scope check also applies to `node_modules/.bin/*.cmd` paths, which +/// are independent of the install root. fn vp_home() -> Option<&'static AbsolutePathBuf> { use std::sync::LazyLock; - static VP_HOME: LazyLock> = - LazyLock::new(|| vp_shared::get_vp_home().ok()); - VP_HOME.as_ref() + static INSTALL_ROOT: LazyLock = + LazyLock::new(|| vp_shared::Dirs::get().data_dir()); + Some(&INSTALL_ROOT) } /// Pure rewrite logic. Factored out so tests can drive it on any platform diff --git a/crates/vp_global_cli/src/commands/env/bin_config.rs b/crates/vp_global_cli/src/commands/env/bin_config.rs index a1959a22fe..b26c4ea4d5 100644 --- a/crates/vp_global_cli/src/commands/env/bin_config.rs +++ b/crates/vp_global_cli/src/commands/env/bin_config.rs @@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize}; use vt_path::AbsolutePathBuf; -use super::config::get_vp_home; use crate::error::Error; /// Source that installed a binary. @@ -52,9 +51,10 @@ impl BinConfig { Self { name, package, version: String::new(), node_version, source: BinSource::Npm } } - /// Get the bins directory path (~/.vite-plus/bins/). + /// Get the bins directory path (`/bins/`; `~/.vite-plus/bins/` under + /// the legacy layout — identical on disk). pub fn bins_dir() -> Result { - Ok(get_vp_home()?.join("bins")) + Ok(vp_shared::Dirs::get().bins_dir()) } /// Get the path to a binary's config file. diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index e1ca0f74cf..e8d754d85f 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -13,9 +13,9 @@ use crate::error::Error; /// Execute the clean command. pub async fn execute(cwd: AbsolutePathBuf) -> Result { - let home_dir = vp_shared::get_vp_home()?; - let node_dir = home_dir.join("js_runtime").join("node"); - let package_manager_dir = home_dir.join("package_manager"); + let dirs = vp_shared::Dirs::get(); + let node_dir = dirs.js_runtime_dir().join("node"); + let package_manager_dir = dirs.package_manager_dir(); let protected_versions = protected_node_versions(&cwd).await?; let corepack_cleaned = run_corepack_cache_clean(&cwd).await?; @@ -138,7 +138,7 @@ async fn corepack_cache_clean_would_auto_install( cwd: &AbsolutePathBuf, corepack_path: &AbsolutePath, ) -> Result { - let bin_dir = config::get_bin_dir()?; + let bin_dir = vp_shared::Dirs::get().bin_dir(); if corepack_path.parent() != Some(&bin_dir) { return Ok(false); } diff --git a/crates/vp_global_cli/src/commands/env/config.rs b/crates/vp_global_cli/src/commands/env/config.rs index 38cd5805b9..125e19318e 100644 --- a/crates/vp_global_cli/src/commands/env/config.rs +++ b/crates/vp_global_cli/src/commands/env/config.rs @@ -1,22 +1,21 @@ //! Configuration and version resolution for the env command. //! //! This module provides: -//! - VP_HOME path resolution //! - Version resolution with priority order //! - Config file management +//! +//! On-disk locations come from [`vp_shared::Dirs`]. use serde::{Deserialize, Serialize}; use vp_js_runtime::{ NodeProvider, VersionSource, is_valid_version, normalize_version, read_nvmrc_file, read_package_json, resolve_node_version, }; +use vp_shared::Dirs; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::error::Error; -/// Config file name -const CONFIG_FILE: &str = "config.json"; - /// Shim mode determines how shims resolve tools. #[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -61,23 +60,6 @@ pub struct VersionResolution { pub is_range: bool, } -/// Get the VP_HOME directory path. -/// -/// Uses `VP_HOME` environment variable if set, otherwise defaults to `~/.vite-plus`. -pub fn get_vp_home() -> Result { - Ok(vp_shared::get_vp_home()?) -} - -/// Get the bin directory path (~/.vite-plus/bin/). -pub fn get_bin_dir() -> Result { - Ok(get_vp_home()?.join("bin")) -} - -/// Get the packages directory path (~/.vite-plus/packages/). -pub fn get_packages_dir() -> Result { - Ok(get_vp_home()?.join("packages")) -} - /// Get the node_modules directory path for a package. /// /// npm uses different layouts on Unix vs Windows: @@ -110,14 +92,9 @@ pub fn get_node_modules_dir(prefix: &AbsolutePath, package_name: &str) -> Absolu } } -/// Get the config file path. -pub fn get_config_path() -> Result { - Ok(get_vp_home()?.join(CONFIG_FILE)) -} - /// Load configuration from disk. pub async fn load_config() -> Result { - let config_path = get_config_path()?; + let config_path = Dirs::get().config_file(); if !tokio::fs::try_exists(&config_path).await.unwrap_or(false) { return Ok(Config::default()); @@ -130,11 +107,11 @@ pub async fn load_config() -> Result { /// Save configuration to disk. pub async fn save_config(config: &Config) -> Result<(), Error> { - let config_path = get_config_path()?; - let vite_plus_home = get_vp_home()?; + let dirs = Dirs::get(); + let config_path = dirs.config_file(); // Ensure directory exists - tokio::fs::create_dir_all(&vite_plus_home).await?; + tokio::fs::create_dir_all(&dirs.config_dir()).await?; let content = serde_json::to_string_pretty(config)?; tokio::fs::write(&config_path, content).await?; @@ -148,14 +125,9 @@ pub const VERSION_ENV_VAR: &str = vp_shared::env_vars::VP_NODE_VERSION; /// Session version file name, written by `vp env use` so shims work without the shell eval wrapper. pub const SESSION_VERSION_FILE: &str = ".session-node-version"; -/// Get the path to the session version file (~/.vite-plus/.session-node-version). -pub fn get_session_version_path() -> Result { - Ok(get_vp_home()?.join(SESSION_VERSION_FILE)) -} - /// Read the session version file. Returns `None` if the file is missing or empty. pub async fn read_session_version() -> Option { - let path = get_session_version_path().ok()?; + let path = Dirs::get().session_node_version_file(); let content = tokio::fs::read_to_string(&path).await.ok()?; let trimmed = content.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } @@ -163,7 +135,7 @@ pub async fn read_session_version() -> Option { /// Read the session version file synchronously. Returns `None` if the file is missing or empty. pub fn read_session_version_sync() -> Option { - let path = get_session_version_path().ok()?; + let path = Dirs::get().session_node_version_file(); let content = std::fs::read_to_string(path.as_path()).ok()?; let trimmed = content.trim().to_string(); if trimmed.is_empty() { None } else { Some(trimmed) } @@ -171,7 +143,7 @@ pub fn read_session_version_sync() -> Option { /// Write the resolved version to the session version file. pub async fn write_session_version(version: &str) -> Result<(), Error> { - let path = get_session_version_path()?; + let path = Dirs::get().session_node_version_file(); // Ensure parent directory exists if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; @@ -182,7 +154,7 @@ pub async fn write_session_version(version: &str) -> Result<(), Error> { /// Delete the session version file. Ignores "not found" errors. pub async fn delete_session_version() -> Result<(), Error> { - let path = get_session_version_path()?; + let path = Dirs::get().session_node_version_file(); match tokio::fs::remove_file(&path).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -221,7 +193,7 @@ pub async fn resolve_version(cwd: &AbsolutePath) -> Result Result Result Result { match config.default_node_version { Some(version) => { println!("Default Node.js version: {version}"); - let config_path = get_config_path()?; + let config_path = vp_shared::Dirs::get().config_file(); println!(" Set via: {}", config_path.as_path().display()); // If it's an alias, also show the resolved version diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index 6ce79f0473..94ba683bea 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -3,10 +3,10 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; -use vp_shared::{env_vars, output}; +use vp_shared::{Dirs, env_vars, output}; use vt_path::{AbsolutePathBuf, current_dir}; -use super::config::{self, ShimMode, get_bin_dir, get_vp_home, load_config, resolve_version}; +use super::config::{self, ShimMode, load_config, resolve_version}; use crate::{ commands::shell::{ALL_SHELL_PROFILES, IDE_SHELL_PROFILES, ShellProfile, resolve_profile_path}, error::Error, @@ -110,9 +110,7 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { Some(EnvSourcingStatus::IdeFound) | None => {} // All good, no guidance needed Some(EnvSourcingStatus::ShellOnly | EnvSourcingStatus::NotFound) => { // Show IDE setup guidance when env is not in IDE-relevant profiles - if let Ok(bin_dir) = get_bin_dir() { - print_ide_setup_guidance(&bin_dir); - } + print_ide_setup_guidance(&Dirs::get().env_scripts_dir()); } } @@ -130,29 +128,21 @@ pub async fn execute(cwd: AbsolutePathBuf) -> Result { } } -/// Check VP_HOME directory. +/// Check the vite-plus home directory (the legacy root under the `Home` +/// layout, the data directory under the split layout — same path on disk +/// under `Home`). async fn check_vite_plus_home() -> bool { - let home = match get_vp_home() { - Ok(h) => h, - Err(e) => { - print_check( - &output::CROSS.red().to_string(), - env_vars::VP_HOME, - &format!("{e}").red().to_string(), - ); - return false; - } - }; + let home = Dirs::get().data_dir(); let display = abbreviate_home(&home.as_path().display().to_string()); if tokio::fs::try_exists(&home).await.unwrap_or(false) { - print_check(&output::CHECK.green().to_string(), env_vars::VP_HOME, &display); + print_check(&output::CHECK.green().to_string(), "Home directory", &display); true } else { print_check( &output::CROSS.red().to_string(), - env_vars::VP_HOME, + "Home directory", &"does not exist".red().to_string(), ); print_hint("Run 'vp env setup' to create it."); @@ -162,10 +152,7 @@ async fn check_vite_plus_home() -> bool { /// Check bin directory and shim files. async fn check_bin_dir() -> bool { - let bin_dir = match get_bin_dir() { - Ok(d) => d, - Err(_) => return false, - }; + let bin_dir = Dirs::get().bin_dir(); if !tokio::fs::try_exists(&bin_dir).await.unwrap_or(false) { print_check( @@ -265,15 +252,9 @@ async fn check_shim_mode() -> (ShimMode, Option) { /// Tries IDE-relevant profiles first, then falls back to all shell profiles. /// Returns `EnvSourcingStatus` indicating where (if anywhere) the sourcing was found. fn check_env_sourcing() -> EnvSourcingStatus { - let bin_dir = match get_bin_dir() { - Ok(d) => d, - Err(_) => return EnvSourcingStatus::NotFound, - }; + let env_dir = Dirs::get().env_scripts_dir(); - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); + let home_path = env_dir.as_path().display().to_string(); let home_path = if let Ok(home_dir) = std::env::var("HOME") { if let Some(suffix) = home_path.strip_prefix(&home_dir) { format!("$HOME{suffix}") @@ -339,10 +320,7 @@ fn check_session_override() { /// Check PATH configuration. async fn check_path() -> bool { - let bin_dir = match get_bin_dir() { - Ok(d) => d, - Err(_) => return false, - }; + let bin_dir = Dirs::get().bin_dir(); let path_var = std::env::var_os("PATH").unwrap_or_default(); let paths: Vec<_> = std::env::split_paths(&path_var).collect(); @@ -359,7 +337,7 @@ async fn check_path() -> bool { print_check(&output::CROSS.red().to_string(), "vp", &"not in PATH".red().to_string()); print_hint(&format!("Expected: {bin_display}")); println!(); - print_path_fix(&bin_dir); + print_path_fix(&Dirs::get().env_scripts_dir()); return false; } @@ -396,14 +374,11 @@ fn find_in_path(name: &str) -> Option { } /// Print PATH fix instructions for shell setup. -fn print_path_fix(bin_dir: &vt_path::AbsolutePath) { +fn print_path_fix(env_dir: &vt_path::AbsolutePath) { #[cfg(not(windows))] { - // Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); + // Use the $HOME prefix for readability when the env dir is under $HOME + let home_path = env_dir.as_path().display().to_string(); let home_path = if let Ok(home_dir) = std::env::var("HOME") { if let Some(suffix) = home_path.strip_prefix(&home_dir) { format!("$HOME{suffix}") @@ -431,7 +406,7 @@ fn print_path_fix(bin_dir: &vt_path::AbsolutePath) { #[cfg(windows)] { - let _ = bin_dir; + let _ = env_dir; println!(" {}", "Add the bin directory to your PATH via:".dimmed()); println!(" System Properties -> Environment Variables -> Path"); println!(); @@ -469,12 +444,9 @@ fn check_profile_files(vite_plus_home: &str, profile_files: &[ShellProfile]) -> } /// Print IDE setup guidance for GUI applications. -fn print_ide_setup_guidance(bin_dir: &vt_path::AbsolutePath) { - // Derive vite_plus_home display path from bin_dir.parent(), using $HOME prefix - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); +fn print_ide_setup_guidance(env_dir: &vt_path::AbsolutePath) { + // Use the $HOME prefix for readability when the env dir is under $HOME + let home_path = env_dir.as_path().display().to_string(); let home_path = if let Ok(home_dir) = std::env::var("HOME") { if let Some(suffix) = home_path.strip_prefix(&home_dir) { format!("$HOME{suffix}") @@ -571,10 +543,7 @@ async fn check_current_resolution( print_check(" ", "Version", &resolution.version.bright_green().to_string()); // Check if Node.js is installed - let home_dir = match vp_shared::get_vp_home() { - Ok(d) => d.join("js_runtime").join("node").join(&resolution.version), - Err(_) => return None, - }; + let home_dir = Dirs::get().js_runtime_dir().join("node").join(&resolution.version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index 2bd20a2a8b..8251f77972 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -52,8 +52,7 @@ fn compare_versions(a: &str, b: &str) -> Ordering { /// Execute the list command (local installed versions). pub async fn execute(cwd: AbsolutePathBuf, json_output: bool) -> Result { - let home_dir = vp_shared::get_vp_home()?; - let node_dir = home_dir.join("js_runtime").join("node"); + let node_dir = vp_shared::Dirs::get().js_runtime_dir().join("node"); let versions = list_installed_versions(node_dir.as_path()); diff --git a/crates/vp_global_cli/src/commands/env/list_remote.rs b/crates/vp_global_cli/src/commands/env/list_remote.rs index 81b3317c8e..ca88f73b09 100644 --- a/crates/vp_global_cli/src/commands/env/list_remote.rs +++ b/crates/vp_global_cli/src/commands/env/list_remote.rs @@ -103,10 +103,7 @@ async fn local_markers(cwd: &AbsolutePathBuf, provider: &NodeProvider) -> LocalM /// Collect the set of locally installed Node.js versions (without `v` prefix). fn installed_versions() -> std::collections::HashSet { - let Ok(home_dir) = vp_shared::get_vp_home() else { - return std::collections::HashSet::new(); - }; - let node_dir = home_dir.join("js_runtime").join("node"); + let node_dir = vp_shared::Dirs::get().js_runtime_dir().join("node"); super::list::list_installed_versions(node_dir.as_path()).into_iter().collect() } diff --git a/crates/vp_global_cli/src/commands/env/mod.rs b/crates/vp_global_cli/src/commands/env/mod.rs index bae8bccd8c..939a38383b 100644 --- a/crates/vp_global_cli/src/commands/env/mod.rs +++ b/crates/vp_global_cli/src/commands/env/mod.rs @@ -109,8 +109,8 @@ pub async fn execute(cwd: AbsolutePathBuf, args: EnvArgs) -> Result { let provider = vp_js_runtime::NodeProvider::new(); let resolved = config::resolve_version_alias(&version, &provider).await?; - let home_dir = vp_shared::get_vp_home()?; - let version_dir = home_dir.join("js_runtime").join("node").join(&resolved); + let version_dir = + vp_shared::Dirs::get().js_runtime_dir().join("node").join(&resolved); if !version_dir.as_path().exists() { eprintln!("Node.js v{} is not installed", resolved); return Ok(exit_status(1)); diff --git a/crates/vp_global_cli/src/commands/env/package_metadata.rs b/crates/vp_global_cli/src/commands/env/package_metadata.rs index 21eadc1048..2076c199dd 100644 --- a/crates/vp_global_cli/src/commands/env/package_metadata.rs +++ b/crates/vp_global_cli/src/commands/env/package_metadata.rs @@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize}; use uuid::{Uuid, Version}; use vt_path::AbsolutePathBuf; -use super::config::get_packages_dir; use crate::error::Error; // This is legacy, for old Vite+ version's compatibility @@ -117,7 +116,7 @@ impl PackageMetadata { package_name: &str, install_id: &str, ) -> Result { - let packages_dir = get_packages_dir()?; + let packages_dir = vp_shared::Dirs::get().packages_dir(); let package_dir = packages_dir.join(package_name); if install_id.is_empty() { Ok(package_dir) @@ -134,7 +133,7 @@ impl PackageMetadata { /// Get the metadata file path for a package. pub fn metadata_path(package_name: &str) -> Result { - let packages_dir = get_packages_dir()?; + let packages_dir = vp_shared::Dirs::get().packages_dir(); Ok(packages_dir.join(format!("{package_name}.json"))) } @@ -173,7 +172,7 @@ impl PackageMetadata { /// List all installed packages. pub async fn list_all() -> Result, Error> { - let packages_dir = get_packages_dir()?; + let packages_dir = vp_shared::Dirs::get().packages_dir(); if !tokio::fs::try_exists(&packages_dir).await.unwrap_or(false) { return Ok(Vec::new()); } @@ -358,9 +357,15 @@ mod tests { let result = metadata.save().await; assert!(result.is_ok(), "Failed to save scoped package metadata: {:?}", result.err()); - // Verify the file exists at the correct location - let expected_path = temp_path.join("packages").join("@scope").join("test-pkg.json"); - assert!(expected_path.exists(), "Metadata file not found at {:?}", expected_path); + // Verify the file exists at the correct location (under the resolved + // packages directory for the sandboxed home). + let expected_path = + vp_shared::Dirs::get().packages_dir().join("@scope").join("test-pkg.json"); + assert!( + expected_path.as_path().exists(), + "Metadata file not found at {:?}", + expected_path.as_path() + ); } #[tokio::test] diff --git a/crates/vp_global_cli/src/commands/env/pin.rs b/crates/vp_global_cli/src/commands/env/pin.rs index 23b3468373..fc752843da 100644 --- a/crates/vp_global_cli/src/commands/env/pin.rs +++ b/crates/vp_global_cli/src/commands/env/pin.rs @@ -13,7 +13,7 @@ use vp_js_runtime::NodeProvider; use vp_shared::output; use vt_path::AbsolutePathBuf; -use super::config::{get_config_path, load_config}; +use super::config::load_config; use crate::{cli::PinTarget, error::Error}; /// Node version file name @@ -76,7 +76,7 @@ async fn show_pinned(cwd: &AbsolutePathBuf) -> Result { let config = load_config().await?; match config.default_node_version { Some(version) => { - let config_path = get_config_path()?; + let config_path = vp_shared::Dirs::get().config_file(); println!("No version pinned."); println!(" Using default: {version} (from {})", config_path.as_path().display()); } @@ -583,7 +583,6 @@ pub async fn do_unpin( #[cfg(test)] mod tests { - use serial_test::serial; use tempfile::TempDir; use vt_path::AbsolutePathBuf; @@ -690,19 +689,14 @@ mod tests { } #[tokio::test] - // Run serially: mutates VP_HOME env var which affects invalidate_cache() - #[serial] async fn test_do_unpin_invalidates_cache() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Point VP_HOME to temp dir - unsafe { - std::env::set_var(vp_shared::env_vars::VP_HOME, temp_path.as_path()); - } - - // Create cache file manually - let cache_dir = temp_path.join("cache"); + // Sandboxed legacy layout: the on-disk `.vite-plus` under the + // overridden user home selects it, so the resolve cache lives at + // `/.vite-plus/cache/resolve_cache.json`. + let cache_dir = temp_path.join(".vite-plus").join("cache"); std::fs::create_dir_all(&cache_dir).unwrap(); let cache_file = cache_dir.join("resolve_cache.json"); std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); @@ -710,6 +704,9 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist before unpin" ); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_path.as_path(), + )); // Create .node-version and unpin let node_version_path = temp_path.join(".node-version"); @@ -722,27 +719,15 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_err(), "Cache file should be removed after unpin" ); - - // Cleanup - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } } - // Run serially: mutates VP_HOME env var which affects invalidate_cache() #[tokio::test] - #[serial] async fn test_do_pin_invalidates_cache() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Point VP_HOME to temp dir - unsafe { - std::env::set_var(vp_shared::env_vars::VP_HOME, temp_path.as_path()); - } - - // Create cache file manually - let cache_dir = temp_path.join("cache"); + // Sandboxed legacy layout (see test_do_unpin_invalidates_cache). + let cache_dir = temp_path.join(".vite-plus").join("cache"); std::fs::create_dir_all(&cache_dir).unwrap(); let cache_file = cache_dir.join("resolve_cache.json"); std::fs::write(&cache_file, r#"{"version":2,"entries":{}}"#).unwrap(); @@ -750,6 +735,9 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist before pin" ); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_path.as_path(), + )); // Pin an exact version (no_install=true to skip download, force=true to skip prompt) let result = do_pin(&temp_path, "20.18.0", true, true, None).await; @@ -766,11 +754,6 @@ mod tests { std::fs::metadata(cache_file.as_path()).is_err(), "Cache file should be removed after pin" ); - - // Cleanup - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } } #[tokio::test] diff --git a/crates/vp_global_cli/src/commands/env/setup.rs b/crates/vp_global_cli/src/commands/env/setup.rs index ad4fd9c237..b4e574bdba 100644 --- a/crates/vp_global_cli/src/commands/env/setup.rs +++ b/crates/vp_global_cli/src/commands/env/setup.rs @@ -1,8 +1,9 @@ //! Setup command implementation for creating bin directory and shims. //! -//! Creates the following structure: -//! - ~/.vite-plus/bin/ - Contains vp symlink and node/npm/npx/corepack shims -//! - ~/.vite-plus/current/ - Contains the actual vp CLI binary +//! Creates the following structure (legacy layout shown; under the split +//! layout the bin dir and data dir are separate, see `vp_shared::Dirs`): +//! - / - Contains vp symlink and node/npm/npx/corepack shims +//! - /current/ - Contains the actual vp CLI binary //! //! On Unix: //! - bin/vp is a symlink to the active vp binary @@ -18,8 +19,8 @@ use std::process::ExitStatus; use owo_colors::OwoColorize; +use vp_shared::Dirs; -use super::config::{get_bin_dir, get_vp_home}; use crate::{error::Error, help}; /// Shells that get a generated `~/.vite-plus/env.*` setup script. @@ -56,13 +57,13 @@ fn accent_command(command: &str) -> String { /// Execute the setup command. pub async fn execute(refresh: bool, env_only: bool) -> Result { - let vite_plus_home = get_vp_home()?; + let dirs = Dirs::get(); - // Ensure home directory exists (env files are written here) - tokio::fs::create_dir_all(&vite_plus_home).await?; + // Ensure the env-scripts directory exists (env files are written here) + tokio::fs::create_dir_all(&dirs.env_scripts_dir()).await?; // Create env files with PATH guard (prevents duplicate PATH entries) - create_env_files(&vite_plus_home).await?; + create_env_files(&dirs).await?; if env_only { println!("{}", help::render_heading("Setup")); @@ -71,7 +72,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result return Ok(ExitStatus::default()); } - let bin_dir = get_bin_dir()?; + let bin_dir = dirs.bin_dir(); println!("{}", help::render_heading("Setup")); println!(" Preparing vite-plus environment."); @@ -154,7 +155,7 @@ pub async fn execute(refresh: bool, env_only: bool) -> Result } println!(); - print_path_instructions(&bin_dir); + print_path_instructions(&dirs.env_scripts_dir()); Ok(ExitStatus::default()) } @@ -529,7 +530,6 @@ pub(crate) async fn cleanup_legacy_windows_shim(bin_dir: &vt_path::AbsolutePath, // Includes shell completion support const ENV_TEMPLATE_POSIX: &str = r#"#!/bin/sh # Vite+ environment setup (https://viteplus.dev) -export VP_HOME="__VP_HOME__" __vp_bin="__VP_BIN__" case ":${PATH}:" in *":${__vp_bin}:"*) @@ -577,7 +577,6 @@ fi "#; const ENV_TEMPLATE_FISH: &str = r#"# Vite+ environment setup (https://viteplus.dev) -set -gx VP_HOME "__VP_HOME__" set -l __vp_idx (contains -i -- __VP_BIN__ $PATH) and set -e PATH[$__vp_idx] set -gx PATH __VP_BIN__ $PATH @@ -613,7 +612,6 @@ complete -c vpr --keep-order --exclusive --arguments "(__vpr_complete)" // Completions delegate to Fish dynamically (VP_COMPLETE=fish) because clap_complete_nushell // generates multiple rest params (e.g. for `vp install`), which Nushell does not support. const ENV_TEMPLATE_NU: &str = r#"# Vite+ environment setup (https://viteplus.dev) -$env.VP_HOME = ("__VP_HOME__" | path expand --no-symlink) $env.PATH = ($env.PATH | where { $in != "__VP_BIN__" } | prepend "__VP_BIN__") # Shell function wrapper: intercepts `vp env use` to parse its stdout, @@ -674,7 +672,6 @@ export extern "vpr" [...args: string@"nu-complete vpr"] "#; const ENV_TEMPLATE_PS1: &str = r#"# Vite+ environment setup (https://viteplus.dev) -$env:VP_HOME = "__VP_HOME_WIN__" $__vp_bin = "__VP_BIN_WIN__" if ($env:Path -split ';' -notcontains $__vp_bin) { $env:Path = "$__vp_bin;$env:Path" @@ -735,8 +732,10 @@ Register-ArgumentCompleter -Native -CommandName vpr -ScriptBlock $__vpr_comp // cmd.exe wrapper for `vp env use` (cmd.exe cannot define shell functions). // Users run `vp-use 24` in cmd.exe instead of `vp env use 24`. +// Locates the real vp.exe next to the bin dir: `\current\bin\vp.exe` +// (legacy layout) or `\data\current\bin\vp.exe` (split layout). #[cfg(windows)] -const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nset VP_HOME=%~dp0..\r\nfor /f \"delims=\" %%i in ('%~dp0..\\current\\bin\\vp.exe env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; +const VP_USE_CMD_CONTENT: &str = "@echo off\r\nset VP_ENV_USE_EVAL_ENABLE=1\r\nset \"VP_EXE=%~dp0..\\current\\bin\\vp.exe\"\r\nif not exist \"%VP_EXE%\" set \"VP_EXE=%~dp0..\\data\\current\\bin\\vp.exe\"\r\nfor /f \"delims=\" %%i in ('%VP_EXE% env use %*') do %%i\r\nset VP_ENV_USE_EVAL_ENABLE=\r\n"; fn render_home_relative_path(path: &std::path::Path, home_dir: Option<&std::path::Path>) -> String { // Use $HOME-relative path if install dir is under HOME (like rustup's ~/.cargo/env). @@ -762,37 +761,26 @@ fn render_nu_path_ref(path_ref: &str) -> String { } } -/// Render the env-file content for `shell` against `vite_plus_home`. -fn render_env_content(shell: EnvShell, vite_plus_home: &vt_path::AbsolutePath) -> String { - let bin_path = vite_plus_home.join("bin"); +/// Render the env-file content for `shell` against the resolved [`Dirs`]. +fn render_env_content(shell: EnvShell, dirs: &Dirs) -> String { + let bin_path = dirs.bin_dir(); let home_dir = vp_shared::EnvConfig::get().user_home; let home_dir = home_dir.as_deref(); - let home_path_ref = render_home_relative_path(vite_plus_home.as_path(), home_dir); let bin_path_ref = render_home_relative_path(bin_path.as_path(), home_dir); match shell { - EnvShell::Posix => ENV_TEMPLATE_POSIX - .replace("__VP_HOME__", &home_path_ref) - .replace("__VP_BIN__", &bin_path_ref), - EnvShell::Fish => ENV_TEMPLATE_FISH - .replace("__VP_HOME__", &home_path_ref) - .replace("__VP_BIN__", &bin_path_ref), + EnvShell::Posix => ENV_TEMPLATE_POSIX.replace("__VP_BIN__", &bin_path_ref), + EnvShell::Fish => ENV_TEMPLATE_FISH.replace("__VP_BIN__", &bin_path_ref), EnvShell::Nu => { // Nushell requires `~` instead of `$HOME` in string literals — `$HOME` is not // expanded at parse time, so PATH entries would contain a literal "$HOME/...". - let home_path_ref_nu = render_nu_path_ref(&home_path_ref); let bin_path_ref_nu = render_nu_path_ref(&bin_path_ref); - ENV_TEMPLATE_NU - .replace("__VP_HOME__", &home_path_ref_nu) - .replace("__VP_BIN__", &bin_path_ref_nu) + ENV_TEMPLATE_NU.replace("__VP_BIN__", &bin_path_ref_nu) } EnvShell::Powershell => { // PowerShell uses the actual absolute path (not $HOME-relative) - let home_path_win = vite_plus_home.as_path().display().to_string(); let bin_path_win = bin_path.as_path().display().to_string(); - ENV_TEMPLATE_PS1 - .replace("__VP_HOME_WIN__", &home_path_win) - .replace("__VP_BIN_WIN__", &bin_path_win) + ENV_TEMPLATE_PS1.replace("__VP_BIN_WIN__", &bin_path_win) } } } @@ -804,22 +792,20 @@ fn render_env_content(shell: EnvShell, vite_plus_home: &vt_path::AbsolutePath) - /// - `~/.vite-plus/env.fish` (fish shell) with `vp` wrapper function /// - `~/.vite-plus/env.nu` (Nushell) with `vp env use` wrapper function /// - `~/.vite-plus/env.ps1` (PowerShell) with PATH setup + `vp` function -async fn create_env_files(vite_plus_home: &vt_path::AbsolutePath) -> Result<(), Error> { +async fn create_env_files(dirs: &Dirs) -> Result<(), Error> { + let env_dir = dirs.env_scripts_dir(); for shell in [EnvShell::Posix, EnvShell::Fish, EnvShell::Nu, EnvShell::Powershell] { - let content = render_env_content(shell, vite_plus_home); - tokio::fs::write(vite_plus_home.join(shell.env_file_name()), content).await?; + let content = render_env_content(shell, dirs); + tokio::fs::write(env_dir.join(shell.env_file_name()), content).await?; } Ok(()) } -/// Print instructions for adding bin directory to PATH. -fn print_path_instructions(bin_dir: &vt_path::AbsolutePath) { - // Derive vite_plus_home from bin_dir (parent), using $HOME prefix for readability - let home_path = bin_dir - .parent() - .map(|p| p.as_path().display().to_string()) - .unwrap_or_else(|| bin_dir.as_path().display().to_string()); +/// Print instructions for sourcing the env files from `env_dir`. +fn print_path_instructions(env_dir: &vt_path::AbsolutePath) { + // Use the $HOME prefix for readability when the env dir is under $HOME + let home_path = env_dir.as_path().display().to_string(); let (home_path, nu_home_path) = if let Ok(home_dir) = std::env::var("HOME") { if let Some(suffix) = home_path.strip_prefix(&home_dir) { // POSIX/Fish use $HOME; Nushell's `source` is a parse-time keyword @@ -889,6 +875,17 @@ mod tests { assert!(!crate::commands::global::CORE_SHIMS.contains(&"corepack")); } + /// Set up a sandboxed legacy layout for env-file rendering tests: user + /// home at `home` with the legacy root `/.vite-plus` created on + /// disk so `Dirs` selects the monolithic layout. Returns the EnvConfig + /// guard and the legacy root. + fn legacy_home(home: &std::path::Path) -> (vp_shared::TestEnvGuard, AbsolutePathBuf) { + let root = home.join(".vite-plus"); + std::fs::create_dir_all(&root).unwrap(); + let guard = home_guard(home); + (guard, AbsolutePathBuf::new(root).unwrap()) + } + /// Helper: create a test_guard with user_home set to the given path. fn home_guard(home: impl Into) -> vp_shared::TestEnvGuard { vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { @@ -931,10 +928,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_creates_all_files() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let env_path = home.join("env"); let env_fish_path = home.join("env.fish"); @@ -949,10 +945,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_nu_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let nu_content = tokio::fs::read_to_string(home.join("env.nu")).await.unwrap(); assert!( @@ -960,8 +955,8 @@ mod tests { "env.nu should not contain __VP_BIN__ placeholder" ); assert!( - nu_content.contains("~/bin"), - "env.nu should reference ~/bin (not $HOME/bin — Nushell does not expand $HOME in string literals)" + nu_content.contains("~/.vite-plus/bin"), + "env.nu should reference ~/.vite-plus/bin (not $HOME/bin — Nushell does not expand $HOME in string literals)" ); assert!( nu_content.contains("VP_ENV_USE_EVAL_ENABLE"), @@ -977,11 +972,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_replaces_placeholder_with_home_relative_path() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().join("vp_home")).unwrap(); - let _guard = home_guard(temp_dir.path()); - tokio::fs::create_dir_all(&home).await.unwrap(); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -997,64 +990,67 @@ mod tests { !fish_content.contains("__VP_BIN__"), "env.fish file should not contain __VP_BIN__ placeholder" ); - assert!( - !env_content.contains("__VP_HOME__") && !fish_content.contains("__VP_HOME__"), - "env files should not contain __VP_HOME__ placeholder" - ); - assert!( - !nu_content.contains("__VP_HOME__") && !ps1_content.contains("__VP_HOME_WIN__"), - "env files should not contain VP_HOME placeholders" - ); - // Should use $HOME-relative path since install dir is under HOME - assert!( - env_content.contains("$HOME/vp_home/bin"), - "env file should reference $HOME/vp_home/bin, got: {env_content}" - ); - assert!( - fish_content.contains("$HOME/vp_home/bin"), - "env.fish file should reference $HOME/vp_home/bin, got: {fish_content}" - ); - assert!( - env_content.contains("export VP_HOME=\"$HOME/vp_home\""), - "env file should export VP_HOME, got: {env_content}" - ); + // VP_HOME is gone: the CLI locates its install root from the + // executable path, so the env scripts must not set it. + for (name, content) in [ + ("env", &env_content), + ("env.fish", &fish_content), + ("env.nu", &nu_content), + ("env.ps1", &ps1_content), + ] { + assert!( + !content.contains("VP_HOME"), + "{name} should not reference VP_HOME, got: {content}" + ); + } + + // Should use $HOME-relative path since the bin dir is under HOME assert!( - fish_content.contains("set -gx VP_HOME \"$HOME/vp_home\""), - "env.fish file should export VP_HOME, got: {fish_content}" + env_content.contains("$HOME/.vite-plus/bin"), + "env file should reference $HOME/.vite-plus/bin, got: {env_content}" ); assert!( - nu_content.contains("$env.VP_HOME = (\"~/vp_home\" | path expand --no-symlink)"), - "env.nu file should set home-relative VP_HOME, got: {nu_content}" + fish_content.contains("$HOME/.vite-plus/bin"), + "env.fish file should reference $HOME/.vite-plus/bin, got: {fish_content}" ); assert!( - nu_content.contains("~/vp_home/bin"), - "env.nu file should reference ~/vp_home/bin, got: {nu_content}" + nu_content.contains("~/.vite-plus/bin"), + "env.nu file should reference ~/.vite-plus/bin, got: {nu_content}" ); - let expected_home = home.as_path().display().to_string(); + let expected_bin = home.join("bin").as_path().display().to_string(); assert!( - ps1_content.contains(&format!("$env:VP_HOME = \"{expected_home}\"")), - "env.ps1 file should set VP_HOME, got: {ps1_content}" + ps1_content.contains(&format!("$__vp_bin = \"{expected_bin}\"")), + "env.ps1 file should set the bin dir, got: {ps1_content}" ); } #[tokio::test] - async fn test_create_env_files_uses_absolute_path_when_not_under_home() { + async fn test_create_env_files_uses_absolute_path_when_bin_not_under_home() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Set user_home to a different path so install dir is NOT under HOME - let _guard = home_guard("/nonexistent-home-dir"); + let home = temp_dir.path().join("home"); + // Bin directory outside HOME via VP_BIN_DIR override (split layout). + let outside_bin = temp_dir.path().join("outside-bin"); + std::fs::create_dir_all(&outside_bin).unwrap(); + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { + vp_bin_dir: Some(outside_bin.clone()), + ..vp_shared::EnvConfig::for_test_with_home(&home) + }); - create_env_files(&home).await.unwrap(); + let dirs = Dirs::get(); + assert!(!dirs.is_legacy_layout(), "no .vite-plus under home → split layout"); + tokio::fs::create_dir_all(dirs.env_scripts_dir().as_path()).await.unwrap(); - let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); - let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); + create_env_files(&dirs).await.unwrap(); - // Should use absolute path since install dir is not under HOME - let expected_bin = home.join("bin"); - let expected_str = expected_bin.as_path().display().to_string().replace('\\', "/"); - let expected_home = home.as_path().display().to_string().replace('\\', "/"); + let env_content = + tokio::fs::read_to_string(dirs.env_scripts_dir().join("env")).await.unwrap(); + let fish_content = + tokio::fs::read_to_string(dirs.env_scripts_dir().join("env.fish")).await.unwrap(); + + // Should use the absolute path since the bin dir is not under HOME + let expected_str = outside_bin.display().to_string().replace('\\', "/"); assert!( env_content.contains(&expected_str), "env file should use absolute path {expected_str}, got: {env_content}" @@ -1063,26 +1059,32 @@ mod tests { fish_content.contains(&expected_str), "env.fish file should use absolute path {expected_str}, got: {fish_content}" ); + + // Should NOT use a $HOME-relative path for the bin dir assert!( - env_content.contains(&format!("export VP_HOME=\"{expected_home}\"")), - "env file should export absolute VP_HOME {expected_home}, got: {env_content}" - ); - assert!( - fish_content.contains(&format!("set -gx VP_HOME \"{expected_home}\"")), - "env.fish file should export absolute VP_HOME {expected_home}, got: {fish_content}" + !env_content.contains("export PATH=\"$HOME"), + "env file should not reference a $HOME-relative bin, got: {env_content}" ); + } - // Should NOT use $HOME-relative path - assert!(!env_content.contains("$HOME/bin"), "env file should not reference $HOME/bin"); + #[test] + fn test_render_home_relative_path_falls_back_to_absolute_outside_home() { + let (path, home) = if cfg!(windows) { + (r"C:\install\vp", r"C:\Users\vp") + } else { + ("/opt/vp", "/home/vp") + }; + let rendered = + render_home_relative_path(std::path::Path::new(path), Some(std::path::Path::new(home))); + assert_eq!(rendered, path.replace('\\', "/")); } #[tokio::test] async fn test_create_env_files_posix_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); @@ -1110,10 +1112,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_fish_contains_path_guard() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -1132,16 +1133,15 @@ mod tests { #[tokio::test] async fn test_create_env_files_is_idempotent() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); // Create env files twice - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let first_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let first_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); let first_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let second_env = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let second_fish = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); let second_ps1 = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); @@ -1154,10 +1154,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_posix_contains_vp_shell_function() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); @@ -1181,10 +1180,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_fish_contains_vp_function() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); @@ -1203,10 +1201,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_ps1_contains_vp_function() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let ps1_content = tokio::fs::read_to_string(home.join("env.ps1")).await.unwrap(); @@ -1225,27 +1222,30 @@ mod tests { #[serial_test::serial] async fn test_execute_creates_cmd_wrapper_in_fresh_home() { let temp_dir = TempDir::new().unwrap(); - let fresh_home = temp_dir.path().join("new-vite-plus"); let _trampoline_guard = FakeTrampolineGuard::new(temp_dir.path()); - let _env_guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vite_plus_home: Some(fresh_home.clone()), - user_home: Some(temp_dir.path().to_path_buf()), - ..vp_shared::EnvConfig::for_test() - }); + // Fresh home (no `.vite-plus` yet): the split layout is selected and + // setup creates the bin directory. + let _env_guard = vp_shared::EnvConfig::test_guard( + vp_shared::EnvConfig::for_test_with_home(temp_dir.path()), + ); - assert!(!fresh_home.exists(), "VP_HOME should not exist before initial setup"); + let bin_dir = Dirs::get().bin_dir(); + assert!(!bin_dir.as_path().exists(), "bin dir should not exist before initial setup"); let status = execute(false, false).await.unwrap(); assert!(status.success(), "initial vp env setup should succeed"); - let bin_dir = AbsolutePathBuf::new(fresh_home.join("bin")).unwrap(); let cmd_content = tokio::fs::read_to_string(bin_dir.join("vp-use.cmd")).await.unwrap(); assert!( - cmd_content.contains("set VP_HOME=%~dp0..\r\nfor /f"), - "vp-use.cmd should set VP_HOME before invoking vp env use, got: {cmd_content}" + !cmd_content.contains("VP_HOME"), + "vp-use.cmd should not set VP_HOME, got: {cmd_content}" ); assert!( - cmd_content.contains("%~dp0..\\current\\bin\\vp.exe env use %*"), - "vp-use.cmd should invoke the install-local vp.exe" + cmd_content.contains("%~dp0..\\current\\bin\\vp.exe"), + "vp-use.cmd should try the legacy-layout vp.exe first, got: {cmd_content}" + ); + assert!( + cmd_content.contains("%~dp0..\\data\\current\\bin\\vp.exe"), + "vp-use.cmd should fall back to the split-layout vp.exe, got: {cmd_content}" ); } @@ -1253,12 +1253,11 @@ mod tests { #[cfg(unix)] async fn test_create_env_files_does_not_create_cmd_wrapper_on_unix() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); let bin_dir = home.join("bin"); tokio::fs::create_dir_all(&bin_dir).await.unwrap(); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); assert!( !bin_dir.join("vp-use.cmd").as_path().exists(), @@ -1269,24 +1268,26 @@ mod tests { #[tokio::test] async fn test_execute_env_only_creates_home_dir_and_env_files() { let temp_dir = TempDir::new().unwrap(); - let fresh_home = temp_dir.path().join("new-vite-plus"); - // Directory does NOT exist yet — execute should create it - let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig { - vite_plus_home: Some(fresh_home.clone()), - user_home: Some(temp_dir.path().to_path_buf()), - ..vp_shared::EnvConfig::for_test() - }); + // Fresh home (no `.vite-plus` yet): the split layout is selected and + // execute creates the env-scripts directory it needs. + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_dir.path(), + )); + + let dirs = Dirs::get(); + let env_dir = dirs.env_scripts_dir(); + assert!(!env_dir.as_path().exists(), "env dir should not exist before initial setup"); let status = execute(false, true).await.unwrap(); assert!(status.success(), "execute --env-only should succeed"); // Directory should now exist - assert!(fresh_home.exists(), "VP_HOME directory should be created"); + assert!(env_dir.as_path().exists(), "env directory should be created"); // Env files should be written - assert!(fresh_home.join("env").exists(), "env file should be created"); - assert!(fresh_home.join("env.fish").exists(), "env.fish file should be created"); - assert!(fresh_home.join("env.ps1").exists(), "env.ps1 file should be created"); + assert!(env_dir.join("env").as_path().exists(), "env file should be created"); + assert!(env_dir.join("env.fish").as_path().exists(), "env.fish file should be created"); + assert!(env_dir.join("env.ps1").as_path().exists(), "env.ps1 file should be created"); } #[tokio::test] @@ -1428,10 +1429,9 @@ mod tests { #[tokio::test] async fn test_create_env_files_contains_dynamic_completion() { let temp_dir = TempDir::new().unwrap(); - let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - let _guard = home_guard(temp_dir.path()); + let (_guard, home) = legacy_home(temp_dir.path()); - create_env_files(&home).await.unwrap(); + create_env_files(&Dirs::get()).await.unwrap(); let env_content = tokio::fs::read_to_string(home.join("env")).await.unwrap(); let fish_content = tokio::fs::read_to_string(home.join("env.fish")).await.unwrap(); diff --git a/crates/vp_global_cli/src/commands/env/use.rs b/crates/vp_global_cli/src/commands/env/use.rs index 7b07cf94d8..78ffd5cbf3 100644 --- a/crates/vp_global_cli/src/commands/env/use.rs +++ b/crates/vp_global_cli/src/commands/env/use.rs @@ -137,8 +137,7 @@ pub async fn execute( // Ensure version is installed (unless --no-install) if !no_install { - let home_dir = - vp_shared::get_vp_home()?.join("js_runtime").join("node").join(&resolved_version); + let home_dir = vp_shared::Dirs::get().js_runtime_dir().join("node").join(&resolved_version); #[cfg(windows)] let binary_path = home_dir.join("node.exe"); diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 3d5fbebb01..97e1cc4e69 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -19,7 +19,7 @@ use vt_path::{AbsolutePath, AbsolutePathBuf}; use super::{ bin_config::{BinConfig, BinSource}, - config::{VERSION_ENV_VAR, get_bin_dir, get_node_modules_dir, resolve_version}, + config::{VERSION_ENV_VAR, get_node_modules_dir, resolve_version}, package_metadata::PackageMetadata, }; use crate::{cli::exit_status, error::Error}; @@ -110,7 +110,7 @@ async fn execute_npm_link_binary(tool: &str, bin_config: &BinConfig) -> Result Result { - let link_path = get_bin_dir()?.join(tool); + let link_path = vp_shared::Dirs::get().bin_dir().join(tool); let target = tokio::fs::read_link(&link_path).await?; let binary_path = if target.is_absolute() { target @@ -127,7 +127,7 @@ async fn locate_npm_link_binary(tool: &str) -> Result { #[cfg(windows)] async fn locate_npm_link_binary(tool: &str) -> Result { - let cmd_path = get_bin_dir()?.join(format!("{tool}.cmd")); + let cmd_path = vp_shared::Dirs::get().bin_dir().join(format!("{tool}.cmd")); let content = tokio::fs::read_to_string(&cmd_path).await?; let mut lines = content.lines(); let source = match (lines.next(), lines.next(), lines.next(), lines.next()) { @@ -202,8 +202,7 @@ async fn execute_core_tool(cwd: AbsolutePathBuf, tool: &str) -> Result bin_dir, - Err(error) => { - let _ = cleanup_failed_install(&install_dir).await; - if first_error.is_none() { - first_error = Some(error); - } - continue; - } - }; + let bin_dir = vp_shared::Dirs::get().bin_dir(); let metadata_version = installed_version.as_deref().unwrap_or("unknown"); let mut metadata = PackageMetadata::new( @@ -966,7 +957,7 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { }; if dry_run { - let bin_dir = get_bin_dir()?; + let bin_dir = vp_shared::Dirs::get().bin_dir(); let package_dir = match &metadata { Some(metadata) => metadata.installation_dir()?, None => PackageMetadata::installation_dir_for(&package_name, "")?, @@ -991,7 +982,7 @@ pub async fn uninstall(package_name: &str, dry_run: bool) -> Result<(), Error> { } // Remove shims and bin configs - let bin_dir = get_bin_dir()?; + let bin_dir = vp_shared::Dirs::get().bin_dir(); for bin_name in &bins { remove_package_shim(&bin_dir, bin_name).await?; BinConfig::delete(bin_name).await?; diff --git a/crates/vp_global_cli/src/commands/implode.rs b/crates/vp_global_cli/src/commands/implode.rs index 47b9a9d070..3a0aad6205 100644 --- a/crates/vp_global_cli/src/commands/implode.rs +++ b/crates/vp_global_cli/src/commands/implode.rs @@ -20,12 +20,10 @@ use crate::{ const VITE_PLUS_COMMENT: &str = "# Vite+ bin"; pub fn execute(yes: bool) -> Result { - let Ok(home_dir) = vp_shared::get_vp_home() else { - output::info("vite-plus is not installed (could not determine home directory)"); - return Ok(exit_status(0)); - }; + let dirs = vp_shared::Dirs::get(); + let plan = RemovalPlan::new(&dirs); - if !home_dir.as_path().exists() { + if !plan.anything_to_remove() { output::info("vite-plus is not installed (directory does not exist)"); return Ok(exit_status(0)); } @@ -35,13 +33,13 @@ pub fn execute(yes: bool) -> Result { .ok_or_else(|| Error::Other("Could not determine user home directory".into()))?; let user_home = AbsolutePathBuf::new(base_dirs.home_dir().to_path_buf()).unwrap(); - let source_matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let source_matcher = VitePlusSourceMatcher::new(&plan.profile_roots, &user_home); // Collect shell profiles that contain Vite+ lines (content cached for cleaning) let affected_profiles = collect_affected_profiles(&user_home, &source_matcher); // Confirmation - if !yes && !confirm_implode(&home_dir, &affected_profiles)? { + if !yes && !confirm_implode(&plan, &affected_profiles)? { return Ok(exit_status(0)); } @@ -51,7 +49,7 @@ pub fn execute(yes: bool) -> Result { // Remove Windows PATH entry #[cfg(windows)] { - let bin_path = home_dir.join("bin"); + let bin_path = dirs.bin_dir(); if let Err(e) = remove_windows_path_entry(&bin_path) { output::warn(&vt_str::format!("Failed to clean Windows PATH: {e}")); } else { @@ -59,8 +57,7 @@ pub fn execute(yes: bool) -> Result { } } - // Remove the directory - remove_vite_plus_dir(&home_dir)?; + plan.remove()?; output::raw(""); output::success("vite-plus has been removed from your system."); @@ -69,6 +66,165 @@ pub fn execute(yes: bool) -> Result { Ok(exit_status(0)) } +/// What `vp implode` removes, derived from the resolved [`vp_shared::Dirs`] +/// layout. +struct RemovalPlan { + /// Directories removed wholesale. Legacy layout: just the install root. + /// Split layout: data, config, state, and cache dirs (deduplicated — + /// category overrides can make them coincide). + dirs: Vec, + /// Bin directory to clean of vp-owned shims (split layout only; under + /// the legacy layout it lives inside the removed root). The directory + /// itself is removed only when vp-dedicated; a shared dir like + /// `~/.local/bin` is never removed. + bin_dir: Option, + /// Directories shell-profile sourcing lines may reference. Legacy: the + /// install root (env scripts live there). Split: the env-scripts dir + /// (e.g. `. "$HOME/.config/vite-plus/env"`). + profile_roots: Vec, +} + +impl RemovalPlan { + fn new(dirs: &vp_shared::Dirs) -> Self { + if dirs.is_legacy_layout() { + let root = dirs.data_dir(); + return Self { dirs: vec![root.clone()], bin_dir: None, profile_roots: vec![root] }; + } + + let mut category_dirs = + vec![dirs.data_dir(), dirs.config_dir(), dirs.state_dir(), dirs.cache_dir()]; + category_dirs.dedup(); + Self { + dirs: category_dirs, + bin_dir: Some(dirs.bin_dir()), + profile_roots: vec![dirs.env_scripts_dir()], + } + } + + fn anything_to_remove(&self) -> bool { + self.dirs.iter().any(|dir| dir.as_path().exists()) + || self.bin_dir.as_ref().is_some_and(|bin_dir| { + std::fs::read_dir(bin_dir) + .map(|entries| { + entries.filter_map(Result::ok).any(|entry| { + entry.file_name().to_str().is_some_and(is_vp_owned_bin_name) + }) + }) + .unwrap_or(false) + }) + } + + fn remove(&self) -> Result<(), Error> { + let mut failed = false; + for dir in &self.dirs { + if !dir.as_path().exists() { + continue; + } + if remove_vite_plus_dir(dir).is_err() { + failed = true; + } + } + if let Some(bin_dir) = &self.bin_dir { + clean_bin_dir(bin_dir); + } + if failed { + Err(Error::Other("Failed to remove all vite-plus directories".into())) + } else { + Ok(()) + } + } +} + +/// Names vp owns in the bin directory, in both Unix and Windows spellings: +/// the `vp` wrapper, the tool shims, and the cmd.exe `vp env use` wrapper. +const VP_OWNED_BIN_NAMES: &[&str] = &[ + "vp", + "node", + "npm", + "npx", + "corepack", + "vpx", + "vpr", + "vp.exe", + "node.exe", + "npm.exe", + "npx.exe", + "corepack.exe", + "vpx.exe", + "vpr.exe", + "vp.cmd", + "node.cmd", + "npm.cmd", + "npx.cmd", + "corepack.cmd", + "vpx.cmd", + "vpr.cmd", + "vp-use.cmd", +]; + +/// Whether vp owns the bin-dir entry `name`: an exact shim name, or a +/// `..old` leftover from Windows rename-before-copy. +fn is_vp_owned_bin_name(name: &str) -> bool { + if VP_OWNED_BIN_NAMES.contains(&name) { + return true; + } + if let Some(stem) = name.strip_suffix(".old") + && let Some((base, timestamp)) = stem.rsplit_once('.') + { + // Rename-before-copy leftovers are `..old`. + return timestamp.bytes().all(|b| b.is_ascii_digit()) && VP_OWNED_BIN_NAMES.contains(&base); + } + false +} + +/// Remove vp's shims from `bin_dir` (split layout). The directory itself is +/// removed only when it is vp-dedicated (contains nothing but vp-owned +/// files); otherwise only the known shim names are deleted and a shared bin +/// dir like `~/.local/bin` is left in place. +fn clean_bin_dir(bin_dir: &AbsolutePathBuf) { + let Ok(entries) = std::fs::read_dir(bin_dir) else { + return; + }; + let names: Vec = entries + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok().map(Str::from)) + .collect(); + if names.is_empty() { + return; + } + + if names.iter().all(|name| is_vp_owned_bin_name(name)) { + // vp-dedicated bin dir: remove it wholesale. + match std::fs::remove_dir_all(bin_dir) { + Ok(()) => output::success(&vt_str::format!("Removed {}", bin_dir.as_path().display())), + Err(e) => { + output::warn(&vt_str::format!( + "Failed to remove {}: {e}", + bin_dir.as_path().display() + )); + } + } + return; + } + + // Shared bin dir: delete only the files vp owns. + for name in names.iter().filter(|name| is_vp_owned_bin_name(name)) { + let path = bin_dir.join(name.as_str()); + match std::fs::remove_file(&path) { + Ok(()) => { + output::success(&vt_str::format!("Removed {}", path.as_path().display())); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + output::warn(&vt_str::format!( + "Failed to remove {}: {e}", + path.as_path().display() + )); + } + } + } +} + /// A shell profile that contains Vite+ sourcing lines. struct AffectedProfile { /// Display name (e.g. ".zshrc", ".config/fish/conf.d/vite-plus.fish"). @@ -126,7 +282,7 @@ fn collect_affected_profiles( /// Show confirmation prompt and require the user to type "uninstall". /// Returns `Ok(true)` if confirmed, `Ok(false)` if aborted. fn confirm_implode( - home_dir: &AbsolutePathBuf, + plan: &RemovalPlan, affected_profiles: &[AffectedProfile], ) -> Result { if !vp_shared::is_stdin_terminal() { @@ -138,7 +294,19 @@ fn confirm_implode( output::warn("This will completely remove vite-plus from your system!"); output::raw(""); - output::raw(&vt_str::format!(" Directory: {}", home_dir.as_path().display())); + if plan.dirs.len() == 1 { + output::raw(&vt_str::format!(" Directory: {}", plan.dirs[0].as_path().display())); + } else { + output::raw(" Directories:"); + for dir in &plan.dirs { + output::raw(&vt_str::format!(" - {}", dir.as_path().display())); + } + } + if let Some(bin_dir) = &plan.bin_dir + && bin_dir.as_path().exists() + { + output::raw(&vt_str::format!(" Shims to remove from: {}", bin_dir.as_path().display())); + } if !affected_profiles.is_empty() { output::raw(" Shell profiles to clean:"); for profile in affected_profiles { @@ -272,29 +440,37 @@ fn spawn_deferred_delete(trash_path: &std::path::Path) -> std::io::Result, } impl VitePlusSourceMatcher { - fn new(home_dir: &AbsolutePathBuf, user_home: &AbsolutePathBuf) -> Self { - let mut roots = vec![normalize_path_separators(&home_dir.as_path().display().to_string())]; - - if let Ok(Some(suffix)) = home_dir.strip_prefix(user_home) { - // `RelativePathBuf` guarantees forward-slash separators. - let suffix = vt_str::format!("{suffix}"); - if suffix.is_empty() { - roots.push(Str::from("$HOME")); - roots.push(Str::from("~")); - } else { - roots.push(vt_str::format!("$HOME/{suffix}")); - roots.push(vt_str::format!("~/{suffix}")); + fn new(reference_dirs: &[AbsolutePathBuf], user_home: &AbsolutePathBuf) -> Self { + let mut roots = Vec::new(); + + for dir in reference_dirs { + roots.push(normalize_path_separators(&dir.as_path().display().to_string())); + + if let Ok(Some(suffix)) = dir.strip_prefix(user_home) { + // `RelativePathBuf` guarantees forward-slash separators. + let suffix = vt_str::format!("{suffix}"); + if suffix.is_empty() { + roots.push(Str::from("$HOME")); + roots.push(Str::from("~")); + } else { + roots.push(vt_str::format!("$HOME/{suffix}")); + roots.push(vt_str::format!("~/{suffix}")); + } } } @@ -428,7 +604,7 @@ mod tests { fn default_source_matcher() -> VitePlusSourceMatcher { let user_home = default_user_home(); let home_dir = user_home.join(".vite-plus"); - VitePlusSourceMatcher::new(&home_dir, &user_home) + VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home) } #[test] @@ -451,7 +627,7 @@ mod tests { fn test_remove_vite_plus_lines_absolute_path() { let user_home = default_user_home(); let home_dir = user_home.join(".vite-plus"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let env_path = shell_path(&home_dir.join("env")); let content = vt_str::format!("# existing\n. \"{env_path}\"\n"); let result = remove_vite_plus_lines(&content, &matcher, "env"); @@ -462,7 +638,7 @@ mod tests { fn test_remove_vite_plus_lines_custom_absolute_path() { let user_home = custom_user_home(); let home_dir = user_home.join("tools").join("vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let env_path = shell_path(&home_dir.join("env")); let content = vt_str::format!("# existing\n. \"{env_path}\"\n"); let result = remove_vite_plus_lines(&content, &matcher, "env"); @@ -473,7 +649,7 @@ mod tests { fn test_remove_vite_plus_lines_custom_home_relative_path() { let user_home = custom_user_home(); let home_dir = user_home.join("tools").join("vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let content = "# existing\n. \"$HOME/tools/vp/env\"\n"; let result = remove_vite_plus_lines(content, &matcher, "env"); assert_eq!(&*result, "# existing\n"); @@ -483,7 +659,7 @@ mod tests { fn test_remove_vite_plus_lines_custom_tilde_path() { let user_home = custom_user_home(); let home_dir = user_home.join("tools").join("vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &user_home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &user_home); let content = "# existing\nsource '~/tools/vp/env.nu'\n"; let result = remove_vite_plus_lines(content, &matcher, "env.nu"); assert_eq!(&*result, "# existing\n"); @@ -542,7 +718,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let home_dir = temp_path.join(".vite-plus"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &temp_path); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &temp_path); let profile_path = temp_path.join(".zshrc"); let original = "# my config\nexport FOO=bar\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.vite-plus/env\"\n"; std::fs::write(&profile_path, original).unwrap(); @@ -612,7 +788,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let home_dir = home.join(".vite-plus"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &home); // Clear env overrides so the test environment doesn't affect results let _guard = ProfileEnvGuard::new(None, None, None); @@ -639,7 +815,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let home = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); let home_dir = home.join("tools/vp"); - let matcher = VitePlusSourceMatcher::new(&home_dir, &home); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&home_dir), &home); let _guard = ProfileEnvGuard::new(None, None, None); @@ -725,7 +901,7 @@ mod tests { std::fs::write(zdotdir.join(".zshenv"), ". \"$HOME/.vite-plus/env\"\n").unwrap(); let _guard = ProfileEnvGuard::new(Some(&zdotdir), None, None); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + let matcher = VitePlusSourceMatcher::new(&[home.join(".vite-plus")], &home); let profiles = collect_affected_profiles(&home, &matcher); let zdotdir_profiles: Vec<_> = @@ -749,7 +925,7 @@ mod tests { .unwrap(); let _guard = ProfileEnvGuard::new(None, Some(&xdg_config), None); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + let matcher = VitePlusSourceMatcher::new(&[home.join(".vite-plus")], &home); let profiles = collect_affected_profiles(&home, &matcher); let xdg_profiles: Vec<_> = @@ -772,7 +948,7 @@ mod tests { std::fs::write(nushell_dir.join("vite-plus.nu"), "source '~/.vite-plus/env.nu'\n").unwrap(); let _guard = ProfileEnvGuard::new(None, None, Some(&xdg_data)); - let matcher = VitePlusSourceMatcher::new(&home.join(".vite-plus"), &home); + let matcher = VitePlusSourceMatcher::new(&[home.join(".vite-plus")], &home); let profiles = collect_affected_profiles(&home, &matcher); let xdg_profiles: Vec<_> = @@ -781,6 +957,84 @@ mod tests { assert!(matches!(&xdg_profiles[0].kind, AffectedProfileKind::Snippet)); } + #[test] + fn test_remove_vite_plus_lines_split_env_scripts_dir() { + // Split layout: profile lines reference the env-scripts dir + // (`. "$HOME/.config/vite-plus/env"`), not the data dir. + let user_home = default_user_home(); + let env_dir = user_home.join(".config").join("vite-plus"); + let data_dir = user_home.join(".local/share").join("vite-plus"); + let matcher = VitePlusSourceMatcher::new(std::slice::from_ref(&env_dir), &user_home); + let content = + "# existing\n\n# Vite+ bin (https://viteplus.dev)\n. \"$HOME/.config/vite-plus/env\"\n"; + let result = remove_vite_plus_lines(content, &matcher, "env"); + assert_eq!(&*result, "# existing\n"); + + // Lines referencing the data dir are not env-script sourcing lines + // and stay untouched. + let env_path = shell_path(&data_dir.join("env")); + let content = vt_str::format!("# existing\n. \"{env_path}\"\n"); + let result = remove_vite_plus_lines(&content, &matcher, "env"); + assert_eq!(&*result, &*content); + } + + #[test] + fn test_is_vp_owned_bin_name() { + for owned in [ + "vp", + "node", + "npm", + "npx", + "corepack", + "vpx", + "vpr", + "vp.exe", + "npm.cmd", + "vp-use.cmd", + ] { + assert!(is_vp_owned_bin_name(owned), "{owned} should be vp-owned"); + } + // Windows rename-before-copy leftovers. + assert!(is_vp_owned_bin_name("vp.exe.1700000000.old")); + // Not vp-owned: other tools, lookalikes, and bare .old files. + for foreign in ["git", "node.exe.old", "vpn", "vp.json", "vp.exe.old.bak"] { + assert!(!is_vp_owned_bin_name(foreign), "{foreign} should not be vp-owned"); + } + } + + #[test] + fn test_clean_bin_dir_removes_dedicated_dir() { + let temp_dir = tempfile::tempdir().unwrap(); + let bin_dir = AbsolutePathBuf::new(temp_dir.path().join("bin")).unwrap(); + std::fs::create_dir_all(&bin_dir).unwrap(); + for name in ["vp", "node", "npm", "vp-use.cmd", "vp.exe.1700000000.old"] { + std::fs::write(bin_dir.join(name), b"shim").unwrap(); + } + + clean_bin_dir(&bin_dir); + + assert!(!bin_dir.as_path().exists(), "vp-dedicated bin dir should be removed wholesale"); + } + + #[test] + fn test_clean_bin_dir_keeps_shared_dir_and_foreign_files() { + let temp_dir = tempfile::tempdir().unwrap(); + let bin_dir = AbsolutePathBuf::new(temp_dir.path().join("bin")).unwrap(); + std::fs::create_dir_all(&bin_dir).unwrap(); + for name in ["vp", "node", "vpr"] { + std::fs::write(bin_dir.join(name), b"shim").unwrap(); + } + std::fs::write(bin_dir.join("git"), b"foreign").unwrap(); + + clean_bin_dir(&bin_dir); + + assert!(bin_dir.as_path().exists(), "shared bin dir must not be removed"); + assert!(bin_dir.join("git").as_path().exists(), "foreign files must stay"); + for name in ["vp", "node", "vpr"] { + assert!(!bin_dir.join(name).as_path().exists(), "{name} should be removed"); + } + } + #[test] fn test_execute_not_installed() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/crates/vp_global_cli/src/commands/upgrade/mod.rs b/crates/vp_global_cli/src/commands/upgrade/mod.rs index c853e84881..8e0c24aa60 100644 --- a/crates/vp_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vp_global_cli/src/commands/upgrade/mod.rs @@ -11,7 +11,7 @@ use vp_setup::{install, integrity, platform, registry}; use vp_shared::output; use vt_path::AbsolutePathBuf; -use crate::{commands::env::config::get_vp_home, error::Error}; +use crate::error::Error; /// Options for the upgrade command. pub struct UpgradeOptions { @@ -34,7 +34,7 @@ pub struct UpgradeOptions { /// Execute the upgrade command. #[allow(clippy::print_stdout, clippy::print_stderr)] pub async fn execute(options: UpgradeOptions) -> Result { - let install_dir = get_vp_home()?; + let install_dir = vp_shared::Dirs::get().versions_dir(); // Handle --rollback if options.rollback { diff --git a/crates/vp_global_cli/src/commands/version.rs b/crates/vp_global_cli/src/commands/version.rs index 5b171b2d1c..3deb4f7aa6 100644 --- a/crates/vp_global_cli/src/commands/version.rs +++ b/crates/vp_global_cli/src/commands/version.rs @@ -236,7 +236,7 @@ mod tests { } // Run serially: the spawned `node` inherits this process's environment, and - // concurrent #[serial] tests mutate PATH/VP_HOME via std::env::set_var, + // concurrent #[serial] tests mutate PATH via std::env::set_var, // which can make a vp shim on PATH resolve incorrectly mid-test. #[test] #[serial] diff --git a/crates/vp_global_cli/src/commands/vpx.rs b/crates/vp_global_cli/src/commands/vpx.rs index c6d6fe8d36..a1f406e13d 100644 --- a/crates/vp_global_cli/src/commands/vpx.rs +++ b/crates/vp_global_cli/src/commands/vpx.rs @@ -10,7 +10,7 @@ use vp_shared::{PrependOptions, exit_code_from_status, output, prepend_to_path_env}; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use crate::{commands::env::config, shim::dispatch}; +use crate::shim::dispatch; /// Parsed vpx flags. #[derive(Debug, Default)] @@ -184,20 +184,12 @@ async fn execute_global_binary(bin: GlobalBinary, args: &[String], cwd: &Absolut /// /// This prevents vpx from finding itself (or other vite-plus shims) on PATH. fn find_on_path(cmd: &str) -> Option { - let bin_dir = config::get_bin_dir().ok(); + let bin_dir = vp_shared::Dirs::get().bin_dir(); let path_var = std::env::var_os("PATH")?; // Filter PATH to exclude vite-plus bin directory - let filtered_paths: Vec<_> = std::env::split_paths(&path_var) - .filter(|p| { - if let Some(ref bin) = bin_dir { - if p == bin.as_path() { - return false; - } - } - true - }) - .collect(); + let filtered_paths: Vec<_> = + std::env::split_paths(&path_var).filter(|p| p != bin_dir.as_path()).collect(); let filtered_path = std::env::join_paths(filtered_paths).ok()?; let cwd = vt_path::current_dir().ok()?; @@ -709,12 +701,12 @@ mod tests { #[serial] fn test_find_on_path_excludes_vp_bin_dir() { let original_path = std::env::var_os("PATH"); - let original_home = std::env::var_os("VP_HOME"); let temp = tempfile::tempdir().unwrap(); - // Set up a fake vite-plus home with bin dir - let fake_home = temp.path().join("vite-plus-home"); - let fake_bin = fake_home.join("bin"); + // Set up a fake vite-plus home with bin dir. The on-disk `.vite-plus` + // under the overridden user home selects the legacy layout, so the + // vp bin dir is `/.vite-plus/bin`. + let fake_bin = temp.path().join(".vite-plus").join("bin"); std::fs::create_dir_all(&fake_bin).unwrap(); create_fake_executable(&fake_bin, "vpx-excluded-tool"); @@ -723,13 +715,14 @@ mod tests { std::fs::create_dir_all(&other_dir).unwrap(); create_fake_executable(&other_dir, "vpx-excluded-tool"); - let path = std::env::join_paths([fake_bin.as_path(), other_dir.as_path()]).unwrap(); + let path = std::env::join_paths([fake_bin.as_os_str(), other_dir.as_os_str()]).unwrap(); // SAFETY: serial test unsafe { std::env::set_var("PATH", &path); - std::env::set_var("VP_HOME", fake_home.as_os_str()); } + let _guard = + vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home(temp.path())); let result = find_on_path("vpx-excluded-tool"); assert!(result.is_some()); @@ -744,10 +737,6 @@ mod tests { Some(v) => std::env::set_var("PATH", v), None => std::env::remove_var("PATH"), } - match &original_home { - Some(v) => std::env::set_var("VP_HOME", v), - None => std::env::remove_var("VP_HOME"), - } } } diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index 66c263ec49..b8679942b4 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -7,7 +7,7 @@ use std::process::{ExitStatus, Output}; use tokio::process::Command; use vp_js_runtime::{JsRuntime, JsRuntimeType, download_runtime, download_runtime_for_project}; -use vp_shared::{PrependOptions, PrependResult, env_vars, format_path_with_prepend}; +use vp_shared::{Dirs, PrependOptions, PrependResult, env_vars, format_path_with_prepend}; use vt_path::{AbsolutePath, AbsolutePathBuf}; use crate::{ @@ -108,6 +108,28 @@ impl JsExecutor { cmd.env(env_vars::VP_CLI_BIN, bin_path.as_path()); } + // Split (XDG) layout: hand JS scripts the resolved dirs so TS code + // that reads paths directly (the create-org tarball cache, generated + // git hook scripts) agrees with the Rust side, and nested vp + // processes resolve the same layout. Legacy installs self-locate + // their root (executable path / `PATH` inference / the grandfathered + // `~/.vite-plus`), and the legacy layout intentionally ignores these + // vars, so only inject them for the split layout. Explicit user + // overrides always win. + let dirs = Dirs::get(); + if !dirs.is_legacy_layout() { + for (var, dir) in [ + (env_vars::VP_BIN_DIR, dirs.bin_dir()), + (env_vars::VP_DATA_DIR, dirs.data_dir()), + (env_vars::VP_CACHE_DIR, dirs.cache_dir()), + ] { + if std::env::var_os(var).is_none() { + tracing::debug!("Set {var} to {dir:?}"); + cmd.env(var, dir.as_path()); + } + } + } + // Prepend runtime bin to PATH so child processes can find the JS runtime let options = PrependOptions { dedupe_anywhere: true }; if let PrependResult::Prepended(new_path) = @@ -618,8 +640,9 @@ mod tests { use tempfile::TempDir; use vp_shared::EnvConfig; - // Isolate VP_HOME so config defaults to managed mode (no `vp env off`) - // and the runtime download cache stays inside the test sandbox. + // Isolate the user home so config defaults to managed mode (no + // `vp env off`) and the runtime download cache stays inside the test + // sandbox (split layout under the temp home). let vp_home = TempDir::new().unwrap(); let _guard = EnvConfig::test_guard(EnvConfig::for_test_with_home(vp_home.path().to_path_buf())); diff --git a/crates/vp_global_cli/src/shim/cache.rs b/crates/vp_global_cli/src/shim/cache.rs index 2f97fd4ee3..911a69e285 100644 --- a/crates/vp_global_cli/src/shim/cache.rs +++ b/crates/vp_global_cli/src/shim/cache.rs @@ -39,7 +39,8 @@ pub struct ResolveCacheEntry { pub is_range: bool, } -/// Resolution cache stored in VP_HOME/cache/resolve_cache.json. +/// Resolution cache stored in `/resolve_cache.json` +/// (`~/.vite-plus/cache/resolve_cache.json` under the legacy layout). #[derive(Serialize, Deserialize, Debug)] pub struct ResolveCache { /// Cache format version for upgrade compatibility @@ -184,8 +185,7 @@ impl ResolveCache { /// Get the cache file path. pub fn get_cache_path() -> Option { - let home = crate::commands::env::config::get_vp_home().ok()?; - Some(home.join("cache").join("resolve_cache.json")) + Some(vp_shared::Dirs::get().resolve_cache_file()) } /// Invalidate the entire resolve cache by deleting the cache file. @@ -344,15 +344,15 @@ mod tests { assert_eq!(cached_entry.unwrap().version, "20.20.0"); } - // Run serially: mutates VP_HOME env var which affects get_cache_path() #[test] - #[serial_test::serial] fn test_invalidate_cache_removes_file() { let temp_dir = TempDir::new().unwrap(); let temp_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap(); - // Set VP_HOME to temp dir so invalidate_cache() targets our test file - let cache_dir = temp_path.join("cache"); + // Sandboxed legacy layout: the on-disk `.vite-plus` under the + // overridden user home selects it, so the resolve cache lives at + // `/.vite-plus/cache/resolve_cache.json`. + let cache_dir = temp_path.join(".vite-plus").join("cache"); std::fs::create_dir_all(&cache_dir).unwrap(); let cache_file = cache_dir.join("resolve_cache.json"); @@ -373,14 +373,11 @@ mod tests { cache.save(&cache_file); assert!(std::fs::metadata(cache_file.as_path()).is_ok(), "Cache file should exist"); - // Point VP_HOME to our temp dir and call invalidate_cache - unsafe { - std::env::set_var(vp_shared::env_vars::VP_HOME, temp_path.as_path()); - } + // Point the sandboxed home at our temp dir and call invalidate_cache + let _guard = vp_shared::EnvConfig::test_guard(vp_shared::EnvConfig::for_test_with_home( + temp_path.as_path(), + )); invalidate_cache(); - unsafe { - std::env::remove_var(vp_shared::env_vars::VP_HOME); - } // Cache file should be removed assert!( diff --git a/crates/vp_global_cli/src/shim/corepack.rs b/crates/vp_global_cli/src/shim/corepack.rs index 92c74c6bf5..39b664c95b 100644 --- a/crates/vp_global_cli/src/shim/corepack.rs +++ b/crates/vp_global_cli/src/shim/corepack.rs @@ -29,7 +29,7 @@ use super::{ }; use crate::commands::env::{ bin_config::{BinConfig, BinSource}, - config, setup, + setup, }; /// Binary names corepack `enable`/`disable` may create or remove in the @@ -58,23 +58,12 @@ pub(crate) async fn dispatch_corepack(args: &[String]) -> i32 { // restore any Vite+-owned shims corepack removed or replaced. The arg // check runs first so the common path skips bin-dir resolution entirely. if is_corepack_link_command(args) { - match config::get_bin_dir() { - Ok(bin_dir) => { - full_args.extend(inject_install_directory(args, &bin_dir)); - let owned_shims = snapshot_vp_owned_shims(&bin_dir).await; - let exit_code = exec::spawn_tool(&program, &full_args); - restore_vp_owned_shims(&bin_dir, &owned_shims).await; - return exit_code; - } - Err(e) => { - // Without a bin dir there is nothing to inject or restore; - // run corepack as-is, but say so instead of failing silently. - output::warn(&format!( - "Cannot resolve the Vite+ bin directory ({e}); running corepack without \ - an --install-directory default, created launchers may not be on PATH" - )); - } - } + let bin_dir = vp_shared::Dirs::get().bin_dir(); + full_args.extend(inject_install_directory(args, &bin_dir)); + let owned_shims = snapshot_vp_owned_shims(&bin_dir).await; + let exit_code = exec::spawn_tool(&program, &full_args); + restore_vp_owned_shims(&bin_dir, &owned_shims).await; + return exit_code; } // The bundled corepack and native binaries have no leading args; exec diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index f072073a74..49a59673b4 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -229,7 +229,7 @@ fn check_npm_global_install_result( node_dir: &AbsolutePath, node_version: &str, ) { - let Ok(bin_dir) = config::get_bin_dir() else { return }; + let bin_dir = vp_shared::Dirs::get().bin_dir(); // Derive bin dir from prefix (Unix: prefix/bin, Windows: prefix itself) #[cfg(unix)] @@ -364,7 +364,11 @@ fn check_npm_global_install_result( let bin_display = bin_list.join(", "); output::raw(&vt_str::format!("'{bin_display}' is not available on your PATH.")); - output::raw_inline("Create a link in ~/.vite-plus/bin/ to make it available? [Y/n] "); + let link_dir = vp_shared::Dirs::get().bin_dir(); + output::raw_inline(&vt_str::format!( + "Create a link in {}/ to make it available? [Y/n] ", + link_dir.as_path().display() + )); let _ = std::io::Write::flush(&mut std::io::stdout()); let mut input = String::new(); @@ -518,7 +522,7 @@ fn dedup_missing_bins( /// still delete its binary from `npm_bin_dir`, leaving our symlink dangling. In that /// case we repair the link by pointing directly at the surviving package's binary. fn remove_npm_global_uninstall_links(bin_entries: &[(String, String)], npm_prefix: &AbsolutePath) { - let Ok(bin_dir) = config::get_bin_dir() else { return }; + let bin_dir = vp_shared::Dirs::get().bin_dir(); for (bin_name, package_name) in bin_entries { // Skip protected shims: a stale Npm BinConfig (e.g. a pre-default-shim @@ -777,7 +781,8 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { // Append current bin_dir to VP_BYPASS to prevent infinite loops // when multiple vite-plus installations exist in PATH. // The next installation will filter all accumulated paths. - if let Ok(bin_dir) = config::get_bin_dir() { + { + let bin_dir = vp_shared::Dirs::get().bin_dir(); let bypass_val = match std::env::var_os(env_vars::VP_BYPASS) { Some(existing) => { let mut paths: Vec<_> = std::env::split_paths(&existing).collect(); @@ -901,37 +906,32 @@ pub async fn dispatch(tool: &str, args: &[String]) -> i32 { if let Some(parsed) = parse_npm_global_install(args) { let exit_code = exec::spawn_tool(&tool_path, args); if exit_code == 0 { - if let Ok(home_dir) = vp_shared::get_vp_home() { - let node_dir = - home_dir.join("js_runtime").join("node").join(&*resolution.version); - let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); - check_npm_global_install_result( - &parsed.packages, - original_path.as_deref(), - &npm_prefix, - &node_dir, - &resolution.version, - ); - } + let node_dir = + vp_shared::Dirs::get().js_runtime_dir().join("node").join(&*resolution.version); + let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); + check_npm_global_install_result( + &parsed.packages, + original_path.as_deref(), + &npm_prefix, + &node_dir, + &resolution.version, + ); } return exit_code; } if let Some(parsed) = parse_npm_global_uninstall(args) { // Collect bin names before uninstall (package.json will be gone after) - let context = if let Ok(home_dir) = vp_shared::get_vp_home() { - let node_dir = home_dir.join("js_runtime").join("node").join(&*resolution.version); + let (bins, npm_prefix) = { + let node_dir = + vp_shared::Dirs::get().js_runtime_dir().join("node").join(&*resolution.version); let npm_prefix = resolve_npm_prefix(&parsed, &tool_path, &node_dir); let bins = collect_bin_names_from_npm(&parsed.packages, &npm_prefix, &node_dir); - Some((bins, npm_prefix)) - } else { - None + (bins, npm_prefix) }; let exit_code = exec::spawn_tool(&tool_path, args); if exit_code == 0 { - if let Some((bin_names, npm_prefix)) = context { - remove_npm_global_uninstall_links(&bin_names, &npm_prefix); - } + remove_npm_global_uninstall_links(&bins, &npm_prefix); } return exit_code; } @@ -1296,16 +1296,12 @@ async fn cached_project_source_still_current( /// Ensure Node.js is installed. pub(crate) async fn ensure_installed(version: &str) -> Result { - let home_dir = vp_shared::get_vp_home() - .map_err(|e| format!("Failed to get vite-plus home dir: {e}"))? - .join("js_runtime") - .join("node") - .join(version); + let version_dir = vp_shared::Dirs::get().js_runtime_dir().join("node").join(version); #[cfg(windows)] - let binary_path = home_dir.join("node.exe"); + let binary_path = version_dir.join("node.exe"); #[cfg(not(windows))] - let binary_path = home_dir.join("bin").join("node"); + let binary_path = version_dir.join("bin").join("node"); // Check if already installed if binary_path.as_path().exists() { @@ -1325,22 +1321,18 @@ pub(crate) async fn ensure_installed(version: &str) -> Result Result { - let home_dir = vp_shared::get_vp_home() - .map_err(|e| format!("Failed to get vite-plus home dir: {e}"))? - .join("js_runtime") - .join("node") - .join(version); + let version_dir = vp_shared::Dirs::get().js_runtime_dir().join("node").join(version); #[cfg(windows)] let tool_path = if tool == "node" { - home_dir.join("node.exe") + version_dir.join("node.exe") } else { // npm and npx are .cmd scripts on Windows - home_dir.join(format!("{tool}.cmd")) + version_dir.join(format!("{tool}.cmd")) }; #[cfg(not(windows))] - let tool_path = home_dir.join("bin").join(tool); + let tool_path = version_dir.join("bin").join(tool); if !tool_path.as_path().exists() { return Err(format!("Tool '{}' not found at {}", tool, tool_path.as_path().display())); @@ -1367,7 +1359,7 @@ pub(crate) fn find_system_tool(tool: &str) -> Option { /// `cwd` only resolves relative PATH entries; it is a parameter so tests can /// exercise them without mutating the process-wide working directory. fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option { - let bin_dir = config::get_bin_dir().ok(); + let bin_dir = vp_shared::Dirs::get().bin_dir(); let path_var = std::env::var_os("PATH")?; tracing::debug!("path_var: {:?}", path_var); @@ -1384,10 +1376,8 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option = std::env::split_paths(&path_var) .filter(|p| { - if let Some(ref bin) = bin_dir { - if p == bin.as_path() { - return false; - } + if p == bin_dir.as_path() { + return false; } !bypass_paths.iter().any(|bp| p == bp) }) @@ -1395,7 +1385,7 @@ fn find_system_tool_in(tool: &str, cwd: &AbsolutePath) -> Option - // Installation B also needs to filter install_b_bin (via get_bin_dir), - // but get_bin_dir returns the real vite-plus home. So we test by putting + // Installation B also needs to filter install_b_bin (via Dirs::bin_dir), + // but Dirs::bin_dir returns the real vite-plus home. So we test by putting // install_b_bin in the bypass as well (simulating cumulative append). let bypass = std::env::join_paths([install_a_bin.as_path(), install_b_bin.as_path()]).unwrap(); diff --git a/crates/vp_global_cli/src/shim/mod.rs b/crates/vp_global_cli/src/shim/mod.rs index c6f8a5a977..967a5e728d 100644 --- a/crates/vp_global_cli/src/shim/mod.rs +++ b/crates/vp_global_cli/src/shim/mod.rs @@ -21,8 +21,6 @@ pub use dispatch::dispatch; pub(crate) use dispatch::find_system_tool; use vp_shared::env_vars; -use crate::commands::env::config::get_bin_dir; - /// Core shim tools (node, npm, npx). /// /// `corepack` is also a default shim (see `commands::env::setup::SHIM_TOOLS`) @@ -48,20 +46,18 @@ pub fn extract_tool_name(argv0: &str) -> String { if cfg!(target_os = "linux") { stem } else { - let bin_dir = get_bin_dir(); - if let Ok(bin_dir) = bin_dir { - if let Ok(read_dir) = fs::read_dir(&bin_dir) { - for bin in read_dir.flatten() { - if bin.path().file_stem().unwrap_or_default().to_string_lossy().to_lowercase() - == stem.to_lowercase() - { - return bin - .path() - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - } + let bin_dir = vp_shared::Dirs::get().bin_dir(); + if let Ok(read_dir) = fs::read_dir(&bin_dir) { + for bin in read_dir.flatten() { + if bin.path().file_stem().unwrap_or_default().to_string_lossy().to_lowercase() + == stem.to_lowercase() + { + return bin + .path() + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); } } } @@ -106,12 +102,8 @@ pub fn is_shim_tool(tool: &str) -> bool { /// because when running through a wrapper script (e.g., current/bin/vp), the current_exe() /// returns the wrapper's location, not the original shim's location. fn is_potential_package_binary(tool: &str) -> bool { - use crate::commands::env::config; - - // Get the configured bin directory (respects VP_HOME env var) - let Ok(configured_bin) = config::get_bin_dir() else { - return false; - }; + // Get the configured bin directory + let configured_bin = vp_shared::Dirs::get().bin_dir(); // Check if the shim exists in the configured bin directory. // Use symlink_metadata to detect symlinks (even broken ones). @@ -241,12 +233,11 @@ mod tests { /// Test that is_potential_package_binary checks the configured bin directory. /// /// The function now checks if a shim exists in the configured bin directory - /// (from VP_HOME/bin) instead of relying on current_exe(). + /// (`Dirs::get().bin_dir()`) instead of relying on current_exe(). /// This allows it to work correctly with wrapper scripts. #[test] fn test_is_potential_package_binary_checks_configured_bin() { - // The function checks config::get_bin_dir() which respects VP_HOME. - // Without setting VP_HOME, it defaults to ~/.vite-plus/bin. + // The function checks Dirs::get().bin_dir(). // // Since we can't easily create test shims in the actual bin directory, // we just verify the function doesn't panic and returns false for diff --git a/crates/vp_global_cli/src/upgrade_check.rs b/crates/vp_global_cli/src/upgrade_check.rs index 6cd8826d18..d0a5d6ecbf 100644 --- a/crates/vp_global_cli/src/upgrade_check.rs +++ b/crates/vp_global_cli/src/upgrade_check.rs @@ -1,7 +1,8 @@ //! Background upgrade check for the vp CLI. //! //! Periodically queries the npm registry for the latest version and caches the -//! result to `~/.vite-plus/.upgrade-check.json`. Displays a one-line notice on +//! result to the state directory's `.upgrade-check.json` (see +//! [`vp_shared::Dirs::upgrade_check_file`]). Displays a one-line notice on //! stderr when a newer version is available, at most once per 24 hours. use std::time::{SystemTime, UNIX_EPOCH}; @@ -12,6 +13,8 @@ use vp_setup::registry; const CHECK_INTERVAL_SECS: u64 = 24 * 60 * 60; const PROMPT_INTERVAL_SECS: u64 = 24 * 60 * 60; +/// Cache file name; see [`vp_shared::Dirs::upgrade_check_file`]. +#[cfg(test)] const CACHE_FILE_NAME: &str = ".upgrade-check.json"; #[expect(clippy::disallowed_types)] // String required for serde JSON round-trip @@ -22,14 +25,12 @@ struct UpgradeCheckCache { prompted_at: u64, } -fn read_cache(install_dir: &vt_path::AbsolutePath) -> Option { - let cache_path = install_dir.join(CACHE_FILE_NAME); +fn read_cache(cache_path: &vt_path::AbsolutePath) -> Option { let data = std::fs::read_to_string(cache_path.as_path()).ok()?; serde_json::from_str(&data).ok() } -fn write_cache(install_dir: &vt_path::AbsolutePath, cache: &UpgradeCheckCache) { - let cache_path = install_dir.join(CACHE_FILE_NAME); +fn write_cache(cache_path: &vt_path::AbsolutePath, cache: &UpgradeCheckCache) { if let Ok(data) = serde_json::to_string(cache) { let _ = std::fs::write(cache_path.as_path(), &data); } @@ -72,17 +73,17 @@ async fn resolve_version_string() -> Option { } pub struct UpgradeCheckResult { - install_dir: vt_path::AbsolutePathBuf, + cache_path: vt_path::AbsolutePathBuf, cache: UpgradeCheckCache, } /// Returns an upgrade check result if a newer version is available and the user /// hasn't been prompted within the last 24 hours. Returns `None` otherwise. pub async fn check_for_update() -> Option { - let install_dir = vp_shared::get_vp_home().ok()?; + let cache_path = vp_shared::Dirs::get().upgrade_check_file(); let current_version = env!("CARGO_PKG_VERSION"); let now = now_secs(); - let mut cache = read_cache(&install_dir); + let mut cache = read_cache(&cache_path); if should_check(cache.as_ref(), now) { let prompted_at = cache.as_ref().map_or(0, |c| c.prompted_at); @@ -90,7 +91,7 @@ pub async fn check_for_update() -> Option { match resolve_version_string().await { Some(latest) => { let new_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &new_cache); + write_cache(&cache_path, &new_cache); cache = Some(new_cache); } None => { @@ -98,7 +99,7 @@ pub async fn check_for_update() -> Option { // retrying on every command when the registry is unreachable. let latest = cache.as_ref().map(|c| c.latest.clone()).unwrap_or_default(); let failed_cache = UpgradeCheckCache { latest, checked_at: now, prompted_at }; - write_cache(&install_dir, &failed_cache); + write_cache(&cache_path, &failed_cache); cache = Some(failed_cache); } } @@ -114,7 +115,7 @@ pub async fn check_for_update() -> Option { return None; } - Some(UpgradeCheckResult { install_dir, cache }) + Some(UpgradeCheckResult { cache_path, cache }) } /// Print a one-line upgrade notice to stderr and record the prompt time. @@ -133,7 +134,7 @@ pub fn display_upgrade_notice(result: &UpgradeCheckResult) { let mut cache = result.cache.clone(); cache.prompted_at = now_secs(); - write_cache(&result.install_dir, &cache); + write_cache(&result.cache_path, &cache); } /// Whether the upgrade check should run for the given command args. @@ -170,12 +171,13 @@ mod tests { fn cache_round_trip() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); + let cache_file = dir_path.join(CACHE_FILE_NAME); let cache = UpgradeCheckCache { latest: "1.2.3".to_owned(), checked_at: 1000, prompted_at: 900 }; - write_cache(&dir_path, &cache); + write_cache(&cache_file, &cache); - let loaded = read_cache(&dir_path).expect("should read back cache"); + let loaded = read_cache(&cache_file).expect("should read back cache"); assert_eq!(loaded.latest, "1.2.3"); assert_eq!(loaded.checked_at, 1000); assert_eq!(loaded.prompted_at, 900); @@ -185,15 +187,16 @@ mod tests { fn read_cache_returns_none_for_missing_file() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - assert!(read_cache(&dir_path).is_none()); + assert!(read_cache(&dir_path.join(CACHE_FILE_NAME)).is_none()); } #[test] fn read_cache_returns_none_for_corrupt_file() { let dir = tempfile::tempdir().unwrap(); let dir_path = vt_path::AbsolutePathBuf::new(dir.path().to_path_buf()).unwrap(); - std::fs::write(dir_path.join(CACHE_FILE_NAME).as_path(), "not json").unwrap(); - assert!(read_cache(&dir_path).is_none()); + let cache_file = dir_path.join(CACHE_FILE_NAME); + std::fs::write(cache_file.as_path(), "not json").unwrap(); + assert!(read_cache(&cache_file).is_none()); } fn with_env_vars_cleared(f: F) { diff --git a/crates/vp_installer/src/cli.rs b/crates/vp_installer/src/cli.rs index 61f7e343f7..c84cd38d28 100644 --- a/crates/vp_installer/src/cli.rs +++ b/crates/vp_installer/src/cli.rs @@ -22,7 +22,9 @@ pub struct Options { #[arg(long = "tag", default_value = "latest")] pub tag: String, - /// Custom installation directory (default: ~/.vite-plus) + /// Custom installation directory: selects the legacy monolithic layout + /// rooted at this directory (default: split platform layout, or the + /// legacy root when `~/.vite-plus` already exists) #[arg(long = "install-dir")] pub install_dir: Option, @@ -49,6 +51,9 @@ pub fn parse() -> Options { opts.version = std::env::var("VP_VERSION").ok(); } if opts.install_dir.is_none() { + // The installers still honor `VP_HOME` as the install dir + // (install.sh/install.ps1 pass it through), selecting the legacy + // monolithic layout; the vp CLI itself never reads it. opts.install_dir = std::env::var("VP_HOME").ok(); } if opts.registry.is_none() { diff --git a/crates/vp_installer/src/main.rs b/crates/vp_installer/src/main.rs index 28ce48bc35..41f3e7df45 100644 --- a/crates/vp_installer/src/main.rs +++ b/crates/vp_installer/src/main.rs @@ -105,48 +105,47 @@ fn main() { let opts = cli::parse(); - // Resolve install dir and set VP_HOME before starting the tokio runtime, - // so the unsafe set_var runs while we're still single-threaded. - let install_dir = match resolve_install_dir(&opts) { - Ok(dir) => dir, + // Resolve the install layout before starting the tokio runtime. + // + // The vp CLI no longer reads `VP_HOME`: the installed binary locates its + // own root from its `/current/bin/vp` path, so no environment + // variable needs to be set for child processes. + let layout = match resolve_layout(&opts) { + Ok(layout) => layout, Err(e) => { print_error(&format!("Failed to resolve install directory: {e}")); std::process::exit(1); } }; - // Safety: called in main() before any threads are spawned. - unsafe { std::env::set_var("VP_HOME", install_dir.as_path()) }; let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap_or_else(|e| { print_error(&format!("Failed to create async runtime: {e}")); std::process::exit(1); }); - let code = rt.block_on(run(opts, install_dir)); + let code = rt.block_on(run(opts, layout)); std::process::exit(code); } #[allow(clippy::print_stdout, clippy::print_stderr)] -async fn run(mut opts: cli::Options, install_dir: AbsolutePathBuf) -> i32 { - let install_dir_display = install_dir.as_path().to_string_lossy().to_string(); - +async fn run(mut opts: cli::Options, layout: InstallLayout) -> i32 { // Pre-compute Node.js manager default before showing the menu, // so the user sees the resolved value and can override it. if !opts.no_node_manager { - opts.no_node_manager = !auto_detect_node_manager(&install_dir, !opts.yes); + opts.no_node_manager = !auto_detect_node_manager(&layout.bin_dir, !opts.yes); } if !opts.yes { - let proceed = show_interactive_menu(&mut opts, &install_dir_display); + let proceed = show_interactive_menu(&mut opts, &layout); if !proceed { println!("Installation cancelled."); return 0; } } - let code = match do_install(&opts, &install_dir).await { + let code = match do_install(&opts, &layout).await { Ok(()) => { - print_success(&opts, &install_dir_display); + print_success(&opts, &layout); 0 } Err(e) => { @@ -167,8 +166,9 @@ async fn run(mut opts: cli::Options, install_dir: AbsolutePathBuf) -> i32 { #[allow(clippy::print_stdout)] async fn do_install( opts: &cli::Options, - install_dir: &AbsolutePathBuf, + layout: &InstallLayout, ) -> Result<(), Box> { + let install_dir = &layout.install_dir; let platform_suffix = platform::detect_platform_suffix()?; if !opts.quiet { print_info(&format!("detected platform: {platform_suffix}")); @@ -257,7 +257,7 @@ async fn do_install( if !opts.quiet { print_info("setting up shims..."); } - if let Err(e) = setup_bin_shims(install_dir).await { + if let Err(e) = setup_bin_shims(layout).await { print_warn(&format!("Shim setup failed (non-fatal): {e}")); } @@ -273,7 +273,7 @@ async fn do_install( } if !opts.no_modify_path { - let bin_dir_str = install_dir.join("bin").as_path().to_string_lossy().to_string(); + let bin_dir_str = layout.bin_dir.as_path().to_string_lossy().to_string(); if let Err(e) = modify_path(&bin_dir_str, opts.quiet) { print_warn(&format!("PATH modification failed (non-fatal): {e}")); } @@ -289,13 +289,13 @@ async fn do_install( /// /// Matches install.ps1/install.sh auto-detect logic: /// 1. VP_NODE_MANAGER=yes → enable; VP_NODE_MANAGER=no → disable -/// 2. Already managing Node (bin/node.exe exists) → enable (refresh) +/// 2. Already managing Node (`node` shim exists in the bin dir) → enable (refresh) /// 3. CI / Codespaces / DevContainer / DevPod → enable /// 4. No system `node` found → enable /// 5. System node present, interactive → enable (matching install.ps1's default-Y prompt; /// user can disable via customize menu before proceeding) /// 6. System node present, silent → disable (don't silently take over) -fn auto_detect_node_manager(install_dir: &vt_path::AbsolutePath, interactive: bool) -> bool { +fn auto_detect_node_manager(bin_dir: &vt_path::AbsolutePath, interactive: bool) -> bool { // VP_NODE_MANAGER env var: only "yes" and "no" are recognized; // unrecognized values fall through to normal auto-detection // (matching install.ps1/install.sh behavior). @@ -309,7 +309,7 @@ fn auto_detect_node_manager(install_dir: &vt_path::AbsolutePath, interactive: bo } // Already managing Node (shims exist from a previous install) - let node_shim = install_dir.join("bin").join(if cfg!(windows) { "node.exe" } else { "node" }); + let node_shim = bin_dir.join(if cfg!(windows) { "node.exe" } else { "node" }); if node_shim.as_path().exists() { return true; } @@ -399,12 +399,16 @@ async fn replace_windows_exe( Ok(()) } -/// Set up the `bin/vp` entry point (trampoline copy on Windows, symlink on Unix). -async fn setup_bin_shims( - install_dir: &vt_path::AbsolutePath, -) -> Result<(), Box> { - let bin_dir = install_dir.join("bin"); - tokio::fs::create_dir_all(&bin_dir).await?; +/// Set up the `vp` entry point in the bin dir (trampoline copy on Windows, +/// symlink on Unix). +/// +/// The bin dir comes from the resolved layout: `/bin` under +/// the legacy layout, the separate split-layout bin dir (e.g. +/// `~/.local/bin`) otherwise — created if needed. +async fn setup_bin_shims(layout: &InstallLayout) -> Result<(), Box> { + let install_dir = &layout.install_dir; + let bin_dir = &layout.bin_dir; + tokio::fs::create_dir_all(bin_dir).await?; #[cfg(windows)] { @@ -419,11 +423,11 @@ async fn setup_bin_shims( }; if tokio::fs::try_exists(&src).await.unwrap_or(false) { - replace_windows_exe(&src, &shim_dst, &bin_dir).await?; + replace_windows_exe(&src, &shim_dst, bin_dir).await?; } // Best-effort cleanup of old shim files - if let Ok(mut entries) = tokio::fs::read_dir(&bin_dir).await { + if let Ok(mut entries) = tokio::fs::read_dir(bin_dir).await { while let Ok(Some(entry)) = entries.next_entry().await { if entry.file_name().to_string_lossy().ends_with(".old") { let _ = tokio::fs::remove_file(entry.path()).await; @@ -434,7 +438,14 @@ async fn setup_bin_shims( #[cfg(unix)] { - let link_target = std::path::PathBuf::from("../current/bin/vp"); + // Legacy layout (bin dir is the install-local `bin`): keep the + // relative `../current/bin/vp` target. Split layout: the bin dir + // lives outside the data dir, so link absolutely. + let link_target = if bin_dir.parent().is_some_and(|parent| parent == install_dir) { + std::path::PathBuf::from("../current/bin/vp") + } else { + install_dir.join("current").join("bin").join("vp").as_path().to_path_buf() + }; let link_path = bin_dir.join("vp"); let _ = tokio::fs::remove_file(&link_path).await; tokio::fs::symlink(&link_target, &link_path).await?; @@ -466,13 +477,41 @@ async fn download_with_progress( Ok(data) } -fn resolve_install_dir(opts: &cli::Options) -> Result> { +/// Resolved install layout: where versions land, where the `vp` wrapper and +/// shims go, and where the shell env scripts live. +struct InstallLayout { + /// Data dir: CLI versions plus the `current` symlink. + install_dir: AbsolutePathBuf, + /// Bin dir receiving the `vp` wrapper and tool shims. + bin_dir: AbsolutePathBuf, + /// Directory of the generated env scripts (`env`, `env.fish`, ...). + env_scripts_dir: AbsolutePathBuf, +} + +/// Resolve the install layout from the CLI options and [`vp_shared::Dirs`]. +/// +/// An explicit `--install-dir`/`VP_HOME` override selects the legacy +/// monolithic layout rooted at that directory (the compat story for custom +/// install locations). Otherwise the resolved `Dirs` decide: the +/// legacy root under the `Home` layout, the split XDG dirs for fresh +/// installs. +fn resolve_layout(opts: &cli::Options) -> Result> { if let Some(ref dir) = opts.install_dir { let path = std::path::PathBuf::from(dir); let abs = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) }; - AbsolutePathBuf::new(abs).ok_or_else(|| "Invalid installation directory".into()) + let install_dir = AbsolutePathBuf::new(abs).ok_or("Invalid installation directory")?; + Ok(InstallLayout { + bin_dir: install_dir.join("bin"), + env_scripts_dir: install_dir.clone(), + install_dir, + }) } else { - Ok(vp_shared::get_vp_home()?) + let dirs = vp_shared::Dirs::get(); + Ok(InstallLayout { + install_dir: dirs.data_dir(), + bin_dir: dirs.bin_dir(), + env_scripts_dir: dirs.env_scripts_dir(), + }) } } @@ -497,10 +536,11 @@ fn modify_path(bin_dir: &str, quiet: bool) -> Result<(), Box bool { +fn show_interactive_menu(opts: &mut cli::Options, layout: &InstallLayout) -> bool { loop { let version = opts.version.as_deref().unwrap_or(&opts.tag); - let bin_dir = format!("{install_dir}{sep}bin", sep = std::path::MAIN_SEPARATOR); + let install_dir = layout.install_dir.as_path().to_string_lossy().to_string(); + let bin_dir = layout.bin_dir.as_path().to_string_lossy().to_string(); println!(); println!(" {}", "Welcome to Vite+ Installer!".bold()); @@ -594,11 +634,12 @@ fn read_input(prompt: &str) -> String { } #[allow(clippy::print_stdout)] -fn print_success(opts: &cli::Options, install_dir: &str) { +fn print_success(opts: &cli::Options, layout: &InstallLayout) { if opts.quiet { return; } + let env_script = layout.env_scripts_dir.join("env"); println!(); println!(" {} Vite+ has been installed successfully!", "\u{2714}".green().bold()); println!(); @@ -606,7 +647,9 @@ fn print_success(opts: &cli::Options, install_dir: &str) { println!(); println!(" {}", "vp --help".cyan()); println!(); - println!(" Install directory: {install_dir}"); + println!(" Install directory: {}", layout.install_dir.as_path().display()); + println!(" Bin directory: {}", layout.bin_dir.as_path().display()); + println!(" Shell setup: . \"{}\"", env_script.as_path().display()); println!(" Documentation: {}", "https://viteplus.dev/guide/"); println!(); } diff --git a/crates/vp_js_runtime/src/cache.rs b/crates/vp_js_runtime/src/cache.rs deleted file mode 100644 index 9308a83d1e..0000000000 --- a/crates/vp_js_runtime/src/cache.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Cache directory utilities for JavaScript runtimes. - -use vt_path::AbsolutePathBuf; - -use crate::Error; - -/// Get the cache directory for JavaScript runtimes. -/// -/// Returns `$VP_HOME/js_runtime`. -pub fn get_cache_dir() -> Result { - Ok(vp_shared::get_vp_home()?.join("js_runtime")) -} diff --git a/crates/vp_js_runtime/src/lib.rs b/crates/vp_js_runtime/src/lib.rs index 56a6e03189..efe136ff2e 100644 --- a/crates/vp_js_runtime/src/lib.rs +++ b/crates/vp_js_runtime/src/lib.rs @@ -43,7 +43,6 @@ clippy::print_stdout )] -mod cache; mod dev_engines; mod download; mod error; diff --git a/crates/vp_js_runtime/src/providers/node.rs b/crates/vp_js_runtime/src/providers/node.rs index 73b23daaeb..23130353f3 100644 --- a/crates/vp_js_runtime/src/providers/node.rs +++ b/crates/vp_js_runtime/src/providers/node.rs @@ -102,7 +102,7 @@ impl NodeProvider { /// /// # Arguments /// * `version_req` - A semver range requirement (e.g., "^20.18.0") - /// * `cache_dir` - The cache directory path (e.g., `~/.cache/vite-plus/js_runtime`) + /// * `cache_dir` - The managed runtime install dir (e.g., `~/.vite-plus/js_runtime`) /// /// # Returns /// The highest LTS cached version that satisfies the requirement, or the @@ -186,8 +186,7 @@ impl NodeProvider { /// /// Returns an error only if the download fails and no local cache exists. pub async fn fetch_version_index(&self) -> Result, Error> { - let cache_dir = crate::cache::get_cache_dir()?; - let cache_path = cache_dir.join("node/index_cache.json"); + let cache_path = vp_shared::Dirs::get().node_index_cache_file(); // Try to load from cache let Some(cache) = load_cache(&cache_path).await else { diff --git a/crates/vp_js_runtime/src/runtime.rs b/crates/vp_js_runtime/src/runtime.rs index da4a7bb387..37a4ba6bfd 100644 --- a/crates/vp_js_runtime/src/runtime.rs +++ b/crates/vp_js_runtime/src/runtime.rs @@ -183,13 +183,13 @@ pub async fn download_runtime_with_provider( version: &str, ) -> Result { let platform = Platform::current(); - let cache_dir = crate::cache::get_cache_dir()?; + let cache_dir = vp_shared::Dirs::get().js_runtime_dir(); // Get paths from provider let binary_relative_path = provider.binary_relative_path(platform); let bin_dir_relative_path = provider.bin_dir_relative_path(platform); - // Cache path: $CACHE_DIR/vite-plus/js_runtime/{runtime}/{version}/ + // Install path: /{runtime}/{version}/ let install_dir = cache_dir.join(provider.name()).join(version); // Check if already cached @@ -456,7 +456,7 @@ pub async fn resolve_node_version( /// Currently only supports Node.js runtime. pub async fn download_runtime_for_project(project_path: &AbsolutePath) -> Result { let provider = NodeProvider::new(); - let cache_dir = crate::cache::get_cache_dir()?; + let cache_dir = vp_shared::Dirs::get().js_runtime_dir(); // Resolve version from the project directory, walking up to inherit from ancestors let resolution = resolve_node_version(project_path, true).await?; @@ -1041,7 +1041,7 @@ mod tests { let version = "20.17.0"; // Clear any existing cache for this version - let cache_dir = crate::cache::get_cache_dir().unwrap(); + let cache_dir = vp_shared::Dirs::get().js_runtime_dir(); let install_dir = cache_dir.join("node").join(version); if tokio::fs::try_exists(&install_dir).await.unwrap_or(false) { tokio::fs::remove_dir_all(&install_dir).await.unwrap(); diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 4bb7dfee2f..66620eac43 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -376,9 +376,9 @@ pub fn package_manager_install_dir( package_manager_type: PackageManagerType, version: &str, ) -> Option { - let home_dir = vp_shared::get_vp_home().ok()?; + let package_manager_dir = vp_shared::Dirs::get().package_manager_dir(); let bin_name = package_manager_type.to_string(); - Some(home_dir.join("package_manager").join(&bin_name).join(version).join(&bin_name)) + Some(package_manager_dir.join(&bin_name).join(version).join(&bin_name)) } /// Return the executable shim path for a package manager binary inside an install directory. @@ -739,9 +739,8 @@ fn find_cached_package_manager_version( package_manager_type: PackageManagerType, range: &node_semver::Range, ) -> Result, Error> { - let home_dir = vp_shared::get_vp_home()?; let bin_name = package_manager_type.to_string(); - let versions_dir = home_dir.join("package_manager").join(&bin_name); + let versions_dir = vp_shared::Dirs::get().package_manager_dir().join(&bin_name); let entries = match fs::read_dir(&versions_dir) { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -844,7 +843,7 @@ pub async fn download_package_manager( package_name = "@yarnpkg/cli-dist".into(); } - let home_dir = vp_shared::get_vp_home()?; + let package_manager_dir = vp_shared::Dirs::get().package_manager_dir(); let bin_name = package_manager_type.to_string(); // For bun, use platform-specific download flow. @@ -852,7 +851,7 @@ pub async fn download_package_manager( // not the platform-specific binary, so we don't pass it through; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Bun) { - return download_bun_package_manager(&version, &home_dir).await; + return download_bun_package_manager(&version, &package_manager_dir).await; } // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package @@ -860,12 +859,13 @@ pub async fn download_package_manager( // A declared hash names the main tarball and is verified against it; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { - return download_pnpm_native_package_manager(&version, &home_dir, expected_hash).await; + return download_pnpm_native_package_manager(&version, &package_manager_dir, expected_hash) + .await; } let tgz_url = get_npm_package_tgz_url(&package_name, &version); - // $VP_HOME/package_manager/pnpm/10.0.0 - let target_dir = home_dir.join("package_manager").join(&bin_name).join(&version); + // /pnpm/10.0.0 + let target_dir = package_manager_dir.join(&bin_name).join(&version); let install_dir = target_dir.join(&bin_name); // If all shims already exist, return the target directory @@ -978,13 +978,13 @@ fn get_bun_platform_package_name() -> Result<&'static str, Error> { /// Layout: `$VP_HOME/package_manager/bun/{version}/bun/bin/bun.native` async fn download_bun_package_manager( version: &Str, - home_dir: &AbsolutePath, + package_manager_dir: &AbsolutePath, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "bun".into(); let platform_package_name = get_bun_platform_package_name()?; - // $VP_HOME/package_manager/bun/{version} - let target_dir = home_dir.join("package_manager").join("bun").join(version.as_str()); + // /bun/{version} + let target_dir = package_manager_dir.join("bun").join(version.as_str()); let install_dir = target_dir.join("bun"); // If shims already exist, return early (same completeness check as the cache @@ -1156,14 +1156,14 @@ async fn fetch_platform_integrity( /// Layout: `$VP_HOME/package_manager/pnpm/{version}/pnpm/bin/pnpm.native` async fn download_pnpm_native_package_manager( version: &Str, - home_dir: &AbsolutePath, + package_manager_dir: &AbsolutePath, expected_hash: Option<&str>, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "pnpm".into(); let platform_package_name = get_pnpm_platform_package_name()?; - // $VP_HOME/package_manager/pnpm/{version} - let target_dir = home_dir.join("package_manager").join("pnpm").join(version.as_str()); + // /pnpm/{version} + let target_dir = package_manager_dir.join("pnpm").join(version.as_str()); let install_dir = target_dir.join("pnpm"); // If shims already exist, return early (same completeness check as the cache @@ -1729,11 +1729,18 @@ mod tests { Complete, } - /// Create a fake managed package manager install under - /// `/package_manager////bin/`. + /// Create a fake managed package manager install under the legacy root + /// `/.vite-plus/package_manager////bin/`. + /// The on-disk `.vite-plus` selects the legacy layout for the overridden + /// user home. fn write_pm_install(vp_home: &AbsolutePath, name: &str, version: &str, state: InstallState) { - let bin_dir = - vp_home.join("package_manager").join(name).join(version).join(name).join("bin"); + let bin_dir = vp_home + .join(".vite-plus") + .join("package_manager") + .join(name) + .join(version) + .join(name) + .join("bin"); fs::create_dir_all(&bin_dir).unwrap(); let bin_file = bin_dir.join(name); if matches!(state, InstallState::BinOnly | InstallState::Complete) { @@ -3778,17 +3785,21 @@ mod tests { .body("this is not a valid gzip archive"); }); + // The on-disk `.vite-plus` under the overridden user home selects + // the legacy layout, so package managers install under + // `/.vite-plus/package_manager/`. + let legacy_root = vp_home.path().join(".vite-plus"); + std::fs::create_dir_all(&legacy_root).unwrap(); let _guard = EnvConfig::test_guard(EnvConfig { npm_registry: server.base_url().into(), - vite_plus_home: Some(vp_home.path().to_path_buf()), - ..EnvConfig::for_test() + ..EnvConfig::for_test_with_home(vp_home.path().to_path_buf()) }); let result = download_package_manager(PackageManagerType::Pnpm, "10.0.0", None).await; assert!(result.is_err(), "corrupt tarball should fail the install, got {result:?}"); // The per-install temp dir must be gone after the failure. - let pnpm_dir = vp_home.path().join("package_manager").join("pnpm"); + let pnpm_dir = legacy_root.join("package_manager").join("pnpm"); let leftovers: Vec<_> = fs::read_dir(&pnpm_dir) .map(|rd| { rd.filter_map(Result::ok) diff --git a/crates/vp_shared/src/dirs.rs b/crates/vp_shared/src/dirs.rs new file mode 100644 index 0000000000..2f6682b3f7 --- /dev/null +++ b/crates/vp_shared/src/dirs.rs @@ -0,0 +1,1206 @@ +//! Unified on-disk path resolution for vite-plus. +//! +//! [`Dirs`] owns every placement decision for files vite-plus installs or +//! creates: executables and shims, configuration, payload data (CLI versions, +//! Node.js runtimes, package managers), state files, and disposable caches. +//! No call site constructs `~/.vite-plus/...` or reads `XDG_*` itself. +//! +//! Two layouts are supported, selected once per resolution (first match +//! wins): +//! +//! 0. **Executable self-location** — the canonicalized `current_exe` path +//! matches `/current/bin/vp[.exe]`. If `/bin/vp[.exe]` also exists +//! → legacy `Home(X)`; otherwise `X` is the data dir of a split install +//! → `Custom` with the data category pinned to `X` (other categories +//! resolve through their normal chains). Covers custom-location installs +//! and launches without `PATH` context (IDEs, the Windows trampoline). +//! 1. **`PATH` inference** — for a `PATH` entry containing a `vp` +//! executable: first the cheap legacy sibling check (`/bin` entry +//! with `bin/vp` plus `current/bin/vp` → `Home(root)`; needed on +//! Windows, where `bin/vp.exe` is a trampoline copy, not a symlink); +//! otherwise the entry's `vp` is canonicalized and, when it resolves +//! into `/current/bin/vp`, the same legacy-vs-split rule as rule 0 +//! applies (on Unix the legacy `bin/vp` is a symlink into `current/bin`, +//! so canonicalization finds legacy installs too). +//! 2. **Existing legacy root** — `/.vite-plus` exists on disk → +//! `Home`, so existing installs keep working untouched. +//! 3. **Split XDG/platform layout** (`Custom`) — fresh installs. Each +//! category resolves independently through its own `VP_*_DIR` override → +//! `XDG_*` → platform-default chain. +//! +//! `VP_HOME` is no longer read; rules 0–1 replace it. The `XDG_*_HOME` +//! variables are read directly from the process environment here — they are +//! the one exception to [`EnvConfig`] centralization, because they +//! participate in `Dirs` resolution. +//! +//! The access pattern mirrors [`EnvConfig`]: [`Dirs::get`] for global +//! access. Tests override the environment through +//! [`EnvConfig::test_scope`] / [`EnvConfig::test_guard`]: while a test +//! override is active, the host-environment rules (0–1 and the XDG reads) +//! are skipped and the home directory comes from the overridden +//! [`EnvConfig::user_home`], so resolution stays hermetic and +//! parallel-safe. +//! +//! Unlike [`EnvConfig`], there is intentionally no global `OnceLock` cache +//! and no `Dirs::init()`: [`Dirs::get`] recomputes from [`EnvConfig::get`] +//! on every call. Resolution is cheap (a few path joins plus at most one +//! filesystem `exists` check), and recomputing keeps +//! [`EnvConfig::test_scope`] overrides observable without a second cache +//! that could go stale. + +use std::{env, ffi::OsStr, path::PathBuf}; + +use directories::BaseDirs; +use vt_path::{AbsolutePath, AbsolutePathBuf}; + +use crate::{EnvConfig, env_vars}; + +/// Subdirectory name appended to XDG base directories and platform defaults. +const APP_DIR_NAME: &str = "vite-plus"; + +/// Directory name of the legacy monolithic install root (`~/.vite-plus`). +pub(crate) const LEGACY_HOME_DIR: &str = ".vite-plus"; + +/// Platform-specific binary name for the `vp` CLI. +pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; + +#[cfg(test)] +thread_local! { + /// Thread-local test override. Each test thread gets its own slot. + static TEST_DIRS: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// Resolved on-disk locations for every vite-plus file category. +/// +/// Obtain via [`Dirs::get`]; query through the category accessors +/// (`bin_dir`, `config_dir`, `data_dir`, `state_dir`, `cache_dir`) or the +/// named helpers for well-known subpaths (`js_runtime_dir`, `config_file`, +/// ...). The layout variant is an implementation detail. +#[derive(Debug, Clone)] +pub struct Dirs { + inner: DirsInner, +} + +/// Layout strategy, resolved once per [`Dirs::get`] call. Compatibility +/// handling lives entirely in which variant is selected — the accessors are +/// just a fixed mapping over it. +#[derive(Debug, Clone)] +enum DirsInner { + /// Monolithic legacy root (`~/.vite-plus` layout). Hit when a legacy + /// root is detected from the executable location or `PATH`, or when + /// `~/.vite-plus` already exists on disk (existing installs). + Home(AbsolutePathBuf), + /// Split XDG/platform layout (fresh installs). Each category resolved + /// independently via its own override → XDG → platform-default chain. + Custom { + /// Executables and shims (node, npm, npx, corepack, vpx, vpr, vp wrapper). + bin: AbsolutePathBuf, + /// User configuration: config.json, env scripts. + config: AbsolutePathBuf, + /// Payload data: CLI versions + `current`, js_runtime, + /// package_manager, packages, per-binary bins/*.json metadata. + data: AbsolutePathBuf, + /// State: .session-node-version, .upgrade-check.json. + state: AbsolutePathBuf, + /// Disposable cache: resolve_cache, tmp/create-org. + /// + /// The Node.js version index cache stays under `js_runtime_dir()` + /// (data) to preserve the legacy on-disk layout. + cache: AbsolutePathBuf, + }, +} + +/// Platform-specific defaults for the `Custom` layout, computed once per +/// platform. Keeping these behind a small injected core leaves the +/// resolution logic in [`resolve`] platform-neutral and unit-testable on any +/// OS. +#[derive(Debug, Clone)] +struct PlatformDefaults { + /// Executables and shims. + bin: AbsolutePathBuf, + /// User configuration. + config: AbsolutePathBuf, + /// Payload data. + data: AbsolutePathBuf, + /// State files. + state: AbsolutePathBuf, + /// Disposable cache. + cache: AbsolutePathBuf, +} + +impl PlatformDefaults { + /// Unix-style defaults derived from the home directory. + /// + /// Also used on macOS (`~/.config`, `~/.local/share`, ... rather than + /// `~/Library/...`), matching uv/fnm community expectations. + fn unix(home_dir: &AbsolutePath) -> Self { + Self { + bin: home_dir.join(".local/bin"), + config: home_dir.join(".config").join(APP_DIR_NAME), + data: home_dir.join(".local/share").join(APP_DIR_NAME), + state: home_dir.join(".local/state").join(APP_DIR_NAME), + cache: home_dir.join(".cache").join(APP_DIR_NAME), + } + } + + /// Windows defaults: everything under `%LOCALAPPDATA%\vite-plus`, except + /// configuration which lives under `%APPDATA%\vite-plus`. + /// + /// Compiled on every platform for tests so the Windows mapping stays + /// unit-tested on Unix. + #[cfg(any(windows, test))] + fn windows(local_app_data: &AbsolutePath, app_data: &AbsolutePath) -> Self { + let base = local_app_data.join(APP_DIR_NAME); + Self { + bin: base.join("bin"), + config: app_data.join(APP_DIR_NAME), + data: base.join("data"), + state: base.join("state"), + cache: base.join("cache"), + } + } + + /// Compute the defaults for the current platform. + #[cfg(not(windows))] + fn detect(home_dir: &AbsolutePath, _base_dirs: Option<&BaseDirs>) -> Self { + Self::unix(home_dir) + } + + /// Compute the defaults for the current platform. + #[cfg(windows)] + fn detect(home_dir: &AbsolutePath, base_dirs: Option<&BaseDirs>) -> Self { + match base_dirs { + // Both roots are absolute whenever `BaseDirs` resolved successfully. + Some(base_dirs) => { + let local_app_data = AbsolutePath::new(base_dirs.data_local_dir()).unwrap(); + let app_data = AbsolutePath::new(base_dirs.config_dir()).unwrap(); + Self::windows(local_app_data, app_data) + } + // No `BaseDirs`: derive the standard locations from the profile. + None => Self::windows( + &home_dir.join("AppData").join("Local"), + &home_dir.join("AppData").join("Roaming"), + ), + } + } +} + +/// XDG base directory values, injected into [`resolve`] so unit tests stay +/// parallel-safe. All values are raw; relative ones are ignored during +/// resolution, per the XDG Base Directory Specification. +#[derive(Debug, Clone, Default)] +struct XdgDirs { + /// `XDG_BIN_HOME` + bin: Option, + /// `XDG_CONFIG_HOME` + config: Option, + /// `XDG_DATA_HOME` + data: Option, + /// `XDG_STATE_HOME` + state: Option, + /// `XDG_CACHE_HOME` + cache: Option, +} + +impl XdgDirs { + /// Read the XDG base directory variables from the process environment. + fn from_env() -> Self { + Self { + bin: env::var(env_vars::XDG_BIN_HOME).ok().map(PathBuf::from), + config: env::var(env_vars::XDG_CONFIG_HOME).ok().map(PathBuf::from), + data: env::var(env_vars::XDG_DATA_HOME).ok().map(PathBuf::from), + state: env::var(env_vars::XDG_STATE_HOME).ok().map(PathBuf::from), + cache: env::var(env_vars::XDG_CACHE_HOME).ok().map(PathBuf::from), + } + } +} + +/// Convert an optional configured path into an absolute path, ignoring +/// relative values (treated as unset). +fn absolute(value: &Option) -> Option { + value.as_deref().and_then(AbsolutePath::new).map(AbsolutePath::to_absolute_path_buf) +} + +/// Layout detected from the host environment (rules 0–1), consumed by +/// [`resolve`]. +#[derive(Debug, Clone)] +enum DetectedLayout { + /// Legacy monolithic install root (`/bin/vp[.exe]` exists + /// alongside `/current/bin/vp[.exe]`). + Legacy(AbsolutePathBuf), + /// Data dir of a split install: `/current/bin/vp[.exe]` with no + /// legacy `/bin/vp[.exe]` sibling (shims live in the separate bin + /// dir, e.g. `~/.local/bin`). + SplitData(AbsolutePathBuf), +} + +/// If `exe` has the `/current/bin/vp[.exe]` install shape, return `X`. +/// +/// Pure suffix check on the path components; shared by executable +/// self-location and `PATH` canonicalization. +fn current_bin_install_parent(exe: &AbsolutePath) -> Option { + if exe.as_path().file_name() != Some(OsStr::new(VP_BINARY_NAME)) { + return None; + } + let bin_dir = exe.parent()?; + if bin_dir.as_path().file_name() != Some(OsStr::new("bin")) { + return None; + } + let current_dir = bin_dir.parent()?; + if current_dir.as_path().file_name() != Some(OsStr::new("current")) { + return None; + } + current_dir.parent().map(AbsolutePath::to_absolute_path_buf) +} + +/// Disambiguate the parent `X` of the `/current/bin/vp[.exe]` shape: a +/// `/bin/vp[.exe]` sibling means the legacy monolithic layout (`X` is the +/// install root); otherwise `X` is the data dir of a split install whose +/// shims live in the separate bin dir. +fn classify_install_parent(parent: AbsolutePathBuf) -> DetectedLayout { + if parent.join("bin").join(VP_BINARY_NAME).as_path().is_file() { + DetectedLayout::Legacy(parent) + } else { + DetectedLayout::SplitData(parent) + } +} + +/// Detect the install layout from the running executable's own location: a +/// canonicalized `/current/bin/vp[.exe]` classifies `X` as a legacy root +/// or a split data dir. This covers custom-location installs (previously +/// located via `VP_HOME`) and launches without `PATH` context (IDEs, the +/// Windows trampoline). Any failure falls through to the next rule. +fn self_located_layout() -> Option { + let exe = AbsolutePathBuf::new(env::current_exe().ok()?.canonicalize().ok()?)?; + current_bin_install_parent(&exe).map(classify_install_parent) +} + +/// Infer the install layout from a `vp` executable on `PATH`. +/// +/// Pure: takes the `PATH` value and the current directory as parameters, so +/// tests need no environment mutation (and no serialization). Two +/// mechanisms per `PATH` entry, first match wins: +/// +/// 1. Cheap legacy sibling check — a `/bin` entry with the legacy +/// layout (`bin/vp` plus `current/bin/vp`) → legacy `Home(root)`. +/// Needed on Windows, where `bin/vp.exe` is a trampoline copy rather +/// than a symlink, and avoids canonicalize syscalls for the common +/// legacy case. +/// 2. Canonicalize `/vp[.exe]`; if it resolves into +/// `/current/bin/vp[.exe]`, classify `X` as legacy or split-data (the +/// same rule as self-location). On Unix the legacy `bin/vp` is a symlink +/// into `current/bin`, so canonicalization alone would find legacy +/// installs too. +fn infer_layout_from_path(path_env: Option<&OsStr>, cwd: &AbsolutePath) -> Option { + for path_entry in env::split_paths(path_env?) { + if path_entry.as_os_str().is_empty() { + continue; + } + + let bin_dir = if path_entry.is_absolute() { + AbsolutePathBuf::new(path_entry).unwrap() + } else { + cwd.join(path_entry) + }; + + // 1. Cheap legacy sibling-layout check (no canonicalization). + if bin_dir.as_path().file_name().is_some_and(|name| name == "bin") + && let Some(home) = bin_dir.parent() + && is_vp_home_layout(&bin_dir, home) + { + return Some(DetectedLayout::Legacy(home.to_absolute_path_buf())); + } + + // 2. Canonicalize `/vp[.exe]` and classify the shape it + // resolves into. + let Ok(canonical) = bin_dir.join(VP_BINARY_NAME).as_path().canonicalize() else { + continue; + }; + let Some(canonical) = AbsolutePathBuf::new(canonical) else { + continue; + }; + if let Some(parent) = current_bin_install_parent(&canonical) { + return Some(classify_install_parent(parent)); + } + } + + None +} + +fn is_vp_home_layout(bin_dir: &AbsolutePath, home: &AbsolutePath) -> bool { + bin_dir.join(VP_BINARY_NAME).as_path().is_file() + && home.join("current").join("bin").join(VP_BINARY_NAME).as_path().is_file() +} + +/// Platform-neutral resolution core, injectable for tests. +/// +/// `detected` is the result of the host-environment layout detection +/// (executable self-location, then `PATH` inference; rules 0–1). +/// `legacy_exists` reports whether the legacy `~/.vite-plus` root exists on +/// disk (rule 2); injected so tests exercise the grandfathering branch +/// without touching host state (or against real tempdirs). +fn resolve( + config: &EnvConfig, + home_dir: &AbsolutePath, + xdg: &XdgDirs, + defaults: &PlatformDefaults, + detected: Option, + legacy_exists: impl Fn(&AbsolutePath) -> bool, +) -> Dirs { + // 0/1. A layout detected from the executable location or `PATH` always + // wins: a legacy root selects the monolithic legacy layout; a split + // data dir selects the split layout with the data category pinned to + // it (the running binary's versions live there, so `VP_DATA_DIR` and + // the XDG chain must not redirect data elsewhere). + match detected { + Some(DetectedLayout::Legacy(root)) => return Dirs::home(root), + Some(DetectedLayout::SplitData(data)) => { + return resolve_custom(config, xdg, defaults, Some(data)); + } + None => {} + } + + // 2. Grandfathered installs: an existing `~/.vite-plus` keeps working + // untouched; nothing is moved. + let legacy_root = home_dir.join(LEGACY_HOME_DIR); + if legacy_exists(&legacy_root) { + return Dirs::home(legacy_root); + } + + // 3. Fresh installs: per-category `VP_*_DIR` override → XDG → + // platform-default chains, first match per category. + resolve_custom(config, xdg, defaults, None) +} + +/// Resolve the split (`Custom`) layout: per-category `VP_*_DIR` override → +/// XDG → platform-default chains, first match per category. `data_override` +/// pins the data category (split self-location), bypassing its chain. +fn resolve_custom( + config: &EnvConfig, + xdg: &XdgDirs, + defaults: &PlatformDefaults, + data_override: Option, +) -> Dirs { + let bin = absolute(&config.vp_bin_dir) + .or_else(|| absolute(&xdg.bin)) + .or_else(|| { + // uv's chain: `$XDG_DATA_HOME/../bin`. + absolute(&xdg.data).and_then(|data_home| data_home.parent().map(|p| p.join("bin"))) + }) + .unwrap_or_else(|| defaults.bin.clone()); + let config_dir = absolute(&xdg.config) + .map(|dir| dir.join(APP_DIR_NAME)) + .unwrap_or_else(|| defaults.config.clone()); + let data = data_override + .or_else(|| absolute(&config.vp_data_dir)) + .or_else(|| absolute(&xdg.data).map(|dir| dir.join(APP_DIR_NAME))) + .unwrap_or_else(|| defaults.data.clone()); + let state = absolute(&xdg.state) + .map(|dir| dir.join(APP_DIR_NAME)) + .unwrap_or_else(|| defaults.state.clone()); + let cache = absolute(&config.vp_cache_dir) + .or_else(|| absolute(&xdg.cache).map(|dir| dir.join(APP_DIR_NAME))) + .unwrap_or_else(|| defaults.cache.clone()); + + Dirs { inner: DirsInner::Custom { bin, config: config_dir, data, state, cache } } +} + +impl Dirs { + fn home(root: AbsolutePathBuf) -> Self { + Self { inner: DirsInner::Home(root) } + } + + /// Resolve the on-disk layout for the current environment. + /// + /// Priority: thread-local test override (test builds only) > fresh + /// resolution from [`EnvConfig::get`]. There is no global cache: each + /// call recomputes from the current [`EnvConfig`], so + /// [`EnvConfig::test_scope`] overrides are observed immediately. + /// Callers in hot loops should keep the returned value rather than + /// calling repeatedly. + #[must_use] + pub fn get() -> Self { + #[cfg(test)] + if let Some(dirs) = TEST_DIRS.with(|c| c.borrow().clone()) { + return dirs; + } + Self::resolve_from_env() + } + + fn resolve_from_env() -> Self { + let config = EnvConfig::get(); + + // Rules 0–1 and the XDG variables read the real process environment. + // Skip them while the thread runs under an `EnvConfig` test override + // so tests resolve purely from the injected config: hermetic, + // parallel-safe, and free of host state (a developer machine can + // have a real legacy install on `PATH`). + let under_test_override = EnvConfig::is_test_override_active(); + + let detected = if under_test_override { + None + } else { + self_located_layout().or_else(|| { + vt_path::current_dir() + .ok() + .and_then(|cwd| infer_layout_from_path(env::var_os("PATH").as_deref(), &cwd)) + }) + }; + + // Home directory: `EnvConfig::user_home` first, then the platform + // base dirs, then the historic `$CWD` fallback. + let base_dirs = BaseDirs::new(); + let home_dir = absolute(&config.user_home).or_else(|| { + base_dirs.as_ref().and_then(|dirs| AbsolutePathBuf::new(dirs.home_dir().to_path_buf())) + }); + + let Some(home_dir) = home_dir else { + // No home directory: preserve the historic fallback of a legacy + // root at `$CWD/.vite-plus`. + if let Some(DetectedLayout::Legacy(root)) = detected { + return Self::home(root); + } + let cwd = vt_path::current_dir() + .expect("no home directory and current directory unavailable"); + return Self::home(cwd.join(LEGACY_HOME_DIR)); + }; + + let xdg = if under_test_override { XdgDirs::default() } else { XdgDirs::from_env() }; + // Under a test override, also keep the platform defaults off the + // host `BaseDirs` (matters on Windows, where they come from the real + // `%APPDATA%`/`%LOCALAPPDATA%`) so everything derives from the + // injected home directory. + let defaults_base_dirs = if under_test_override { None } else { base_dirs.as_ref() }; + resolve( + &config, + &home_dir, + &xdg, + &PlatformDefaults::detect(&home_dir, defaults_base_dirs), + detected, + |path| path.as_path().exists(), + ) + } + + /// Directory for executables and shims. + #[must_use] + pub fn bin_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.join("bin"), + DirsInner::Custom { bin, .. } => bin.clone(), + } + } + + /// Directory for user configuration. + #[must_use] + pub fn config_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.clone(), + DirsInner::Custom { config, .. } => config.clone(), + } + } + + /// Directory for payload data (CLI versions, runtimes, package managers). + /// + /// Under the legacy layout every category hangs off the one root, so + /// this is the legacy root itself there. + #[must_use] + pub fn data_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.clone(), + DirsInner::Custom { data, .. } => data.clone(), + } + } + + /// Directory for state files. + #[must_use] + pub fn state_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.clone(), + DirsInner::Custom { state, .. } => state.clone(), + } + } + + /// Directory for disposable caches. + #[must_use] + pub fn cache_dir(&self) -> AbsolutePathBuf { + match &self.inner { + DirsInner::Home(root) => root.join("cache"), + DirsInner::Custom { cache, .. } => cache.clone(), + } + } + + /// Root under which CLI versions are installed. + /// + /// CLI versions are direct children of the data directory (of the legacy + /// root itself under the `Home` layout), so this currently returns + /// [`Dirs::data_dir`] unchanged. Kept as a named helper so call sites + /// express intent and a future `/versions` move stays local. + #[must_use] + pub fn versions_dir(&self) -> AbsolutePathBuf { + self.data_dir() + } + + /// `current` symlink pointing at the active CLI version (`/current`). + #[must_use] + pub fn current_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("current") + } + + /// Managed JavaScript runtimes (`/js_runtime`). + #[must_use] + pub fn js_runtime_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("js_runtime") + } + + /// Managed package managers (`/package_manager`). + #[must_use] + pub fn package_manager_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("package_manager") + } + + /// Globally installed packages (`/packages`). + #[must_use] + pub fn packages_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("packages") + } + + /// Per-binary metadata for globally installed packages (`/bins`). + #[must_use] + pub fn bins_dir(&self) -> AbsolutePathBuf { + self.data_dir().join("bins") + } + + /// Directory for the shell env scripts (`env`, `env.fish`, `env.nu`, + /// `env.ps1`). + /// + /// These live at the legacy root today, which maps to the config + /// category. + #[must_use] + pub fn env_scripts_dir(&self) -> AbsolutePathBuf { + self.config_dir() + } + + /// Main configuration file (`/config.json`). + #[must_use] + pub fn config_file(&self) -> AbsolutePathBuf { + self.config_dir().join("config.json") + } + + /// Session Node.js version override written by `vp env use` + /// (`/.session-node-version`). + #[must_use] + pub fn session_node_version_file(&self) -> AbsolutePathBuf { + self.state_dir().join(".session-node-version") + } + + /// Upgrade-check result cache (`/.upgrade-check.json`). + #[must_use] + pub fn upgrade_check_file(&self) -> AbsolutePathBuf { + self.state_dir().join(".upgrade-check.json") + } + + /// Shim resolution cache (`/resolve_cache.json`). + #[must_use] + pub fn resolve_cache_file(&self) -> AbsolutePathBuf { + self.cache_dir().join("resolve_cache.json") + } + + /// Node.js version index cache + /// (`/js_runtime/node/index_cache.json`). + #[must_use] + pub fn node_index_cache_file(&self) -> AbsolutePathBuf { + self.js_runtime_dir().join("node").join("index_cache.json") + } + + /// Whether the resolved layout is the legacy monolithic root. + /// + /// Used by migration/compat logic and `vp doctor`. + #[must_use] + pub fn is_legacy_layout(&self) -> bool { + matches!(self.inner, DirsInner::Home(_)) + } +} + +/// Test-only helpers. Kept out of the public API: other crates override the +/// environment through [`EnvConfig::test_scope`] / [`EnvConfig::test_guard`] +/// (see the module docs for why that stays hermetic). +#[cfg(test)] +impl Dirs { + /// Run a closure with a test override (thread-local, parallel-safe). + /// + /// The override only applies to the current thread. + /// Other test threads see their own overrides or a fresh resolution. + pub fn test_scope(dirs: Self, f: impl FnOnce() -> R) -> R { + TEST_DIRS.with(|c| { + let prev = c.borrow_mut().replace(dirs); + let result = f(); + *c.borrow_mut() = prev; + result + }) + } + + /// Set a test override and return a guard that restores the previous one on drop. + /// Works with async tests since it uses RAII instead of closures. + #[must_use] + pub fn test_guard(dirs: Self) -> TestDirsGuard { + let prev = TEST_DIRS.with(|c| c.borrow_mut().replace(dirs)); + TestDirsGuard { prev } + } + + /// Build a legacy-layout (`Home`) `Dirs` rooted at `path`, for tests. + /// + /// # Panics + /// + /// Panics if `path` is not absolute. + #[must_use] + pub fn for_test_with_root(path: impl Into) -> Self { + let root = AbsolutePathBuf::new(path.into()).expect("test root must be absolute"); + Self::home(root) + } +} + +/// RAII guard for a test override. Restores the previous override on drop. +#[cfg(test)] +pub struct TestDirsGuard { + prev: Option, +} + +#[cfg(test)] +impl Drop for TestDirsGuard { + fn drop(&mut self) { + TEST_DIRS.with(|c| { + *c.borrow_mut() = self.prev.take(); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An absolute fake home directory for the current platform. + fn test_home() -> AbsolutePathBuf { + let path = if cfg!(windows) { "C:\\Users\\vp" } else { "/home/vp" }; + AbsolutePathBuf::new(PathBuf::from(path)).unwrap() + } + + /// Turn a unix-style test path into an absolute path for the current + /// platform (`/x/y` stays as-is on Unix, becomes `C:\x\y` on Windows). + fn abs(path: &str) -> PathBuf { + #[cfg(windows)] + { + let mut converted = String::from("C:"); + for part in path.split('/') { + if part.is_empty() { + continue; + } + converted.push('\\'); + converted.push_str(part); + } + PathBuf::from(converted) + } + #[cfg(not(windows))] + { + PathBuf::from(path) + } + } + + fn unix_defaults() -> PlatformDefaults { + PlatformDefaults::unix(&test_home()) + } + + fn no_xdg() -> XdgDirs { + XdgDirs::default() + } + + fn never_exists(_: &AbsolutePath) -> bool { + false + } + + fn write_executable(path: &std::path::Path) { + #[cfg(windows)] + std::fs::write(path, b"MZ").unwrap(); + #[cfg(not(windows))] + { + std::fs::write(path, "#!/bin/sh\necho 'fake vp'").unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms).unwrap(); + } + } + + #[test] + fn detected_legacy_root_selects_home_layout_with_legacy_mapping() { + let config = EnvConfig::for_test(); + let detected = Some(DetectedLayout::Legacy(AbsolutePathBuf::new(abs("/vp-home")).unwrap())); + let dirs = + resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), detected, never_exists); + + assert!(dirs.is_legacy_layout()); + let root = abs("/vp-home"); + + // Category accessors reproduce the legacy monolithic layout. + assert_eq!(dirs.bin_dir().as_path(), abs("/vp-home/bin").as_path()); + assert_eq!(dirs.config_dir().as_path(), root.as_path()); + assert_eq!(dirs.data_dir().as_path(), root.as_path()); + assert_eq!(dirs.state_dir().as_path(), root.as_path()); + assert_eq!(dirs.cache_dir().as_path(), abs("/vp-home/cache").as_path()); + } + + #[test] + fn home_layout_named_helpers_reproduce_current_on_disk_layout() { + let dirs = Dirs::for_test_with_root(abs("/vp-home")); + + assert_eq!(dirs.versions_dir().as_path(), abs("/vp-home").as_path()); + assert_eq!(dirs.current_dir().as_path(), abs("/vp-home/current").as_path()); + assert_eq!(dirs.js_runtime_dir().as_path(), abs("/vp-home/js_runtime").as_path()); + assert_eq!(dirs.package_manager_dir().as_path(), abs("/vp-home/package_manager").as_path()); + assert_eq!(dirs.packages_dir().as_path(), abs("/vp-home/packages").as_path()); + assert_eq!(dirs.bins_dir().as_path(), abs("/vp-home/bins").as_path()); + assert_eq!(dirs.env_scripts_dir().as_path(), abs("/vp-home").as_path()); + assert_eq!(dirs.config_file().as_path(), abs("/vp-home/config.json").as_path()); + assert_eq!( + dirs.session_node_version_file().as_path(), + abs("/vp-home/.session-node-version").as_path() + ); + assert_eq!( + dirs.upgrade_check_file().as_path(), + abs("/vp-home/.upgrade-check.json").as_path() + ); + assert_eq!( + dirs.resolve_cache_file().as_path(), + abs("/vp-home/cache/resolve_cache.json").as_path() + ); + assert_eq!( + dirs.node_index_cache_file().as_path(), + abs("/vp-home/js_runtime/node/index_cache.json").as_path() + ); + } + + #[test] + fn existing_legacy_root_selects_home_layout() { + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), None, |_| true); + + assert!(dirs.is_legacy_layout()); + let expected = test_home().join(LEGACY_HOME_DIR); + assert_eq!(dirs.data_dir(), expected); + } + + #[test] + fn detected_legacy_root_wins_over_existing_legacy_root() { + let config = EnvConfig::for_test(); + let detected = Some(DetectedLayout::Legacy(AbsolutePathBuf::new(abs("/vp-home")).unwrap())); + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), detected, |_| true); + + assert!(dirs.is_legacy_layout()); + assert_eq!(dirs.data_dir().as_path(), abs("/vp-home").as_path()); + } + + #[test] + fn detected_split_data_dir_pins_data_category() { + let config = EnvConfig { + vp_bin_dir: Some(abs("/ov/bin")), + vp_data_dir: Some(abs("/ov/data")), + ..EnvConfig::for_test() + }; + let detected = + Some(DetectedLayout::SplitData(AbsolutePathBuf::new(abs("/split-data")).unwrap())); + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), detected, |_| true); + + assert!(!dirs.is_legacy_layout()); + // Data is pinned to the detected dir, ignoring VP_DATA_DIR and the + // grandfathered legacy root. + assert_eq!(dirs.data_dir().as_path(), abs("/split-data").as_path()); + assert_eq!(dirs.current_dir().as_path(), abs("/split-data/current").as_path()); + // Other categories resolve through their normal chains. + assert_eq!(dirs.bin_dir().as_path(), abs("/ov/bin").as_path()); + let home = test_home(); + assert_eq!(dirs.config_dir(), home.join(".config").join(APP_DIR_NAME)); + assert_eq!(dirs.state_dir(), home.join(".local/state").join(APP_DIR_NAME)); + assert_eq!(dirs.cache_dir(), home.join(".cache").join(APP_DIR_NAME)); + } + + #[test] + fn current_bin_install_parent_matches_install_shape() { + let shaped = AbsolutePathBuf::new(abs("/x/current/bin").join(VP_BINARY_NAME)).unwrap(); + assert_eq!(current_bin_install_parent(&shaped).unwrap().as_path(), abs("/x").as_path()); + + // Not under `current/bin`. + let plain_bin = AbsolutePathBuf::new(abs("/x/bin").join(VP_BINARY_NAME)).unwrap(); + assert!(current_bin_install_parent(&plain_bin).is_none()); + + // Right shape, wrong file name. + let other = AbsolutePathBuf::new(abs("/x/current/bin/other")).unwrap(); + assert!(current_bin_install_parent(&other).is_none()); + } + + #[test] + fn classify_install_parent_distinguishes_legacy_from_split() { + let temp_dir = + std::env::temp_dir().join(format!("vp-dirs-test-classify-{}", std::process::id())); + let legacy = temp_dir.join("legacy"); + let split = temp_dir.join("split"); + std::fs::create_dir_all(legacy.join("bin")).unwrap(); + std::fs::create_dir_all(&split).unwrap(); + write_executable(&legacy.join("bin").join(VP_BINARY_NAME)); + + let legacy = AbsolutePathBuf::new(legacy).unwrap(); + let split = AbsolutePathBuf::new(split).unwrap(); + assert!(matches!(classify_install_parent(legacy), DetectedLayout::Legacy(_))); + assert!(matches!(classify_install_parent(split), DetectedLayout::SplitData(_))); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn legacy_root_detection_against_real_tempdir() { + let temp_dir = + std::env::temp_dir().join(format!("vp-dirs-test-legacy-{}", std::process::id())); + let legacy_root = temp_dir.join(LEGACY_HOME_DIR); + std::fs::create_dir_all(&legacy_root).unwrap(); + + let home_dir = AbsolutePathBuf::new(temp_dir.clone()).unwrap(); + let config = EnvConfig::for_test(); + let dirs = resolve( + &config, + &home_dir, + &no_xdg(), + &PlatformDefaults::unix(&home_dir), + None, + |path| path.as_path().exists(), + ); + + assert!(dirs.is_legacy_layout()); + assert_eq!(dirs.data_dir().as_path(), legacy_root.as_path()); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn fresh_install_uses_platform_defaults() { + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), None, never_exists); + + assert!(!dirs.is_legacy_layout()); + let home = test_home(); + assert_eq!(dirs.bin_dir(), home.join(".local/bin")); + assert_eq!(dirs.config_dir(), home.join(".config").join(APP_DIR_NAME)); + assert_eq!(dirs.data_dir(), home.join(".local/share").join(APP_DIR_NAME)); + assert_eq!(dirs.state_dir(), home.join(".local/state").join(APP_DIR_NAME)); + assert_eq!(dirs.cache_dir(), home.join(".cache").join(APP_DIR_NAME)); + } + + #[test] + fn custom_layout_named_helpers_hang_off_category_roots() { + let config = EnvConfig { + vp_bin_dir: Some(abs("/ov/bin")), + vp_data_dir: Some(abs("/ov/data")), + vp_cache_dir: Some(abs("/ov/cache")), + ..EnvConfig::for_test() + }; + let xdg = XdgDirs { + config: Some(abs("/xdg/config")), + state: Some(abs("/xdg/state")), + ..XdgDirs::default() + }; + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + assert_eq!(dirs.bin_dir().as_path(), abs("/ov/bin").as_path()); + assert_eq!(dirs.config_dir().as_path(), abs("/xdg/config/vite-plus").as_path()); + assert_eq!(dirs.data_dir().as_path(), abs("/ov/data").as_path()); + assert_eq!(dirs.state_dir().as_path(), abs("/xdg/state/vite-plus").as_path()); + assert_eq!(dirs.cache_dir().as_path(), abs("/ov/cache").as_path()); + + assert_eq!(dirs.current_dir().as_path(), abs("/ov/data/current").as_path()); + assert_eq!(dirs.js_runtime_dir().as_path(), abs("/ov/data/js_runtime").as_path()); + assert_eq!(dirs.bins_dir().as_path(), abs("/ov/data/bins").as_path()); + assert_eq!( + dirs.config_file().as_path(), + abs("/xdg/config/vite-plus/config.json").as_path() + ); + assert_eq!( + dirs.session_node_version_file().as_path(), + abs("/xdg/state/vite-plus/.session-node-version").as_path() + ); + assert_eq!( + dirs.resolve_cache_file().as_path(), + abs("/ov/cache/resolve_cache.json").as_path() + ); + assert_eq!( + dirs.node_index_cache_file().as_path(), + abs("/ov/data/js_runtime/node/index_cache.json").as_path() + ); + } + + #[test] + fn vp_overrides_apply_per_category() { + // Only VP_DATA_DIR set: data resolves to it, everything else defaults. + let config = EnvConfig { vp_data_dir: Some(abs("/custom/data")), ..EnvConfig::for_test() }; + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), None, never_exists); + + assert_eq!(dirs.data_dir().as_path(), abs("/custom/data").as_path()); + let home = test_home(); + assert_eq!(dirs.bin_dir(), home.join(".local/bin")); + assert_eq!(dirs.cache_dir(), home.join(".cache").join(APP_DIR_NAME)); + } + + #[test] + fn xdg_vars_apply_with_app_subdir() { + let xdg = XdgDirs { + bin: Some(abs("/xdg/bin")), + config: Some(abs("/xdg/config")), + data: Some(abs("/xdg/data")), + state: Some(abs("/xdg/state")), + cache: Some(abs("/xdg/cache")), + }; + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + // XDG_BIN_HOME is used verbatim (like uv); base dirs get `vite-plus`. + assert_eq!(dirs.bin_dir().as_path(), abs("/xdg/bin").as_path()); + assert_eq!(dirs.config_dir().as_path(), abs("/xdg/config/vite-plus").as_path()); + assert_eq!(dirs.data_dir().as_path(), abs("/xdg/data/vite-plus").as_path()); + assert_eq!(dirs.state_dir().as_path(), abs("/xdg/state/vite-plus").as_path()); + assert_eq!(dirs.cache_dir().as_path(), abs("/xdg/cache/vite-plus").as_path()); + } + + #[test] + fn bin_falls_back_to_xdg_data_home_parent() { + // uv's chain: `$XDG_DATA_HOME/../bin` when XDG_BIN_HOME is unset. + let xdg = XdgDirs { data: Some(abs("/xdg/data")), ..XdgDirs::default() }; + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + assert_eq!(dirs.bin_dir().as_path(), abs("/xdg/bin").as_path()); + } + + #[test] + fn vp_overrides_beat_xdg() { + let config = EnvConfig { + vp_data_dir: Some(abs("/ov/data")), + vp_cache_dir: Some(abs("/ov/cache")), + ..EnvConfig::for_test() + }; + let xdg = XdgDirs { + data: Some(abs("/xdg/data")), + cache: Some(abs("/xdg/cache")), + ..XdgDirs::default() + }; + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + assert_eq!(dirs.data_dir().as_path(), abs("/ov/data").as_path()); + assert_eq!(dirs.cache_dir().as_path(), abs("/ov/cache").as_path()); + } + + #[test] + fn relative_xdg_values_are_ignored() { + let xdg = XdgDirs { + bin: Some(PathBuf::from("relative/bin")), + config: Some(PathBuf::from("relative/config")), + data: Some(PathBuf::from("relative/data")), + state: Some(PathBuf::from("relative/state")), + cache: Some(PathBuf::from("relative/cache")), + }; + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &xdg, &unix_defaults(), None, never_exists); + + // All relative values ignored → platform defaults. + let home = test_home(); + assert_eq!(dirs.bin_dir(), home.join(".local/bin")); + assert_eq!(dirs.config_dir(), home.join(".config").join(APP_DIR_NAME)); + assert_eq!(dirs.data_dir(), home.join(".local/share").join(APP_DIR_NAME)); + assert_eq!(dirs.state_dir(), home.join(".local/state").join(APP_DIR_NAME)); + assert_eq!(dirs.cache_dir(), home.join(".cache").join(APP_DIR_NAME)); + } + + #[test] + fn relative_vp_dir_overrides_are_ignored() { + let config = EnvConfig { + vp_bin_dir: Some(PathBuf::from("relative/bin")), + vp_data_dir: Some(PathBuf::from("relative/data")), + vp_cache_dir: Some(PathBuf::from("relative/cache")), + ..EnvConfig::for_test() + }; + let dirs = resolve(&config, &test_home(), &no_xdg(), &unix_defaults(), None, never_exists); + + // All relative values ignored → platform defaults. + let home = test_home(); + assert_eq!(dirs.bin_dir(), home.join(".local/bin")); + assert_eq!(dirs.data_dir(), home.join(".local/share").join(APP_DIR_NAME)); + assert_eq!(dirs.cache_dir(), home.join(".cache").join(APP_DIR_NAME)); + } + + #[test] + fn windows_defaults_follow_platform_conventions() { + let local = AbsolutePathBuf::new(abs("/AppData/Local")).unwrap(); + let roaming = AbsolutePathBuf::new(abs("/AppData/Roaming")).unwrap(); + let defaults = PlatformDefaults::windows(&local, &roaming); + + let base = local.join(APP_DIR_NAME); + assert_eq!(defaults.bin, base.join("bin")); + assert_eq!(defaults.data, base.join("data")); + assert_eq!(defaults.state, base.join("state")); + assert_eq!(defaults.cache, base.join("cache")); + assert_eq!(defaults.config, roaming.join(APP_DIR_NAME)); + + // The platform-neutral resolution core consumes them unchanged. + let config = EnvConfig::for_test(); + let dirs = resolve(&config, &test_home(), &no_xdg(), &defaults, None, never_exists); + assert_eq!(dirs.bin_dir(), base.join("bin")); + assert_eq!(dirs.config_dir(), roaming.join(APP_DIR_NAME)); + } + + #[test] + fn infers_legacy_home_from_vp_on_path() { + let temp_dir = std::env::temp_dir().join(format!("vp-test-vp-path-{}", std::process::id())); + let legacy_home = temp_dir.join(LEGACY_HOME_DIR); + let bin_dir = legacy_home.join("bin"); + let current_bin_dir = legacy_home.join("current").join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + std::fs::create_dir_all(¤t_bin_dir).unwrap(); + write_executable(&bin_dir.join(VP_BINARY_NAME)); + write_executable(¤t_bin_dir.join(VP_BINARY_NAME)); + + let path = env::join_paths([bin_dir.as_os_str()]).unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.clone()).unwrap(); + let inferred = infer_layout_from_path(Some(&path), &cwd); + assert!( + matches!(&inferred, Some(DetectedLayout::Legacy(root)) if root.as_path() == legacy_home.as_path()) + ); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(unix)] + #[test] + fn infers_split_data_dir_from_path_symlink() { + let temp_dir = + std::env::temp_dir().join(format!("vp-test-split-path-{}", std::process::id())); + // Split layout: real binary at `/current/bin/vp`, no legacy + // `/bin/vp` sibling; the PATH entry is a separate bin dir + // whose `vp` symlinks into the data dir. + let data_dir = temp_dir.join("data"); + let current_bin_dir = data_dir.join("current").join("bin"); + let shims_dir = temp_dir.join("shims"); + std::fs::create_dir_all(¤t_bin_dir).unwrap(); + std::fs::create_dir_all(&shims_dir).unwrap(); + write_executable(¤t_bin_dir.join(VP_BINARY_NAME)); + std::os::unix::fs::symlink( + current_bin_dir.join(VP_BINARY_NAME), + shims_dir.join(VP_BINARY_NAME), + ) + .unwrap(); + + let path = env::join_paths([shims_dir.as_os_str()]).unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.clone()).unwrap(); + let inferred = infer_layout_from_path(Some(&path), &cwd); + assert!( + matches!(&inferred, Some(DetectedLayout::SplitData(data)) if data.as_path() == data_dir.canonicalize().unwrap().as_path()) + ); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[cfg(unix)] + #[test] + fn infers_legacy_home_from_path_symlink_into_current_bin() { + // On Unix the legacy `bin/vp` is a symlink into `current/bin`; even + // without the sibling-check fast path (a non-`bin` entry name), + // canonicalization finds the legacy root. + let temp_dir = + std::env::temp_dir().join(format!("vp-test-legacy-link-{}", std::process::id())); + let legacy_home = temp_dir.join(LEGACY_HOME_DIR); + let current_bin_dir = legacy_home.join("current").join("bin"); + let shims_dir = temp_dir.join("shims"); + std::fs::create_dir_all(legacy_home.join("bin")).unwrap(); + std::fs::create_dir_all(¤t_bin_dir).unwrap(); + std::fs::create_dir_all(&shims_dir).unwrap(); + write_executable(&legacy_home.join("bin").join(VP_BINARY_NAME)); + write_executable(¤t_bin_dir.join(VP_BINARY_NAME)); + std::os::unix::fs::symlink( + current_bin_dir.join(VP_BINARY_NAME), + shims_dir.join(VP_BINARY_NAME), + ) + .unwrap(); + + let path = env::join_paths([shims_dir.as_os_str()]).unwrap(); + let cwd = AbsolutePathBuf::new(temp_dir.clone()).unwrap(); + let inferred = infer_layout_from_path(Some(&path), &cwd); + assert!( + matches!(&inferred, Some(DetectedLayout::Legacy(root)) if root.as_path() == legacy_home.canonicalize().unwrap().as_path()) + ); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn inference_ignores_relative_bin_without_current_vp() { + let temp_dir = + std::env::temp_dir().join(format!("vp-test-relative-bin-{}", std::process::id())); + let project_dir = temp_dir.join("project"); + let bin_dir = project_dir.join("tools").join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + write_executable(&bin_dir.join(VP_BINARY_NAME)); + + // `tools/bin` has a `vp` but no `current/bin/vp` sibling layout. + let path = env::join_paths([std::path::Path::new("tools/bin")]).unwrap(); + let cwd = AbsolutePathBuf::new(project_dir.clone()).unwrap(); + assert!(infer_layout_from_path(Some(&path), &cwd).is_none()); + + let _ = std::fs::remove_dir_all(&temp_dir); + } + + #[test] + fn inference_returns_none_without_path() { + assert!(infer_layout_from_path(None, &test_home()).is_none()); + } + + #[test] + fn self_location_does_not_fire_for_test_binary() { + // The test binary is `/debug/deps/-`, never + // `/current/bin/vp`. + assert!(self_located_layout().is_none()); + } + + #[test] + fn test_scope_overrides_get() { + let override_dirs = Dirs::for_test_with_root(abs("/scoped/root")); + Dirs::test_scope(override_dirs, || { + let dirs = Dirs::get(); + assert!(dirs.is_legacy_layout()); + assert_eq!(dirs.data_dir().as_path(), abs("/scoped/root").as_path()); + }); + } + + #[test] + fn test_guard_restores_previous() { + let before = Dirs::get().is_legacy_layout(); + { + let _guard = Dirs::test_guard(Dirs::for_test_with_root(abs("/guarded/root"))); + assert_eq!(Dirs::get().data_dir().as_path(), abs("/guarded/root").as_path()); + } + assert_eq!(Dirs::get().is_legacy_layout(), before); + } + + #[test] + fn get_recomputes_from_env_config_test_scope() { + // No Dirs override installed: Dirs::get() must observe + // EnvConfig::test_scope overrides on every call. A `.vite-plus` + // under the overridden home selects the legacy layout. + let temp_dir = + std::env::temp_dir().join(format!("vp-dirs-test-scope-{}", std::process::id())); + let legacy_root = temp_dir.join(LEGACY_HOME_DIR); + std::fs::create_dir_all(&legacy_root).unwrap(); + + EnvConfig::test_scope(EnvConfig::for_test_with_home(&temp_dir), || { + let dirs = Dirs::get(); + assert!(dirs.is_legacy_layout()); + assert_eq!(dirs.data_dir().as_path(), legacy_root.as_path()); + }); + + let _ = std::fs::remove_dir_all(&temp_dir); + } +} diff --git a/crates/vp_shared/src/env_config.rs b/crates/vp_shared/src/env_config.rs index 6ff07cc398..770713626e 100644 --- a/crates/vp_shared/src/env_config.rs +++ b/crates/vp_shared/src/env_config.rs @@ -26,7 +26,7 @@ //! EnvConfig::for_test_with_home("/tmp/test"), //! || { //! assert_eq!( -//! EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), +//! EnvConfig::get().user_home.as_ref().unwrap().to_str().unwrap(), //! "/tmp/test" //! ); //! }, @@ -51,10 +51,28 @@ thread_local! { /// time. Use `EnvConfig::get()` to access the current config from anywhere. #[derive(Debug, Clone)] pub struct EnvConfig { - /// Override for the vite-plus home directory (`~/.vite-plus`). + /// Override for the directory where executables and shims are installed. /// - /// Env: `VP_HOME` - pub vite_plus_home: Option, + /// Only applies to the split XDG/platform layout (fresh installs); a + /// legacy `~/.vite-plus` layout is all-or-nothing. + /// + /// Env: `VP_BIN_DIR` + pub vp_bin_dir: Option, + + /// Override for the payload data directory (CLI versions, Node.js + /// runtimes, package managers). + /// + /// Only applies to the split XDG/platform layout (fresh installs). + /// + /// Env: `VP_DATA_DIR` + pub vp_data_dir: Option, + + /// Override for the disposable cache directory. + /// + /// Only applies to the split XDG/platform layout (fresh installs). + /// + /// Env: `VP_CACHE_DIR` + pub vp_cache_dir: Option, /// NPM registry URL. /// @@ -106,7 +124,9 @@ impl EnvConfig { /// Called once in `main()` via `EnvConfig::init()`. pub fn from_env() -> Self { Self { - vite_plus_home: std::env::var(env_vars::VP_HOME).ok().map(PathBuf::from), + vp_bin_dir: std::env::var(env_vars::VP_BIN_DIR).ok().map(PathBuf::from), + vp_data_dir: std::env::var(env_vars::VP_DATA_DIR).ok().map(PathBuf::from), + vp_cache_dir: std::env::var(env_vars::VP_CACHE_DIR).ok().map(PathBuf::from), npm_registry: std::env::var(env_vars::NPM_CONFIG_REGISTRY) .or_else(|_| std::env::var(env_vars::NPM_CONFIG_REGISTRY_UPPER)) .unwrap_or_else(|_| "https://registry.npmjs.org".into()) @@ -163,7 +183,7 @@ impl EnvConfig { /// || { /// let config = EnvConfig::get(); /// assert_eq!( - /// config.vite_plus_home.as_ref().unwrap().to_str().unwrap(), + /// config.user_home.as_ref().unwrap().to_str().unwrap(), /// "/tmp/test" /// ); /// }, @@ -193,7 +213,9 @@ impl EnvConfig { #[must_use] pub fn for_test() -> Self { Self { - vite_plus_home: None, + vp_bin_dir: None, + vp_data_dir: None, + vp_cache_dir: None, npm_registry: "https://registry.npmjs.org".into(), node_dist_mirror: None, node_skip_signature_verify: false, @@ -205,9 +227,24 @@ impl EnvConfig { } } - /// Create a test configuration with a custom home directory. + /// Create a test configuration with a custom user home directory. + /// + /// `Dirs` resolves entirely under this home: with no `/.vite-plus` + /// on disk the split XDG/platform layout lands under `` (fully + /// sandboxed, no host filesystem access); create `/.vite-plus/` + /// to select the legacy monolithic layout instead. pub fn for_test_with_home(home: impl Into) -> Self { - Self { vite_plus_home: Some(home.into()), ..Self::for_test() } + Self { user_home: Some(home.into()), ..Self::for_test() } + } + + /// Whether the current thread runs under a `test_scope`/`test_guard` + /// override. + /// + /// `Dirs` uses this to skip host-environment detection (executable + /// self-location, `PATH` inference, XDG variables) so test threads + /// resolve purely from the injected config and stay hermetic. + pub(crate) fn is_test_override_active() -> bool { + TEST_CONFIG.with(|c| c.borrow().is_some()) } /// Set a test config override and return a guard that restores the previous on drop. @@ -239,7 +276,7 @@ mod tests { #[test] fn test_for_test_returns_defaults() { let config = EnvConfig::for_test(); - assert!(config.vite_plus_home.is_none()); + assert!(config.user_home.is_none()); assert_eq!(config.npm_registry, "https://registry.npmjs.org"); assert!(!config.is_ci); assert!(!config.node_skip_signature_verify); @@ -248,7 +285,7 @@ mod tests { #[test] fn test_for_test_with_home() { let config = EnvConfig::for_test_with_home("/tmp/test-home"); - assert_eq!(config.vite_plus_home, Some(PathBuf::from("/tmp/test-home"))); + assert_eq!(config.user_home, Some(PathBuf::from("/tmp/test-home"))); } #[test] @@ -260,14 +297,14 @@ mod tests { }; assert_eq!(config.npm_registry, "https://custom.registry"); assert!(config.is_ci); - assert!(config.vite_plus_home.is_none()); + assert!(config.user_home.is_none()); } #[test] fn test_scope_overrides_get() { EnvConfig::test_scope(EnvConfig::for_test_with_home("/scoped/home"), || { let config = EnvConfig::get(); - assert_eq!(config.vite_plus_home.as_ref().unwrap().to_str().unwrap(), "/scoped/home"); + assert_eq!(config.user_home.as_ref().unwrap().to_str().unwrap(), "/scoped/home"); }); } @@ -275,30 +312,24 @@ mod tests { fn test_scope_restores_previous() { let before = EnvConfig::get(); EnvConfig::test_scope(EnvConfig::for_test_with_home("/tmp/scope"), || { - assert!(EnvConfig::get().vite_plus_home.is_some()); + assert!(EnvConfig::get().user_home.is_some()); }); let after = EnvConfig::get(); - assert_eq!(before.vite_plus_home.is_some(), after.vite_plus_home.is_some()); + assert_eq!(before.user_home.is_some(), after.user_home.is_some()); } #[test] fn test_nested_scopes() { EnvConfig::test_scope(EnvConfig::for_test_with_home("/outer"), || { - assert_eq!( - EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), - "/outer" - ); + assert_eq!(EnvConfig::get().user_home.as_ref().unwrap().to_str().unwrap(), "/outer"); EnvConfig::test_scope(EnvConfig::for_test_with_home("/inner"), || { assert_eq!( - EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), + EnvConfig::get().user_home.as_ref().unwrap().to_str().unwrap(), "/inner" ); }); // Restored to outer - assert_eq!( - EnvConfig::get().vite_plus_home.as_ref().unwrap().to_str().unwrap(), - "/outer" - ); + assert_eq!(EnvConfig::get().user_home.as_ref().unwrap().to_str().unwrap(), "/outer"); }); } diff --git a/crates/vp_shared/src/env_vars.rs b/crates/vp_shared/src/env_vars.rs index 0588b56322..abb0fb69c1 100644 --- a/crates/vp_shared/src/env_vars.rs +++ b/crates/vp_shared/src/env_vars.rs @@ -9,11 +9,40 @@ //! //! Standard system variables (`PATH`, `HOME`, `CI`, etc.) are intentionally //! excluded — they're well-known and benefit less from constant definitions. +//! The `XDG_*_HOME` base-directory variables are the exception: they +//! participate in `Dirs` path resolution, so they get constants too. // ── Config: read once at startup via EnvConfig ────────────────────────── -/// Override for the vite-plus home directory (default: `~/.vite-plus`). -pub const VP_HOME: &str = "VP_HOME"; +/// Override directory for executables and shims. +/// +/// Only applies to the split XDG/platform layout (fresh installs); a legacy +/// `~/.vite-plus` layout is all-or-nothing. +pub const VP_BIN_DIR: &str = "VP_BIN_DIR"; + +/// Override directory for payload data: CLI versions, Node.js runtimes, and +/// package managers (the disk hogs). +pub const VP_DATA_DIR: &str = "VP_DATA_DIR"; + +/// Override directory for the disposable cache. +pub const VP_CACHE_DIR: &str = "VP_CACHE_DIR"; + +// ── XDG base directories: read by Dirs resolution ─────────────────────── + +/// XDG base directory for executables. +pub const XDG_BIN_HOME: &str = "XDG_BIN_HOME"; + +/// XDG base directory for user configuration. +pub const XDG_CONFIG_HOME: &str = "XDG_CONFIG_HOME"; + +/// XDG base directory for user data. +pub const XDG_DATA_HOME: &str = "XDG_DATA_HOME"; + +/// XDG base directory for user state. +pub const XDG_STATE_HOME: &str = "XDG_STATE_HOME"; + +/// XDG base directory for disposable caches. +pub const XDG_CACHE_HOME: &str = "XDG_CACHE_HOME"; /// Log filter string for `tracing_subscriber` (e.g. `"debug"`, `"vt=trace"`). pub const VP_LOG: &str = "VP_LOG"; diff --git a/crates/vp_shared/src/home.rs b/crates/vp_shared/src/home.rs deleted file mode 100644 index c0004fdaf9..0000000000 --- a/crates/vp_shared/src/home.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::env; - -use directories::BaseDirs; -use vt_path::{AbsolutePathBuf, current_dir}; - -use crate::EnvConfig; - -/// Default `VP_HOME` directory name -const VITE_PLUS_HOME_DIR: &str = ".vite-plus"; - -/// Platform-specific binary name for the `vp` CLI. -pub const VP_BINARY_NAME: &str = if cfg!(windows) { "vp.exe" } else { "vp" }; - -/// Get the vite-plus home directory. -/// -/// Uses `EnvConfig::get().vite_plus_home` if set, -/// or the `VP_HOME/bin` directory on `PATH`, -/// otherwise defaults to `~/.vite-plus`. -/// Falls back to `$CWD/.vite-plus` if the home directory cannot be determined. -pub fn get_vp_home() -> std::io::Result { - let config = EnvConfig::get(); - if let Some(ref home) = config.vite_plus_home - && let Some(path) = AbsolutePathBuf::new(home.clone()) - { - return Ok(path); - } - - // Project-local .bin wrappers can shadow Vite+ shims; only trust a full install layout. - if let Some(home) = infer_vp_home_from_path()? { - return Ok(home); - } - - // Default to ~/.vite-plus - match BaseDirs::new() { - Some(dirs) => { - let home = AbsolutePathBuf::new(dirs.home_dir().to_path_buf()).unwrap(); - Ok(home.join(VITE_PLUS_HOME_DIR)) - } - None => { - // Fallback to $CWD/.vite-plus - Ok(current_dir()?.join(VITE_PLUS_HOME_DIR)) - } - } -} - -fn infer_vp_home_from_path() -> std::io::Result> { - let Some(path_env) = env::var_os("PATH") else { - return Ok(None); - }; - - for path_entry in env::split_paths(&path_env) { - if path_entry.as_os_str().is_empty() { - continue; - } - - let bin_dir = if path_entry.is_absolute() { - AbsolutePathBuf::new(path_entry).unwrap() - } else { - current_dir()?.join(path_entry) - }; - if bin_dir.as_path().file_name().is_none_or(|name| name != "bin") { - continue; - } - let Some(home) = bin_dir.parent() else { - continue; - }; - if is_vp_home_layout(&bin_dir, home) { - return Ok(Some(home.to_absolute_path_buf())); - } - } - - Ok(None) -} - -fn is_vp_home_layout(bin_dir: &vt_path::AbsolutePath, home: &vt_path::AbsolutePath) -> bool { - bin_dir.join(VP_BINARY_NAME).as_path().is_file() - && home.join("current").join("bin").join(VP_BINARY_NAME).as_path().is_file() -} - -#[cfg(test)] -mod tests { - use std::ffi::{OsStr, OsString}; - - use super::*; - - struct EnvVarGuard { - name: &'static str, - original: Option, - } - - impl EnvVarGuard { - fn set(name: &'static str, value: impl AsRef) -> Self { - let guard = Self { name, original: std::env::var_os(name) }; - // SAFETY: these serial tests own process environment mutations and restore them on drop. - unsafe { std::env::set_var(name, value) }; - guard - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - // SAFETY: restore the environment snapshot captured by this serial test. - unsafe { - match &self.original { - Some(value) => std::env::set_var(self.name, value), - None => std::env::remove_var(self.name), - } - } - } - } - - struct CurrentDirGuard { - original: AbsolutePathBuf, - } - - impl CurrentDirGuard { - fn set(path: impl AsRef) -> Self { - let guard = Self { original: current_dir().unwrap() }; - std::env::set_current_dir(path).unwrap(); - guard - } - } - - impl Drop for CurrentDirGuard { - fn drop(&mut self) { - std::env::set_current_dir(&self.original).unwrap(); - } - } - - fn write_executable(path: &std::path::Path) { - #[cfg(windows)] - std::fs::write(path, b"MZ").unwrap(); - #[cfg(not(windows))] - { - std::fs::write(path, "#!/bin/sh\necho 'fake vp'").unwrap(); - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(path).unwrap().permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(path, perms).unwrap(); - } - } - - #[test] - fn test_get_vp_home() { - let home = get_vp_home().unwrap(); - assert!(home.ends_with(".vite-plus")); - } - - #[test] - fn test_get_vp_home_with_custom_path() { - let temp_dir = std::env::temp_dir().join("vp-test-custom-home"); - EnvConfig::test_scope(EnvConfig::for_test_with_home(&temp_dir), || { - let home = get_vp_home().unwrap(); - assert_eq!(home.as_path(), temp_dir.as_path()); - }); - } - - #[test] - #[serial_test::serial] - fn test_get_vp_home_without_vp_home_infers_from_vp_on_path() { - let temp_dir = std::env::temp_dir().join(format!("vp-test-vp-path-{}", std::process::id())); - let vite_plus_home = temp_dir.join(".vite-plus"); - let bin_dir = vite_plus_home.join("bin"); - let current_bin_dir = vite_plus_home.join("current").join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - std::fs::create_dir_all(¤t_bin_dir).unwrap(); - - write_executable(&bin_dir.join(VP_BINARY_NAME)); - write_executable(¤t_bin_dir.join(VP_BINARY_NAME)); - - let path = std::env::join_paths([bin_dir.as_os_str()]).unwrap(); - let _path_guard = EnvVarGuard::set("PATH", path); - - // `EnvConfig::for_test()` leaves `vite_plus_home` unset, so `get_vp_home` - // ignores any real `VP_HOME` env var and exercises the PATH inference. - EnvConfig::test_scope(EnvConfig::for_test(), || { - let home = get_vp_home().unwrap(); - assert_eq!(home.as_path(), vite_plus_home.as_path()); - }); - - let _ = std::fs::remove_dir_all(&temp_dir); - } - - #[test] - #[serial_test::serial] - fn test_get_vp_home_without_vp_home_ignores_relative_bin_without_current_vp() { - let temp_dir = - std::env::temp_dir().join(format!("vp-test-relative-bin-{}", std::process::id())); - let project_dir = temp_dir.join("project"); - let bin_dir = project_dir.join("tools").join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - - write_executable(&bin_dir.join(VP_BINARY_NAME)); - - let _cwd_guard = CurrentDirGuard::set(&project_dir); - let path = std::env::join_paths([std::path::Path::new("tools/bin")]).unwrap(); - let _path_guard = EnvVarGuard::set("PATH", path); - - EnvConfig::test_scope(EnvConfig::for_test(), || { - let home = get_vp_home().unwrap(); - assert_ne!(home.as_path(), project_dir.join("tools").as_path()); - }); - - let _ = std::fs::remove_dir_all(&temp_dir); - } -} diff --git a/crates/vp_shared/src/lib.rs b/crates/vp_shared/src/lib.rs index bcac140c23..824291e571 100644 --- a/crates/vp_shared/src/lib.rs +++ b/crates/vp_shared/src/lib.rs @@ -7,11 +7,11 @@ clippy::print_stdout )] +mod dirs; mod env_config; pub mod env_vars; mod error; pub mod header; -mod home; mod http; mod interactivity; mod json_edit; @@ -24,9 +24,9 @@ pub mod string_similarity; mod tls; mod tracing; +pub use dirs::{Dirs, VP_BINARY_NAME}; pub use env_config::{EnvConfig, TestEnvGuard}; pub use error::format_error_chain; -pub use home::{VP_BINARY_NAME, get_vp_home}; pub use http::{HttpClientError, shared_http_client}; pub use interactivity::{ is_ci_environment, is_interactive_terminal, is_stderr_terminal, is_stdin_terminal, diff --git a/crates/vp_trampoline/src/main.rs b/crates/vp_trampoline/src/main.rs index b0f2aa639f..2689bf28dc 100644 --- a/crates/vp_trampoline/src/main.rs +++ b/crates/vp_trampoline/src/main.rs @@ -19,6 +19,22 @@ use std::{ process::{self, Command, ExitStatus}, }; +/// Locate the real `vp.exe` relative to the install base dir (the parent of +/// the bin dir the trampoline copy lives in). +/// +/// Legacy layout first (`/current/bin/vp.exe`, where the bin dir is +/// `/bin`), then the split layout (`/data/current/bin/vp.exe`, +/// where the bin dir is a separate `/bin`). Returns `None` if neither +/// exists. +fn locate_vp_exe(base: &std::path::Path) -> Option { + let legacy = base.join("current").join("bin").join("vp.exe"); + if legacy.is_file() { + return Some(legacy); + } + let split = base.join("data").join("current").join("bin").join("vp.exe"); + split.is_file().then_some(split) +} + /// Preserve Unix signal termination using the shell's `128 + signal` convention. fn exit_code_from_status(status: ExitStatus) -> i32 { #[cfg(unix)] @@ -37,10 +53,19 @@ fn main() { let tool_name = exe_path.file_stem().and_then(|s| s.to_str()).unwrap_or_else(|| process::exit(1)); - // 2. Locate vp.exe: /../current/bin/vp.exe + // 2. Locate vp.exe: legacy `/current/bin/vp.exe` first, then the + // split layout's `/data/current/bin/vp.exe`. let bin_dir = exe_path.parent().unwrap_or_else(|| process::exit(1)); - let vp_home = bin_dir.parent().unwrap_or_else(|| process::exit(1)); - let vp_exe = vp_home.join("current").join("bin").join("vp.exe"); + let base = bin_dir.parent().unwrap_or_else(|| process::exit(1)); + let vp_exe = locate_vp_exe(base).unwrap_or_else(|| { + use std::io::Write; + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_all(b"vite-plus: could not locate vp.exe under "); + let _ = handle.write_all(base.as_os_str().as_encoded_bytes()); + let _ = handle.write_all(b" (tried current\\bin and data\\current\\bin)\n"); + process::exit(1); + }); // 3. Install Ctrl+C handler that ignores signals (child will handle them). // This prevents the "Terminate batch job (Y/N)?" prompt. @@ -48,13 +73,12 @@ fn main() { install_ctrl_handler(); // 4. Spawn vp.exe - // - Always set VP_HOME so vp.exe uses the correct home directory - // (matches what the old .cmd wrappers did with %~dp0..) + // - No VP_HOME needed: vp.exe locates its install root from its own + // `/current/bin/vp.exe` path. // - If tool is "vp", run in normal CLI mode (no VP_SHIM_TOOL) // - Otherwise, set VP_SHIM_TOOL so vp.exe enters shim dispatch let mut cmd = Command::new(&vp_exe); cmd.args(env::args_os().skip(1)); - cmd.env("VP_HOME", vp_home); if tool_name != "vp" { cmd.env("VP_SHIM_TOOL", tool_name); @@ -83,15 +107,54 @@ fn main() { } } -#[cfg(all(test, unix))] +#[cfg(test)] mod tests { use super::*; + #[cfg(unix)] #[test] fn preserves_signal_exit_code() { let status = Command::new("/bin/sh").arg("-c").arg("kill -ILL $$").status().unwrap(); assert_eq!(exit_code_from_status(status), 132); } + + fn test_base(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("vp-trampoline-test-{name}-{}", process::id())) + } + + #[test] + fn locate_vp_exe_prefers_legacy_layout() { + let base = test_base("legacy"); + let legacy_dir = base.join("current").join("bin"); + std::fs::create_dir_all(&legacy_dir).unwrap(); + std::fs::write(legacy_dir.join("vp.exe"), b"MZ").unwrap(); + + assert_eq!(locate_vp_exe(&base).unwrap(), legacy_dir.join("vp.exe")); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn locate_vp_exe_falls_back_to_split_layout() { + let base = test_base("split"); + let split_dir = base.join("data").join("current").join("bin"); + std::fs::create_dir_all(&split_dir).unwrap(); + std::fs::write(split_dir.join("vp.exe"), b"MZ").unwrap(); + + assert_eq!(locate_vp_exe(&base).unwrap(), split_dir.join("vp.exe")); + + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn locate_vp_exe_returns_none_when_absent() { + let base = test_base("absent"); + std::fs::create_dir_all(&base).unwrap(); + + assert!(locate_vp_exe(&base).is_none()); + + let _ = std::fs::remove_dir_all(&base); + } } /// Install a console control handler that ignores Ctrl+C, Ctrl+Break, etc. diff --git a/docker/Dockerfile b/docker/Dockerfile index 100a601386..4c218d92c2 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -60,8 +60,10 @@ RUN apt-get update \ # root work those phases occasionally need. USER vp -ENV VP_HOME=/home/vp/.vite-plus \ - PATH=/home/vp/.vite-plus/bin:$PATH +# The installer defaults to the split XDG layout for fresh installs: the `vp` +# symlink and shims land in ~/.local/bin, versions + `current` in +# ~/.local/share/vite-plus. PATH carries the bin dir; no VP_HOME override. +ENV PATH=/home/vp/.local/bin:$PATH # Install the vp global CLI. The installer downloads the platform package from # npm (or from the registry bridge when VP_PR_VERSION is set). Node.js itself is @@ -74,6 +76,6 @@ ENV VP_HOME=/home/vp/.vite-plus \ # first use. RUN curl -fsSL https://vite.plus | VP_VERSION="${VP_VERSION}" VP_PR_VERSION="${VP_PR_VERSION}" bash \ && vp --version \ - && rm -rf "$VP_HOME/js_runtime" + && rm -rf /home/vp/.local/share/vite-plus/js_runtime WORKDIR /app diff --git a/docs/guide/env.md b/docs/guide/env.md index a1580348d5..28da173bf4 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -21,7 +21,7 @@ latest LTS. When a project declares `packageManager` (or `devEngines.packageManager`) in `package.json`, matching package-manager shims also use that package-manager version. For example, `packageManager: "npm@10.9.4"` makes both `npm` and `npx` run through npm 10.9.4. Alias pairs follow the installed package-manager shims: `npm`/`npx`, `pnpm`/`pnpx`, `yarn`/`yarnpkg`, and `bun`/`bunx`. Vite+ does not translate mismatched commands, so a project pinned to `pnpm` still lets `npm` fall back to the npm that comes with the resolved Node.js runtime. -By default, Vite+ stores its managed runtime and related files in `~/.vite-plus`. If needed, you can override that location with `VP_HOME`. +Fresh installs store the managed runtime and related files in a split XDG-style layout — resolved per category from `VP_BIN_DIR`/`VP_DATA_DIR`/`VP_CACHE_DIR`, the `XDG_*` base directories, and platform defaults. Installs that already have `~/.vite-plus` keep the legacy monolithic layout (grandfathered; nothing is moved). The CLI never reads the `VP_HOME` variable — the installers accept it as an override selecting the legacy layout, and at runtime the CLI locates the install root from the `vp` binary's own path. See [Directory Layout and XDG Variables](/guide/installer-env-vars#directory-layout-and-xdg-variables). References to `VP_HOME` paths below use the legacy layout; under the split layout, substitute the corresponding bin/config/data/state directory. If you want to keep that behavior, run: @@ -43,7 +43,7 @@ This switches to system-first mode, where the shims prefer your system Node.js a ### Setup -- `vp env setup` creates or updates shims in `VP_HOME/bin` (and writes the per-shell setup scripts under `VP_HOME`) +- `vp env setup` creates or updates shims in the Vite+ bin directory (`VP_HOME/bin` in the legacy layout) and writes the per-shell setup scripts to the config directory (`VP_HOME` in the legacy layout) - `vp env on` enables managed mode so shims always use Vite+-managed Node.js - `vp env off` enables system-first mode so shims prefer system Node.js first - `vp env print` prints the shell snippet for the current session @@ -51,6 +51,9 @@ This switches to system-first mode, where the shims prefer your system Node.js a PowerShell needs to dot-source the generated setup script in the current shell before `vp env use` can affect only that shell session: ```powershell +# Split layout (fresh installs) +. "$env:APPDATA\vite-plus\env.ps1" +# Legacy layout (existing ~/.vite-plus installs) . "$env:USERPROFILE\.vite-plus\env.ps1" ``` @@ -76,9 +79,9 @@ node --version vp-use --unset ``` -Only `vp env use` needs this alternate command. Other `vp env` commands work normally in Command Prompt. `vp env setup` creates `vp-use.cmd` under `VP_HOME/bin` on Windows. +Only `vp env use` needs this alternate command. Other `vp env` commands work normally in Command Prompt. `vp env setup` creates `vp-use.cmd` in the Vite+ bin directory (`VP_HOME/bin` in the legacy layout) on Windows. -In CI, `vp env use` can still run without shell initialization. It writes a temporary session file under `VP_HOME` so later shim calls in the same job can resolve the selected Node.js version. +In CI, `vp env use` can still run without shell initialization. It writes a temporary session file to the Vite+ state directory (`VP_HOME` in the legacy layout) so later shim calls in the same job can resolve the selected Node.js version. ### Manage @@ -144,7 +147,7 @@ Vite+ creates a `corepack` shim by default, so corepack works without a system N - On Node.js 25 and later, where corepack is no longer bundled, Vite+ installs corepack as a managed global package on first use. Only the `corepack` binary is linked; run `vp install -g corepack` yourself if you also want the package's pnpm/yarn launchers exposed directly. - If you install corepack explicitly with `vp install -g corepack`, that installation is always preferred. -`corepack enable` normally creates `pnpm`/`yarn` launchers next to the corepack binary, which under Vite+ would not be on `PATH`. The shim fixes this by defaulting `--install-directory` to `VP_HOME/bin`, so after `corepack enable` the launchers are available everywhere and still resolve the project's Node.js and package-manager versions: +`corepack enable` normally creates `pnpm`/`yarn` launchers next to the corepack binary, which under Vite+ would not be on `PATH`. The shim fixes this by defaulting `--install-directory` to the Vite+ bin directory (`VP_HOME/bin` in the legacy layout), so after `corepack enable` the launchers are available everywhere and still resolve the project's Node.js and package-manager versions: ```bash corepack enable # pnpm and yarn now resolve via corepack diff --git a/docs/guide/implode.md b/docs/guide/implode.md index 02a019f5f6..edda71cb96 100644 --- a/docs/guide/implode.md +++ b/docs/guide/implode.md @@ -6,6 +6,8 @@ Use `vp implode` to remove `vp` and all related Vite+ data from your machine. `vp implode` is the cleanup command for removing a Vite+ installation and its managed data. Use it if you no longer want Vite+ to manage your runtime, package manager, and related local tooling state. +It removes the Vite+ directories for the resolved layout — the legacy monolithic root (`~/.vite-plus`, or a custom root chosen at install time), or the split-layout data, config, state, and cache directories plus the vp-owned shims in the bin directory — and cleans the Vite+ lines from your shell profiles. + ::: info If you decide Vite+ is not for you, please [share your feedback with us](https://discord.gg/cAnsqHh5PX). ::: diff --git a/docs/guide/install.md b/docs/guide/install.md index 7eb21a015a..ab6645ffc8 100644 --- a/docs/guide/install.md +++ b/docs/guide/install.md @@ -74,7 +74,7 @@ Updates keep the version spec a package was installed with: a package installed ::: warning These commands do **NOT** interact with the underlying package manager's global installation directory. -Instead, Vite+ manages its own global packages under `VP_HOME/packages`, allowing them to remain available across different Node.js versions. +Instead, Vite+ manages its own global packages in the `packages` subdirectory of its data directory (`VP_HOME/packages` in the legacy `~/.vite-plus` layout; see [Directory Layout and XDG Variables](/guide/installer-env-vars#directory-layout-and-xdg-variables)), allowing them to remain available across different Node.js versions. As a result, commands such as `vp link` do not affect Vite+'s global packages and will not appear in `vp list -g`. ::: diff --git a/docs/guide/installer-env-vars.md b/docs/guide/installer-env-vars.md index b4b087116b..c47960cd79 100644 --- a/docs/guide/installer-env-vars.md +++ b/docs/guide/installer-env-vars.md @@ -25,9 +25,10 @@ These variables control the installer scripts and the standalone Windows install ### `VP_HOME` -- **Purpose**: Installation directory; the installed CLI reads the same variable as the Vite+ home directory (see [Environment](/guide/env)) -- **Default**: `~/.vite-plus` (Unix) or `%USERPROFILE%\.vite-plus` (Windows) +- **Purpose**: Installer-only override that selects the legacy monolithic layout, rooted at the given directory +- **Default**: None — fresh installs use the [split layout](#directory-layout-and-xdg-variables); `~/.vite-plus` is used only when it already exists (grandfathered installs) or when `VP_HOME`/`--install-dir` is set - **CLI equivalent**: `--install-dir` +- **Details**: Only the installers (`install.sh`, `install.ps1`, `vp-setup.exe`) read `VP_HOME`; the installed `vp` CLI never does. At runtime the CLI locates a custom install root from the `vp` binary's own path (`/current/bin/vp`), so installs at a custom `VP_HOME` keep working without any variable set. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). - **Example**: ```bash @@ -75,7 +76,25 @@ When developing Vite+ itself, `VP_LOCAL_TGZ` (path to a local `vite-plus.tgz`) a ## Runtime Variables -These variables configure the installed Vite+ CLI. `VP_HOME` (above) also applies at runtime. +These variables configure the installed Vite+ CLI. + +### `VP_BIN_DIR` + +- **Purpose**: Directory for executables and shims (`node`, `npm`, `npx`, `corepack`, `vpx`, `vpr`, the `vp` wrapper) +- **Default**: `XDG_BIN_HOME` if set, then `XDG_DATA_HOME/../bin`, otherwise `~/.local/bin` (Unix) or `%LOCALAPPDATA%\vite-plus\bin` (Windows) +- **Details**: Only applies in the split layout; ignored when the legacy monolithic layout is selected. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). + +### `VP_DATA_DIR` + +- **Purpose**: Payload data directory (CLI versions, managed Node.js runtimes, package managers, global packages) +- **Default**: `XDG_DATA_HOME/vite-plus` if set, otherwise `~/.local/share/vite-plus` (Unix) or `%LOCALAPPDATA%\vite-plus\data` (Windows) +- **Details**: Only applies in the split layout; ignored when the legacy monolithic layout is selected. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). + +### `VP_CACHE_DIR` + +- **Purpose**: Disposable cache directory +- **Default**: `XDG_CACHE_HOME/vite-plus` if set, otherwise `~/.cache/vite-plus` (Unix) or `%LOCALAPPDATA%\vite-plus\cache` (Windows) +- **Details**: Only applies in the split layout; ignored when the legacy monolithic layout is selected. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). ### `VP_NODE_DIST_MIRROR` @@ -184,7 +203,36 @@ Vite+ also respects these standard environment variables: ### `HOME` / `USERPROFILE` - **Purpose**: User home directory -- **Effect**: Base for the default `~/.vite-plus` path +- **Effect**: Base for the legacy `~/.vite-plus` root and the Unix platform defaults (`~/.local/bin`, `~/.config`, ...) + +### `XDG_BIN_HOME` / `XDG_CONFIG_HOME` / `XDG_DATA_HOME` / `XDG_STATE_HOME` / `XDG_CACHE_HOME` + +- **Purpose**: XDG base directories honored when resolving the split layout +- **Details**: Read directly from the process environment during directory resolution. See [Directory Layout and XDG Variables](#directory-layout-and-xdg-variables). + +## Directory Layout and XDG Variables + +The installed CLI resolves where its files live by picking one of two layouts; the first match wins: + +1. **Executable self-location** — the running `vp` binary's own (canonicalized) path matches `/current/bin/vp`: when `/bin/vp` also exists, `` is a legacy monolithic install root; otherwise `` is the data dir of a split install, and the data category is pinned to it (the other categories resolve through their normal chains). This covers custom-location installs (previously located via `VP_HOME`) and launches without `PATH` context (IDEs, the Windows shim trampoline). +2. **`PATH` inference** — for a `PATH` entry containing a `vp` executable: a `/bin` entry with the legacy layout (`bin/vp` plus `current/bin/vp`) marks `` as a legacy install; otherwise the entry's `vp` is canonicalized and, when it resolves into `/current/bin/vp`, the same legacy-vs-split rule as rule 1 applies. +3. **`~/.vite-plus` exists** — the legacy monolithic layout, grandfathered: existing installs keep working untouched and nothing is moved. +4. **Otherwise (fresh installs)** — a split layout where each category resolves independently through its own override → XDG → platform-default chain: + +| Category | Contents | Resolution (first match wins) | Unix default | Windows default | +| --- | --- | --- | --- | --- | +| Executables and shims | `node`, `npm`, `npx`, `corepack`, `vpx`, `vpr`, the `vp` wrapper | `VP_BIN_DIR` → `XDG_BIN_HOME` → `XDG_DATA_HOME/../bin` | `~/.local/bin` | `%LOCALAPPDATA%\vite-plus\bin` | +| Configuration | `config.json`, shell env scripts | `XDG_CONFIG_HOME/vite-plus` | `~/.config/vite-plus` | `%APPDATA%\vite-plus` | +| Data | CLI versions, managed Node.js runtimes, package managers, global packages, per-binary `bins/*.json` metadata | `VP_DATA_DIR` → `XDG_DATA_HOME/vite-plus` | `~/.local/share/vite-plus` | `%LOCALAPPDATA%\vite-plus\data` | +| State | Session and upgrade-check files | `XDG_STATE_HOME/vite-plus` | `~/.local/state/vite-plus` | `%LOCALAPPDATA%\vite-plus\state` | +| Cache | Disposable caches | `VP_CACHE_DIR` → `XDG_CACHE_HOME/vite-plus` | `~/.cache/vite-plus` | `%LOCALAPPDATA%\vite-plus\cache` | + +Notes: + +- Relative values in the `VP_*_DIR` and `XDG_*` variables are ignored, per the XDG Base Directory specification. +- `VP_BIN_DIR`, `VP_DATA_DIR`, and `VP_CACHE_DIR` only apply in the split layout; the legacy layout is all-or-nothing. +- The CLI never reads `VP_HOME`; rules 1–2 replace it. `VP_HOME` remains an installer-only override that selects the legacy monolithic layout at install time. +- The installers (`install.sh`, `install.ps1`, `vp-setup.exe`) default to the split layout for fresh installs; an existing `~/.vite-plus` keeps the legacy layout. ## Precedence diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index c37507a314..2bd26c7d36 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -6,7 +6,11 @@ # # Environment variables: # VP_VERSION - Version to install (default: latest) -# VP_HOME - Installation directory (default: $env:USERPROFILE\.vite-plus) +# VP_HOME - Installer-only override: install with the legacy monolithic +# layout rooted at this directory (the vp CLI itself never reads +# VP_HOME). Default: split layout under %LOCALAPPDATA%\vite-plus +# (data\bin) + %APPDATA%\vite-plus (config) for fresh installs, or +# %USERPROFILE%\.vite-plus when it already exists (grandfathered). # NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) # VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) # VP_PR_VERSION - PR number or commit SHA to install from the registry bridge @@ -17,9 +21,34 @@ $ErrorActionPreference = "Stop" $ViteVersion = if ($env:VP_VERSION) { $env:VP_VERSION } else { "latest" } -$InstallDir = if ($env:VP_HOME) { $env:VP_HOME } else { "$env:USERPROFILE\.vite-plus" } -# Use ~ shorthand if install dir is under USERPROFILE, matching the final summary output -$NodeManagerBinDisplay = (Join-Path $InstallDir.TrimEnd('\', '/') "bin") -replace [regex]::Escape($env:USERPROFILE), '~' + +# Install layout. An explicit VP_HOME selects the legacy monolithic layout +# rooted at that directory (compat path), as does an existing +# %USERPROFILE%\.vite-plus — grandfathered installs stay put, matching the +# CLI's directory resolution (vp_shared::Dirs). Fresh installs use the split +# Windows layout: data %LOCALAPPDATA%\vite-plus\data (versions + `current`), +# bin %LOCALAPPDATA%\vite-plus\bin (vp.exe trampoline + shims), config +# %APPDATA%\vite-plus (generated env scripts). +$LegacyLayout = $false +if ($env:VP_HOME) { + $InstallDir = $env:VP_HOME + $LegacyLayout = $true +} elseif (Test-Path -LiteralPath "$env:USERPROFILE\.vite-plus" -PathType Container) { + $InstallDir = "$env:USERPROFILE\.vite-plus" + $LegacyLayout = $true +} else { + $localAppData = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { "$env:USERPROFILE\AppData\Local" } + $appData = if ($env:APPDATA) { $env:APPDATA } else { "$env:USERPROFILE\AppData\Roaming" } + $InstallDir = "$localAppData\vite-plus\data" + $ShimBinDir = "$localAppData\vite-plus\bin" + $EnvScriptsDir = "$appData\vite-plus" +} +if ($LegacyLayout) { + $ShimBinDir = Join-Path $InstallDir.TrimEnd('\', '/') "bin" + $EnvScriptsDir = $InstallDir +} +# Use ~ shorthand if the shim bin dir is under USERPROFILE, matching the final summary output +$NodeManagerBinDisplay = $ShimBinDir -replace [regex]::Escape($env:USERPROFILE), '~' # npm registry URL (strip trailing slash if present) $NpmRegistry = if ($env:NPM_CONFIG_REGISTRY) { $env:NPM_CONFIG_REGISTRY.TrimEnd('/') } else { "https://registry.npmjs.org" } # Local tarball for development/testing @@ -573,10 +602,10 @@ function Remove-CurrentLink { } } -# Configure user PATH for ~/.vite-plus/bin +# Configure user PATH for the shim bin dir # Returns: "true" = added, "already" = already configured function Configure-UserPath { - $binPath = "$InstallDir\bin" + $binPath = $ShimBinDir $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if ($userPath -like "*$binPath*") { @@ -632,7 +661,7 @@ function Configure-Nushell { } $autoloadFile = Join-Path $autoloadDir "vite-plus.nu" - $nuEnvRef= (Join-Path $InstallDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' + $nuEnvRef = (Join-Path $EnvScriptsDir "env.nu") -replace [regex]::Escape($env:USERPROFILE), '~' $content = "# Vite+ bin (https://viteplus.dev)`n" + ("source '"+ $nuEnvRef +"'") + "`n" try { @@ -676,7 +705,7 @@ function Refresh-Shims { function Setup-NodeManager { param([string]$BinDir) - $binPath = "$InstallDir\bin" + $binPath = $ShimBinDir # Explicit override via environment variable if ($env:VP_NODE_MANAGER -eq "yes") { @@ -765,7 +794,7 @@ function Main { # Registry bridge mode: resolve the requested PR/SHA to the bridge's # immutable commit version (0.0.0-commit.), the clearly-defined test # version we install. The directory label stays non-semver so it keeps - # out of Cleanup-OldVersions and makes the PR build obvious in ~/.vite-plus. + # out of Cleanup-OldVersions and makes the PR build obvious in the data dir. $PrCommitVersion = Resolve-BridgeCommitVersion -Ref $PrVersion if (-not $PrCommitVersion) { Write-Error-Exit "Could not resolve a registry bridge build for $PrVersion" @@ -919,13 +948,13 @@ function Main { cmd /c mklink /J "$CurrentLink" "$VersionDir" | Out-Null # Create bin directory and vp wrapper (always done) - New-Item -ItemType Directory -Force -Path "$InstallDir\bin" | Out-Null + New-Item -ItemType Directory -Force -Path $ShimBinDir | Out-Null $trampolineSrc = "$VersionDir\bin\vp-shim.exe" if (Test-Path $trampolineSrc) { # New versions: use trampoline exe to avoid "Terminate batch job (Y/N)?" on Ctrl+C - Copy-Item -Path $trampolineSrc -Destination "$InstallDir\bin\vp.exe" -Force + Copy-Item -Path $trampolineSrc -Destination "$ShimBinDir\vp.exe" -Force # Remove legacy .cmd and shell script wrappers from previous versions - foreach ($legacy in @("$InstallDir\bin\vp.cmd", "$InstallDir\bin\vp")) { + foreach ($legacy in @("$ShimBinDir\vp.cmd", "$ShimBinDir\vp")) { if (Test-Path $legacy) { Remove-Item -Path $legacy -Force -ErrorAction SilentlyContinue } @@ -935,28 +964,37 @@ function Main { # Remove any stale trampoline .exe shims left by a newer install — .exe wins # over .cmd on Windows PATH, so leftover trampolines would bypass the wrappers. foreach ($stale in @("vp.exe", "node.exe", "npm.exe", "npx.exe", "corepack.exe", "vpx.exe", "vpr.exe")) { - $stalePath = Join-Path "$InstallDir\bin" $stale + $stalePath = Join-Path $ShimBinDir $stale if (Test-Path $stalePath) { Remove-Item -Path $stalePath -Force -ErrorAction SilentlyContinue } } - # Keep consistent with the original install.ps1 wrapper format + # VP_HOME points the pre-trampoline CLI at its install root: the + # wrapper's parent under the legacy layout; the data dir under the + # split layout (a data dir carries the same versions + `current` + # shape, and these old CLIs still read VP_HOME). + $wrapperHomeRef = if ($LegacyLayout) { '%~dp0..' } else { $InstallDir } $wrapperContent = @" @echo off -set VP_HOME=%~dp0.. +set VP_HOME=$wrapperHomeRef "%VP_HOME%\current\bin\vp.exe" %* exit /b %ERRORLEVEL% "@ - Set-Content -Path "$InstallDir\bin\vp.cmd" -Value $wrapperContent -NoNewline + Set-Content -Path "$ShimBinDir\vp.cmd" -Value $wrapperContent -NoNewline # Also create shell script wrapper for Git Bash/MSYS + $shHomeRef = if ($LegacyLayout) { + '"$(dirname "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")")"' + } else { + '"' + ($InstallDir -replace '\\', '/') + '"' + } $shContent = @" #!/bin/sh -VP_HOME="`$(dirname "`$(dirname "`$(readlink -f "`$0" 2>/dev/null || echo "`$0")")")" +VP_HOME=$shHomeRef export VP_HOME exec "`$VP_HOME/current/bin/vp.exe" "`$@" "@ - Set-Content -Path "$InstallDir\bin\vp" -Value $shContent -NoNewline + Set-Content -Path "$ShimBinDir\vp" -Value $shContent -NoNewline } # Cleanup old versions @@ -971,8 +1009,9 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" $pathResult = Configure-UserPath $nushellResult = Configure-Nushell - # Use ~ shorthand if install dir is under USERPROFILE, otherwise show full path - $displayDir = $InstallDir -replace [regex]::Escape($env:USERPROFILE), '~' + # Use ~ shorthand for paths under USERPROFILE, otherwise show full paths + $displayBinDir = $ShimBinDir -replace [regex]::Escape($env:USERPROFILE), '~' + $displayEnvScriptsDir = $EnvScriptsDir -replace [regex]::Escape($env:USERPROFILE), '~' # ANSI color codes for consistent output $e = [char]27 @@ -1030,23 +1069,23 @@ exec "`$VP_HOME/current/bin/vp.exe" "`$@" Write-Host "" Write-Host " ${YELLOW}note${NC}: Some shells still need manual setup." Write-Host "" - Write-Host " vp was installed to: ${BOLD}${displayDir}\bin${NC}" + Write-Host " vp was installed to: ${BOLD}${displayBinDir}${NC}" Write-Host "" if ($pathResult -eq "failed") { Write-Host " To use vp in Powershell/cmd, manually add it to your PATH:" Write-Host "" - Write-Host " [Environment]::SetEnvironmentVariable('Path', '$InstallDir\bin;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" + Write-Host " [Environment]::SetEnvironmentVariable('Path', '$ShimBinDir;' + [Environment]::GetEnvironmentVariable('Path', 'User'), 'User')" Write-Host "" } if ($nushellResult.Status -eq "failed") { Write-Host " To use vp in Nushell, create a vite-plus.nu file in your preferred vendor autoload directory with:" Write-Host "" - Write-Host " source '$displayDir\env.nu'" + Write-Host " source '$displayEnvScriptsDir\env.nu'" Write-Host "" } Write-Host " Or run vp directly:" Write-Host "" - Write-Host " & `"$InstallDir\bin\vp.exe`"" + Write-Host " & `"$ShimBinDir\vp.exe`"" } Write-Host "" diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 5aa2244fab..319f1c6755 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -7,7 +7,14 @@ # # Environment variables: # VP_VERSION - Version to install (default: latest) -# VP_HOME - Installation directory (default: ~/.vite-plus) +# VP_HOME - Installer-only override: install with the legacy monolithic +# layout rooted at this directory (the vp CLI itself never reads +# VP_HOME). Default: split XDG layout for fresh installs, or +# ~/.vite-plus when it already exists (grandfathered installs). +# VP_DATA_DIR / VP_BIN_DIR - Split-layout overrides for the data dir (CLI +# versions + `current`) and the bin dir (vp symlink + shims). +# XDG_CONFIG_HOME / XDG_DATA_HOME / XDG_BIN_HOME - XDG base directories +# honored by the split layout (relative values are treated as unset). # NPM_CONFIG_REGISTRY - Custom npm registry URL (default: https://registry.npmjs.org) # VP_NODE_MANAGER - Set to "yes" or "no" to skip interactive prompt (for CI/devcontainers) # VP_LOCAL_TGZ - Path to local vite-plus.tgz (for development/testing) @@ -19,14 +26,73 @@ set -e VP_VERSION="${VP_VERSION:-latest}" -INSTALL_DIR="${VP_HOME:-$HOME/.vite-plus}" -# Use $HOME-relative path for shell config references (portable across sessions) -if case "$INSTALL_DIR" in "$HOME"/*) true;; *) false;; esac; then - INSTALL_DIR_REF_POSIX="\$HOME${INSTALL_DIR#"$HOME"}" - INSTALL_DIR_REF_NU="~${INSTALL_DIR#"$HOME"}" + +# Install layout. An explicit VP_HOME selects the legacy monolithic layout +# rooted at that directory (compat path), as does an existing ~/.vite-plus — +# grandfathered installs stay put, matching the CLI's directory resolution +# (vp_shared::Dirs). Fresh installs use the split XDG layout: +# data ${VP_DATA_DIR:-${XDG_DATA_HOME:-~/.local/share}/vite-plus} +# (CLI versions + `current`; the INSTALL_DIR variable below) +# bin ${VP_BIN_DIR:-${XDG_BIN_HOME:-${XDG_DATA_HOME:+$XDG_DATA_HOME/../bin}}} +# default ~/.local/bin (vp symlink + tool shims) +# config ${XDG_CONFIG_HOME:-~/.config}/vite-plus (generated env scripts) +# The CLI resolves the same chains, so these must mirror vp_shared::Dirs. +LEGACY_LAYOUT="false" +if [ -n "${VP_HOME:-}" ]; then + INSTALL_DIR="$VP_HOME" + LEGACY_LAYOUT="true" +elif [ -d "$HOME/.vite-plus" ]; then + INSTALL_DIR="$HOME/.vite-plus" + LEGACY_LAYOUT="true" +else + case "$(uname -s)" in + MINGW* | MSYS* | CYGWIN*) + # Git Bash/MSYS keeps the legacy root: install.ps1 owns the split + # Windows layout (%LOCALAPPDATA%/%APPDATA%), which has no clean MSYS + # mapping. + INSTALL_DIR="$HOME/.vite-plus" + LEGACY_LAYOUT="true" + ;; + esac +fi + +if [ "$LEGACY_LAYOUT" = "false" ]; then + # Relative VP_*_DIR/XDG_* values are treated as unset, per the XDG Base + # Directory Specification. + for dir_var in VP_BIN_DIR VP_DATA_DIR XDG_BIN_HOME XDG_CONFIG_HOME XDG_DATA_HOME; do + eval "dir_val=\${$dir_var:-}" + case "$dir_val" in + '' | /*) ;; + *) unset "$dir_var" ;; + esac + done + unset dir_var dir_val + + INSTALL_DIR="${VP_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/vite-plus}" + if [ -n "${VP_BIN_DIR:-}" ]; then + SHIM_BIN_DIR="$VP_BIN_DIR" + elif [ -n "${XDG_BIN_HOME:-}" ]; then + SHIM_BIN_DIR="$XDG_BIN_HOME" + elif [ -n "${XDG_DATA_HOME:-}" ] && [ "$XDG_DATA_HOME" != "/" ]; then + # uv's chain: $XDG_DATA_HOME/../bin (trailing slashes stripped so + # dirname resolves the same parent the CLI does) + SHIM_BIN_DIR="$(dirname "${XDG_DATA_HOME%/}")/bin" + else + SHIM_BIN_DIR="$HOME/.local/bin" + fi + ENV_SCRIPTS_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vite-plus" +else + SHIM_BIN_DIR="$INSTALL_DIR/bin" + ENV_SCRIPTS_DIR="$INSTALL_DIR" +fi + +# Use $HOME-relative paths for shell config references (portable across sessions) +if case "$ENV_SCRIPTS_DIR" in "$HOME"/*) true;; *) false;; esac; then + ENV_DIR_REF_POSIX="\$HOME${ENV_SCRIPTS_DIR#"$HOME"}" + ENV_DIR_REF_NU="~${ENV_SCRIPTS_DIR#"$HOME"}" else - INSTALL_DIR_REF_POSIX="$INSTALL_DIR" - INSTALL_DIR_REF_NU="$INSTALL_DIR" + ENV_DIR_REF_POSIX="$ENV_SCRIPTS_DIR" + ENV_DIR_REF_NU="$ENV_SCRIPTS_DIR" fi # npm registry URL (strip trailing slash if present) NPM_REGISTRY="${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org}" @@ -688,7 +754,7 @@ configure_zsh_path() { fi result=0 - append_source_to_file "$zshenv" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$zshenv" ". \"$ENV_DIR_REF_POSIX/env\"" "$ENV_SCRIPTS_DIR/env" "$ENV_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshenv")") ;; 2) already+=("$(abbreviate_path "$zshenv")") ;; @@ -697,7 +763,7 @@ configure_zsh_path() { if [ -f "$zshrc" ]; then result=0 - append_source_to_file "$zshrc" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$zshrc" ". \"$ENV_DIR_REF_POSIX/env\"" "$ENV_SCRIPTS_DIR/env" "$ENV_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$zshrc")") ;; 2) already+=("$(abbreviate_path "$zshrc")") ;; @@ -741,7 +807,7 @@ configure_bash_path() { fi existing=1 result=0 - append_source_to_file "$file" ". \"$INSTALL_DIR_REF_POSIX/env\"" "$INSTALL_DIR/env" "$INSTALL_DIR_REF_POSIX/env" || result=$? + append_source_to_file "$file" ". \"$ENV_DIR_REF_POSIX/env\"" "$ENV_SCRIPTS_DIR/env" "$ENV_DIR_REF_POSIX/env" || result=$? case "$result" in 0) updated+=("$(abbreviate_path "$file")") ;; 2) already+=("$(abbreviate_path "$file")") ;; @@ -776,7 +842,7 @@ configure_bash_path() { configure_fish_path() { local fish_config="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish" local fish_content="# Vite+ bin (https://viteplus.dev) -source \"$INSTALL_DIR_REF_POSIX/env.fish\" +source \"$ENV_DIR_REF_POSIX/env.fish\" " local result=0 @@ -811,7 +877,7 @@ configure_nushell_path() { local nushell_autoload="$nushell_dir/vite-plus.nu" local nushell_content="# Vite+ bin (https://viteplus.dev) -source '$INSTALL_DIR_REF_NU/env.nu' +source '$ENV_DIR_REF_NU/env.nu' " local result=0 @@ -883,7 +949,7 @@ refresh_shims() { # Arguments: bin_dir - path to the version's bin directory containing vp setup_node_manager() { local bin_dir="$1" - local bin_path="$INSTALL_DIR/bin" + local bin_path="$SHIM_BIN_DIR" NODE_MANAGER_ENABLED="false" # Resolve vp binary name (vp on Unix, vp.exe on Windows) @@ -937,7 +1003,7 @@ setup_node_manager() { if [ -e /dev/tty ] && [ -t 1 ]; then echo "" echo "Would you like Vite+ to manage your Node.js versions?" - echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$INSTALL_DIR")/bin/ and automatically uses the right version." + echo "It adds \`node\`, \`npm\`, \`npx\`, and \`corepack\` shims to $(abbreviate_path "$SHIM_BIN_DIR")/ and automatically uses the right version." echo "Opt out anytime with \`vp env off\`." echo -n "Press Enter to accept (Y/n): " read -r response < /dev/tty @@ -1028,7 +1094,7 @@ main() { # Registry bridge mode: resolve the requested PR/SHA to the bridge's # immutable commit version (0.0.0-commit.), the clearly-defined test # version we install. The directory label stays non-semver so it keeps out - # of cleanup_old_versions and makes the PR build obvious in `~/.vite-plus/`. + # of cleanup_old_versions and makes the PR build obvious in the data dir. # `|| true` keeps `set -e` from aborting this assignment when resolution # fails (unregistered ref / transient bridge error), so the actionable # error below is reachable instead of the installer exiting silently. @@ -1173,15 +1239,19 @@ WRAPPER_EOF ln -sfn "$VP_VERSION" "$CURRENT_LINK" # Create bin directory and vp entrypoint (always done) - mkdir -p "$INSTALL_DIR/bin" + mkdir -p "$SHIM_BIN_DIR" if [[ "$platform" == win32* ]]; then # Windows: copy trampoline as vp.exe (matching install.ps1) if [ -f "$INSTALL_DIR/current/bin/vp-shim.exe" ]; then - cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$INSTALL_DIR/bin/vp.exe" + cp "$INSTALL_DIR/current/bin/vp-shim.exe" "$SHIM_BIN_DIR/vp.exe" fi + elif [ "$LEGACY_LAYOUT" = "true" ]; then + # Legacy layout: keep the relative symlink target (portable root). + ln -sf "../current/bin/vp" "$SHIM_BIN_DIR/vp" else - # Unix: symlink to current/bin/vp - ln -sf "../current/bin/vp" "$INSTALL_DIR/bin/vp" + # Split layout: the bin dir lives outside the data dir, so link + # absolutely to /current/bin/vp. + ln -sf "$INSTALL_DIR/current/bin/vp" "$SHIM_BIN_DIR/vp" fi # Cleanup old versions @@ -1204,9 +1274,9 @@ WRAPPER_EOF # Configure shell PATH after the install is otherwise complete. configure_shell_path - # Use ~ shorthand if install dir is under HOME, otherwise show full path - local display_dir="${INSTALL_DIR/#$HOME/~}" - local display_location="${display_dir}/bin" + # Use ~ shorthand for the bin dir when it is under HOME + local display_location + display_location="$(abbreviate_path "$SHIM_BIN_DIR")" # Print success message echo "" @@ -1251,11 +1321,11 @@ WRAPPER_EOF echo "" echo " Manual setup instructions:" echo " - Bash/Zsh: add the following to your shell config (~/.bashrc, ~/.zshrc, etc.):" - echo " . \"$INSTALL_DIR_REF_POSIX/env\"" + echo " . \"$ENV_DIR_REF_POSIX/env\"" echo " - Fish: create ${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/vite-plus.fish with:" - echo " source \"$INSTALL_DIR_REF_POSIX/env.fish\"" + echo " source \"$ENV_DIR_REF_POSIX/env.fish\"" echo " - Nushell: create a vendor autoload file with:" - echo " source '$INSTALL_DIR_REF_NU/env.nu'" + echo " source '$ENV_DIR_REF_NU/env.nu'" echo "" echo " Or run vp directly:" echo "" diff --git a/packages/cli/src/config/hooks.ts b/packages/cli/src/config/hooks.ts index 4dd43e59d7..118468700e 100644 --- a/packages/cli/src/config/hooks.ts +++ b/packages/cli/src/config/hooks.ts @@ -55,10 +55,14 @@ d=${rootExpr} __vp_shell=/bin/sh [ -x "$__vp_shell" ] || __vp_shell=$(command -v sh) -if [ -n "\${VP_HOME-}" ]; then +if [ -n "\${VP_BIN_DIR-}" ]; then + __vp_bin="$VP_BIN_DIR" +elif [ -n "\${VP_HOME-}" ]; then __vp_bin="$VP_HOME/bin" -elif [ -n "\${HOME-}" ]; then +elif [ -n "\${HOME-}" ] && [ -d "$HOME/.vite-plus/bin" ]; then __vp_bin="$HOME/.vite-plus/bin" +elif [ -n "\${HOME-}" ]; then + __vp_bin="$HOME/.local/bin" else __vp_bin="" fi diff --git a/packages/cli/src/create/org-tarball.ts b/packages/cli/src/create/org-tarball.ts index 66f15bd371..596114c411 100644 --- a/packages/cli/src/create/org-tarball.ts +++ b/packages/cli/src/create/org-tarball.ts @@ -9,6 +9,12 @@ import { fetchNpmResource } from '../utils/npm-config.ts'; import type { OrgManifest } from './org-manifest.ts'; function getCacheRoot(): string { + // The global CLI injects VP_CACHE_DIR under the split (XDG) layout; legacy + // installs resolve through VP_HOME / ~/.vite-plus as before. + const cacheDir = process.env.VP_CACHE_DIR; + if (cacheDir) { + return path.join(cacheDir, 'create-org'); + } const home = process.env.VP_HOME || path.join(os.homedir(), '.vite-plus'); return path.join(home, 'tmp', 'create-org'); } diff --git a/rfcs/env-command.md b/rfcs/env-command.md index 96c6cf55be..09d6c9d20b 100644 --- a/rfcs/env-command.md +++ b/rfcs/env-command.md @@ -2414,6 +2414,8 @@ The following decisions have been made: 1. **VP_HOME Default Location**: `~/.vite-plus` - Simple, memorable path that's easy for users to find and configure. + > **Note (superseded):** Superseded by [#827](https://github.com/voidzero-dev/vite-plus/issues/827). Path resolution now lives in `crates/vp_shared/src/dirs.rs` (`Dirs`, with `Home`/`Custom` layout variants): a legacy root detected from the `vp` binary's own path or from `PATH`, or an existing `~/.vite-plus`, still selects the legacy monolithic layout (existing installs are grandfathered; nothing is moved), while fresh installs resolve a split XDG/platform layout per category. `VP_HOME` is no longer read by the CLI. + 2. **Windows Shim Strategy**: Trampoline `.exe` files that set `VP_SHIM_TOOL` and spawn `vp.exe` - Avoids "Terminate batch job?" prompt, works in all shells. See [RFC: Trampoline EXE for Shims](./trampoline-exe-for-shims.md). 3. **Corepack Handling**: Included as a default shim (revisited in [#1309](https://github.com/voidzero-dev/vite-plus/issues/1309), originally excluded). The shim prefers a vp-managed global corepack, falls back to the Node-bundled binary (Node.js ≤ 24), and auto-installs a managed copy on Node.js 25+ where corepack is no longer bundled. See [Corepack Shim](#corepack-shim).