From e4455c9ca7f9ff514c3019d15d926e6634655a78 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Sun, 1 Nov 2026 19:07:31 +0600 Subject: [PATCH 1/2] feat(detector): support the Vite+ (vp) CLI as a version manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nodeup could not upgrade Node for users of Vite+ (https://viteplus.dev), the unified toolchain from VoidZero: `nodeup upgrade` reported "no Node.js version manager detected". Vite+'s `vp env` subcommand exposes the same operations nodeup needs from a version manager, so add a VitePlus manager alongside the existing eight. The implementation follows upstream's data-directory resolution chain (VP_HOME → /.vite-plus → VP_DATA_DIR → XDG/platform default) and drives `vp env {list,current,install,uninstall,use,default} node@`. One subtlety worth calling out: Vite+ can run in "system_first" mode, where the shims prefer the system Node over a managed runtime. In that mode `vp env current` reports the *system* version, so Current() returns an error instead of that version. nodeup treats a failed Current() as "active version unknown" and fails closed on cleanup (issue #58) rather than attributing a system install to the managed set. Also registers the manager in the detector registry (both unix and windows), teaches the system-node classifier that /js_runtime/node is manager-managed, and refreshes the CLI prose and docs. Co-Authored-By: puku-ai-2.8 --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- CLAUDE.md | 2 +- README.md | 4 +- SPEC.md | 4 +- cmd/nodeup/main.go | 7 +- docs/managers.md | 14 +- internal/cli/list.go | 6 +- internal/cli/root.go | 6 +- internal/cli/upgrade.go | 2 +- internal/detector/detector.go | 3 +- internal/detector/registry.go | 1 + internal/detector/registry_windows.go | 1 + internal/detector/system_node.go | 9 + internal/detector/system_node_test.go | 35 ++ internal/detector/viteplus.go | 567 ++++++++++++++++++++++ internal/detector/viteplus_test.go | 656 ++++++++++++++++++++++++++ 16 files changed, 1296 insertions(+), 23 deletions(-) create mode 100644 internal/detector/viteplus.go create mode 100644 internal/detector/viteplus_test.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 542f8d5..4761c2d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,7 +26,7 @@ nodeup upgrade --lts - **nodeup version:** `nodeup version` output - **OS:** (e.g., macOS 14.4, Ubuntu 24.04, Windows 11) - **Architecture:** (e.g., arm64, x86_64) -- **Version manager:** (fnm, nvm, Volta, asdf, mise, n, nodenv, nvm-windows) +- **Version manager:** (fnm, nvm, Volta, asdf, mise, n, nodenv, vite-plus, nvm-windows) - **Manager version:** output of `fnm --version` (or equivalent) - **Node version(s) installed:** output of `nodeup list` diff --git a/CLAUDE.md b/CLAUDE.md index bfa5469..aded1e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ internal/cli/ cobra command wiring — thin layer, delegates to int check.go / list.go / packages.go / config.go / version.go internal/detector/ Manager interface + one file per manager detector.go DetectAll(), ResolveManager() - fnm.go nvm.go volta.go asdf.go mise.go n.go nodenv.go nvm_windows.go + fnm.go nvm.go volta.go asdf.go mise.go n.go nodenv.go viteplus.go nvm_windows.go internal/node/ dist.go nodejs.org/dist/index.json client + 24h TTL cache internal/packages/ npm global snapshot / restore / migrate (merged in PR #19) diff --git a/README.md b/README.md index acd7990..1992792 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ them over, and (optionally) cleans up — all interactively, all resumable. ## Features - 🔍 **Auto-detects** your Node.js version manager: `fnm`, `nvm`, `Volta`, - `asdf`, `mise`, `n`, `nodenv`, `nvm-windows` + `asdf`, `mise`, `n`, `nodenv`, `vite-plus`, `nvm-windows` - 📦 **Migrates global npm packages** across Node versions automatically - 🌍 **Cross-platform**: macOS, Linux, Windows (incl. ARM64) - 💾 **Single static binary** — no runtime, no npm dependency @@ -250,7 +250,7 @@ v1.0.0 → v1.1.0 history. | Phase | Status | What shipped | |---|---|---| -| 1 — Detector engine | ✅ | 8/8 managers detected (`fnm`, `nvm`, `Volta`, `asdf`, `mise`, `n`, `nodenv`, `nvm-windows`) | +| 1 — Detector engine | ✅ | 9/9 managers detected (`fnm`, `nvm`, `Volta`, `asdf`, `mise`, `n`, `nodenv`, `vite-plus`, `nvm-windows`) | | 2 — Node version API | ✅ | `nodeup check` + nodejs.org/dist/index.json fetch + TTL cache + HTTP retry/backoff/timeout | | 3 — Package snapshot/restore | ✅ | `nodeup packages {snapshot,restore,diff}` + interrupted-upgrade sentinel + MigrationReport | | 4 — Upgrade command | ✅ | End-to-end `nodeup upgrade` with snapshot → install → migrate → cleanup | diff --git a/SPEC.md b/SPEC.md index e6f0a7b..926173c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -58,8 +58,8 @@ manual steps, single static binary, no Node runtime dependency. `Install/Uninstall/Use/SetDefault(v semver.Version) error`, `GlobalNpmPrefix(v semver.Version) (string, error)`, `Current() (semver.Version, error)` -- 8 managers implementing `Manager`: fnm, nvm, volta, asdf, mise, n, - nodenv, nvm-windows +- 9 managers implementing `Manager`: fnm, nvm, volta, asdf, mise, n, + nodenv, vite-plus, nvm-windows ## §V Invariants diff --git a/cmd/nodeup/main.go b/cmd/nodeup/main.go index 61da1b0..2b79ce3 100644 --- a/cmd/nodeup/main.go +++ b/cmd/nodeup/main.go @@ -2,9 +2,10 @@ // // nodeup is an automated Node.js version upgrade + global package migration // tool. It detects which Node.js version manager is installed (fnm, nvm, -// Volta, asdf, mise, n, nodenv, nvm-windows), fetches the latest LTS and -// Current versions from the nodejs.org release API, snapshots global npm -// packages, installs the new Node versions, and migrates the packages over. +// Volta, asdf, mise, n, nodenv, vite-plus, nvm-windows), fetches the latest +// LTS and Current versions from the nodejs.org release API, snapshots global +// npm packages, installs the new Node versions, and migrates the packages +// over. package main import ( diff --git a/docs/managers.md b/docs/managers.md index c237cba..9681c1f 100644 --- a/docs/managers.md +++ b/docs/managers.md @@ -1,10 +1,10 @@ # Supported Version Managers -All 8 managers listed below are fully detected by `nodeup` as of v1.0.0 +All 9 managers listed below are fully detected by `nodeup` as of v1.1.0 (macOS, Linux, and Windows where applicable). The matrix is the source of truth — keep it in sync with `internal/detector/registry_*.go` and the per-manager files (`fnm.go`, `nvm.go`, `volta.go`, `asdf.go`, `mise.go`, -`n.go`, `nodenv.go`, `nvm_windows.go`). +`n.go`, `nodenv.go`, `viteplus.go`, `nvm_windows.go`). nodeup auto-detects the following version managers. Detection runs in priority order — earlier managers win when multiple are installed. @@ -18,6 +18,7 @@ priority order — earlier managers win when multiple are installed. | [mise](https://mise.jdx.dev) | macOS, Linux | `mise` on PATH, `node` plugin | | [n](https://github.com/tj/n) | macOS, Linux | `n` on PATH, `N_PREFIX` | | [nodenv](https://github.com/nodenv/nodenv) | macOS, Linux | `nodenv` on PATH, `~/.nodenv/shims` | +| [Vite+](https://viteplus.dev) | macOS, Linux, Windows | `vp` on PATH, `VP_HOME`, `VP_DATA_DIR`, `~/.local/share/vite-plus` (macOS/Linux) / `%LOCALAPPDATA%\vite-plus\data` (Windows) | | [nvm-windows](https://github.com/coreybutler/nvm-windows) | Windows only | `nvm.exe` on PATH, registry entry | ## The nvm special case @@ -45,10 +46,10 @@ nodeup upgrade --manager fnm ## When `node` on PATH doesn't belong to a manager nodeup's job is to swap Node versions inside a *version manager* it -manages — `fnm`, `nvm`, `Volta`, `asdf`, `mise`, `n`, `nodenv`, or -`nvm-windows`. If the `node` binary that lives first on your PATH -was installed some other way, nodeup can't safely replace it: the -other installer would just put it back on the next update. +manages — `fnm`, `nvm`, `Volta`, `asdf`, `mise`, `n`, `nodenv`, +`vite-plus`, or `nvm-windows`. If the `node` binary that lives first on +your PATH was installed some other way, nodeup can't safely replace it: +the other installer would just put it back on the next update. Both `nodeup upgrade` and `nodeup check` classify the `node` on PATH into one of these buckets: @@ -147,6 +148,7 @@ Precedence (highest first): | **mise** | `mise install node@` | `mise uninstall node@` | `mise current node` | `SetDefault` writes to `~/.config/mise/config.toml` via `mise use --global`. | | **n** | `n install ` | `n uninstall ` | `n current` (n ≥ 8) | `SetDefault` is a no-op (n auto-uses the latest install). | | **nodenv** | `nodenv install ` | `nodenv uninstall ` | `nodenv version` (treats `system` as "unknown") | SetDefault writes to `~/.nodenv/version`. | +| **vite-plus** | `vp env install node@` | `vp env uninstall node@` | `vp env current node --json` | Uses the `node@` spec form. `Current()` reports "unknown" in Vite+'s `system_first` mode — the active Node is the system install there, not a Vite+-managed runtime — so cleanup falls back to per-version confirmation. | | **nvm-windows**| **unsupported** | **unsupported** | returns `ErrNVMWindowsNotImplemented` | `nvm-windows` doesn't expose a CLI for install/uninstall — `Install`/`Uninstall`/`Use`/`SetDefault`/`GlobalNpmPrefix` all return `ErrNVMWindowsNotImplemented`; the upgrade command leaves the install list alone, and the cleanup prompt runs but each per-version attempt surfaces that sentinel. | For nvm-windows, the cleanup prompt **still runs** — it just diff --git a/internal/cli/list.go b/internal/cli/list.go index 18f5150..fb89780 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -51,7 +51,7 @@ func newListCmd() *cobra.Command { Use: "list", Short: "List installed Node.js versions via the detected manager", Long: `List installed Node.js versions via the detected version manager -(fnm, nvm, Volta, asdf, mise, n, nodenv, nvm-windows). +(fnm, nvm, Volta, asdf, mise, n, nodenv, vite-plus, nvm-windows). With --json, emits a stable envelope consumable by jq/yq/etc. The human-readable default prints one line per detected manager with @@ -60,7 +60,7 @@ versions sorted ascending.`, } cmd.Flags().Bool("json", false, "output as JSON") - cmd.Flags().String("manager", "", "force a specific manager (fnm, nvm, volta, asdf, mise, n, nodenv, nvm-windows); default is auto-detect") + cmd.Flags().String("manager", "", "force a specific manager (fnm, nvm, volta, asdf, mise, n, nodenv, vite-plus, nvm-windows); default is auto-detect") return cmd } @@ -143,7 +143,7 @@ func outputListTable(w ui.Writer, entries []installedEntryJSON) error { w.Println("") if len(entries) == 0 { w.Info("No Node.js version manager detected.") - w.Info("Install one of: fnm, nvm, Volta, asdf, mise, n, nodenv, nvm-windows.") + w.Info("Install one of: fnm, nvm, Volta, asdf, mise, n, nodenv, vite-plus, nvm-windows.") return nil } for _, e := range entries { diff --git a/internal/cli/root.go b/internal/cli/root.go index da5d286..68fa3f7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -53,9 +53,9 @@ func NewRootCmd(version, commit, date string) *cobra.Command { the manual churn. It auto-detects your version manager (fnm, nvm, Volta, asdf, mise, n, -nodenv, nvm-windows), fetches the latest LTS and Current versions from -nodejs.org, installs them, and migrates your global npm packages -across so you don't lose anything. +nodenv, vite-plus, nvm-windows), fetches the latest LTS and Current +versions from nodejs.org, installs them, and migrates your global npm +packages across so you don't lose anything. Common workflows: nodeup upgrade Upgrade both LTS and Current diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 89146b2..060953b 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -35,7 +35,7 @@ migrate your global npm packages across automatically.`, cmd.Flags().Bool("no-cleanup", false, "skip the prompt to remove old versions") cmd.Flags().Bool("cleanup", false, "auto-confirm the post-upgrade cleanup of old versions") cmd.Flags().StringSlice("cleanup-version", nil, "specify version(s) to delete (repeatable; pairs with --cleanup)") - cmd.Flags().String("manager", "", "force a specific manager (fnm, nvm, volta, asdf, mise, n, nodenv)") + cmd.Flags().String("manager", "", "force a specific manager (fnm, nvm, volta, asdf, mise, n, nodenv, vite-plus)") cmd.Flags().BoolP("yes", "y", false, "non-interactive, assume yes to all prompts") cmd.Flags().Bool("offline", false, "use cached data, don't hit nodejs.org") diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 04f09a6..af3ab17 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -1,5 +1,6 @@ // Package detector discovers and abstracts Node.js version managers on the -// user's system: fnm, nvm, Volta, asdf, mise, n, nodenv, nvm-windows. +// user's system: fnm, nvm, Volta, asdf, mise, n, nodenv, vite-plus, +// nvm-windows. // // The public surface is the Manager interface and the DetectAll / // ResolveManager helpers. Each concrete manager lives in its own file diff --git a/internal/detector/registry.go b/internal/detector/registry.go index c236016..652d817 100644 --- a/internal/detector/registry.go +++ b/internal/detector/registry.go @@ -20,6 +20,7 @@ func All() []Manager { NewMise(), NewN(), NewNodenv(), + NewVitePlus(), } } diff --git a/internal/detector/registry_windows.go b/internal/detector/registry_windows.go index 98b3db9..f45f5d5 100644 --- a/internal/detector/registry_windows.go +++ b/internal/detector/registry_windows.go @@ -14,6 +14,7 @@ func All() []Manager { NewMise(), NewN(), NewNodenv(), + NewVitePlus(), NewNVMWindows(), } } diff --git a/internal/detector/system_node.go b/internal/detector/system_node.go index 7efd19a..b78fd88 100644 --- a/internal/detector/system_node.go +++ b/internal/detector/system_node.go @@ -504,6 +504,15 @@ func managerManagedRoots(m Manager) (roots []string, ok bool) { return []string{filepath.Join(h, ".nodenv")}, true } return []string{}, true + case "vite-plus": + // Vite+ stores Node runtimes under its data directory: + // /js_runtime/node//. The data directory resolves + // through VP_HOME → /.vite-plus → VP_DATA_DIR → the + // XDG/platform default, so we hand back every candidate + // rather than just the first hit — the shell nodeup runs in + // may not carry the same Vite+ env vars that `vp` would + // resolve. See vitePlusDataDirs in viteplus.go. + return vitePlusDataDirs(), true } // nvm-windows, scoop, and any unknown manager: no recognized // root pattern; fall through to path-based classification. diff --git a/internal/detector/system_node_test.go b/internal/detector/system_node_test.go index 51134bb..4f3db4a 100644 --- a/internal/detector/system_node_test.go +++ b/internal/detector/system_node_test.go @@ -11,6 +11,8 @@ import ( "testing" "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" ) // --- SystemNodeKind.String -------------------------------------------------- @@ -420,6 +422,7 @@ func TestManagerManagedRoots(t *testing.T) { for _, v := range []string{ "FNM_DIR", "NVM_DIR", "VOLTA_HOME", "ASDF_DIR", "ASDF_DATA_DIR", "MISE_DATA_DIR", "N_PREFIX", "NODENV_ROOT", + "VP_HOME", "VP_DATA_DIR", "LOCALAPPDATA", "XDG_DATA_HOME", "FNM_MULTISHELL_PATH", "XDG_STATE_HOME", "TMPDIR", } { @@ -431,6 +434,15 @@ func TestManagerManagedRoots(t *testing.T) { // expects it as a suffix. fnmStateRoot := filepath.Join("/home/tester", ".local", "state", "fnm_multishells") + // The Vite+ platform-default data dir (no VP_HOME / VP_DATA_DIR + // set). The legacy /.vite-plus candidate only appears when + // the directory holds a "current" link, which /home/tester does + // not, so it stays out of the expectations. + vitePlusDefaultRoot := filepath.Join("/home/tester", ".local", "share", "vite-plus") + if platform.IsWindows() { + vitePlusDefaultRoot = filepath.Join("/home/tester", "AppData", "Local", "vite-plus", "data") + } + cases := []struct { name string mgr string @@ -569,6 +581,29 @@ func TestManagerManagedRoots(t *testing.T) { wantOK: false, description: "we don't recognize the manager, so the classifier falls through to path patterns", }, + { + name: "vite-plus env wins", + mgr: "vite-plus", + envKey: "VP_HOME", envVal: "/srv/vite-plus", + wantRoots: []string{"/srv/vite-plus", vitePlusDefaultRoot}, + wantOK: true, + description: "VP_HOME override, followed by the platform default candidate", + }, + { + name: "vite-plus data dir override", + mgr: "vite-plus", + envKey: "VP_DATA_DIR", envVal: "/srv/vite-plus-data", + wantRoots: []string{"/srv/vite-plus-data", vitePlusDefaultRoot}, + wantOK: true, + description: "VP_DATA_DIR override, followed by the platform default candidate", + }, + { + name: "vite-plus falls back to the platform default", + mgr: "vite-plus", + wantRoots: []string{vitePlusDefaultRoot}, + wantOK: true, + description: "no env → the XDG / platform data dir", + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/internal/detector/viteplus.go b/internal/detector/viteplus.go new file mode 100644 index 0000000..19c0314 --- /dev/null +++ b/internal/detector/viteplus.go @@ -0,0 +1,567 @@ +package detector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// VitePlus is the Vite+ implementation (https://viteplus.dev). Vite+ is +// the unified toolchain from VoidZero: a single `vp` binary that manages +// the Node.js runtime, the package manager, and the frontend toolchain. +// +// Vite+ manages Node runtimes through its `vp env` subcommand, which +// exposes the same operations nodeup needs from a version manager: +// +// - Detect : `vp` on PATH (or /bin/vp on disk) +// - Version : `vp --version`, parsed to drop the leading "vp " +// - ListInstalled : `vp env list node --json` +// - Current : `vp env current node --json` +// - Install : `vp env install node@` +// - Uninstall : `vp env uninstall node@` +// - Use : `vp env use node@` +// - SetDefault : `vp env default node@` +// - GlobalNpmPrefix : /js_runtime/node//lib/node_modules +// +// Vite+ has two on-disk layouts. +// +// The split layout is the default for fresh installs. The data +// directory holds the Node runtimes, and the bin directory holds the +// `vp` binary and the shims: +// +// / +// ├── bin/ # vp binary + node/npm/pnpm shims +// ├── current -> / # the active vp CLI release +// └── js_runtime/ +// └── node/ +// └── 24.22.1/ # one directory per Node runtime +// ├── bin/node +// └── lib/node_modules +// +// The single-root layout is the legacy one, still produced by VP_HOME +// and by pre-0.2.x installs. Everything lives under one root, with the +// data directory mapped to the root itself: +// +// / +// ├── bin/vp +// └── js_runtime/node//... +// +// The data directory resolution mirrors upstream's chain (see +// crates/vp_shared/src/dirs/resolution.rs): VP_HOME → /.vite-plus +// → VP_DATA_DIR → XDG / platform default. We resolve all candidates +// rather than just the first hit because nodeup has to find Node +// installs without shelling out to `vp` — see vitePlusDataDirs. +// +// Vite+ is Node-version-manager-shaped but with one important twist: it +// can run in "system-first" mode, where the shims prefer the system Node +// over a managed runtime. In that mode the active Node is NOT a +// Vite+-managed runtime, so Current() reports an error rather than a +// version. nodeup treats a failed Current() as "active version unknown" +// and fails closed on cleanup (see issue #58). +type VitePlus struct{} + +// NewVitePlus constructs a fresh Vite+ detector. +func NewVitePlus() *VitePlus { return &VitePlus{} } + +func (v *VitePlus) Name() string { return "vite-plus" } + +// runShell (declared in fnm.go) is the package-level seam used by +// VitePlus to invoke the `vp` binary. Tests overwrite it to capture +// arguments and return canned output without spawning a subprocess. +// Production code never reassigns it. + +// vitePlusDirName is Vite+'s directory name under the XDG data home +// and the shared name for its bin/cache/config/state directories. +const vitePlusDirName = "vite-plus" + +// vitePlusLegacyDirName is the single-root directory name Vite+ used +// before the split layout. It only counts as an install root when it +// contains a "current" link — a stray directory must not flip the +// layout, or a later `vp upgrade` could move the roots out from under +// us (upstream makes the same check). +const vitePlusLegacyDirName = ".vite-plus" + +// vitePlusInstalledVersion mirrors one entry in the `node` array of +// `vp env list node --json`. +// +// {"version": "26.13.0", "current": true, "default": false} +// +// We only model the fields we consume. Unknown fields are ignored by +// encoding/json, so a newer Vite+ that adds metadata doesn't break +// parsing. +type vitePlusInstalledVersion struct { + // Version is the installed runtime version (e.g., "26.13.0"). + Version string `json:"version"` + // Current is true when this version is the one the shims resolve + // to right now. It can be false for every entry when the active + // Node is a system install (system-first mode). + Current bool `json:"current"` + // Default is true when this version is the machine-wide default + // set via `vp env default`. + Default bool `json:"default"` +} + +// vitePlusEnvList is the top-level JSON shape of +// `vp env list node --json`. +type vitePlusEnvList struct { + // Node holds the installed Node runtimes. The field is omitted + // entirely when Vite+ has no node runtimes installed. + Node []vitePlusInstalledVersion `json:"node"` +} + +// vitePlusCurrentNode mirrors the `node` object of +// `vp env current node --json`. +// +// { +// "version": "26.13.0", +// "source": "lts", +// "source_path": "/home/me/.local/share/vite-plus/js_runtime/node/26.13.0/bin/node", +// "project_root": "/home/me/project", +// "bin_path": "/home/me/.local/share/vite-plus/js_runtime/node/26.13.0/bin/node", +// "installed": true, +// "mode": "managed" +// } +type vitePlusCurrentNode struct { + // Version is the resolved Node version. + Version string `json:"version"` + // Source explains where the version came from: "lts", + // "default", ".node-version", ".nvmrc", "devEngines.runtime", + // "engines.node", or "system PATH". + Source string `json:"source"` + // Installed is true when the runtime is present on disk. + Installed bool `json:"installed"` + // Mode is "managed" when the shims resolve through Vite+, or + // "system_first" when they prefer the system Node. + Mode string `json:"mode"` +} + +// vitePlusEnvCurrent is the top-level JSON shape of +// `vp env current node --json`. +type vitePlusEnvCurrent struct { + // Node is null when Vite+ cannot resolve a Node version at all. + Node *vitePlusCurrentNode `json:"node"` +} + +// vitePlusDataDirs returns the candidate Vite+ data directories, in +// upstream's own resolution order. The first entry that exists is the +// one Vite+ itself would use; the rest are fallbacks we keep so that +// nodeup can still find Node installs when the environment differs +// from the one `vp` would resolve (e.g. a stale shell without the +// Vite+ env vars exported). +// +// Resolution order (mirrors crates/vp_shared/src/dirs/resolution.rs): +// 1. $VP_HOME — pins the single-root layout; data maps to the root. +// 2. /.vite-plus — the legacy single-root install, but only +// when it contains a "current" link. +// 3. $VP_DATA_DIR — the split-layout data directory. +// 4. The XDG / platform default: $XDG_DATA_HOME/vite-plus or +// ~/.local/share/vite-plus on unix, %LOCALAPPDATA%\vite-plus\data +// on Windows. +// +// Upstream requires VP_BIN_DIR and VP_CACHE_DIR alongside VP_DATA_DIR +// for the override group to take effect. We don't enforce that here: +// we only ever read the data directory, and a user who pinned +// VP_DATA_DIR wants us to look there. +func vitePlusDataDirs() []string { + var dirs []string + + if d := strings.TrimSpace(getenv("VP_HOME")); d != "" { + // Single-root layout: data maps to the root itself. + dirs = append(dirs, d) + } + + if h, err := userHomeDir(); err == nil && h != "" { + legacy := filepath.Join(h, vitePlusLegacyDirName) + // A "current" link identifies a real install. A bare + // directory could be a leftover from a pre-split local CLI + // and must not be treated as an install root. + if _, err := os.Stat(filepath.Join(legacy, "current")); err == nil { + dirs = append(dirs, legacy) + } + } + + if d := strings.TrimSpace(getenv("VP_DATA_DIR")); d != "" { + dirs = append(dirs, d) + } + + if platform.IsWindows() { + if d := strings.TrimSpace(getenv("LOCALAPPDATA")); d != "" { + dirs = append(dirs, filepath.Join(d, vitePlusDirName, "data")) + } else if h, err := userHomeDir(); err == nil && h != "" { + dirs = append(dirs, filepath.Join(h, "AppData", "Local", vitePlusDirName, "data")) + } + } else { + if xdg := strings.TrimSpace(getenv("XDG_DATA_HOME")); xdg != "" { + dirs = append(dirs, filepath.Join(xdg, vitePlusDirName)) + } else if h, err := userHomeDir(); err == nil && h != "" { + dirs = append(dirs, filepath.Join(h, ".local", "share", vitePlusDirName)) + } + } + + return dirs +} + +// Detect returns true when Vite+ appears to be installed. Vite+ is a +// real binary, so we accept either: +// 1. the `vp` binary is on PATH (via platform.LookupManagerBinary), OR +// 2. a /bin/vp exists on disk +// +// Per the Manager contract, Detect MUST be cheap — neither branch +// spawns a subprocess. +func (v *VitePlus) Detect() bool { + if platform.LookupManagerBinary("vp") != "" { + return true + } + for _, dir := range vitePlusDataDirs() { + if dir == "" { + continue + } + bin := filepath.Join(dir, "bin", "vp") + if _, err := os.Stat(bin); err == nil { + return true + } + } + return false +} + +// Version returns Vite+'s own version string. +// +// Real observed output (vp 1.4.0): +// +// vp v1.4.0 +// +// Local vite-plus: +// vite-plus Not found +// ... +// +// The version is on the first line; the rest is a toolchain report we +// don't need. We parse the first line and accept both the "vp " +// form and a bare version. +func (v *VitePlus) Version() (string, error) { + res, err := runShell(context.Background(), "vp", "--version") + if err != nil { + return "", fmt.Errorf("vp --version: %w", err) + } + return parseVitePlusVersion(res.Stdout) +} + +// parseVitePlusVersion extracts the version token from `vp --version` +// output. Exposed (lowercase) for direct unit testing. +// +// Real observed output: +// +// vp v1.4.0 +// +// Local vite-plus: +// vite-plus Not found +// +// We scan the output line by line and take the first line that looks +// like a version: either a "vp " header or a bare version +// token. We deliberately do not require the "vp " prefix — some builds +// and wrappers print the version on its own. +func parseVitePlusVersion(stdout string) (string, error) { + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + // "vp v1.4.0" — the header line. + if strings.EqualFold(fields[0], "vp") { + if len(fields) < 2 { + continue + } + return strings.TrimPrefix(fields[1], "v"), nil + } + // A bare version token on its own line. + if isSemverToken(fields[0]) { + return strings.TrimPrefix(fields[0], "v"), nil + } + } + return "", errors.New("vp --version returned no version token") +} + +// isSemverToken reports whether s looks like a semantic version. We +// don't use semver.NewVersion here because the callers want the raw +// token (with any "v" prefix stripped), not a normalized struct. +func isSemverToken(s string) bool { + s = strings.TrimPrefix(s, "v") + parts := strings.Split(s, ".") + if len(parts) < 2 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + for _, r := range p { + if r < '0' || r > '9' { + return false + } + } + } + return true +} + +// ListInstalled returns every Node.js version Vite+ has installed, +// sorted ascending. Source: `vp env list node --json`. +// +// We scope the query to `node` so Vite+ doesn't also enumerate npm, +// pnpm, Yarn, and Bun — we only care about runtimes. The JSON output +// (rather than the human-readable table) is what we parse; the table +// is hostile to scripting. +// +// Vite+ has no nvm-style "system" sentinel: an uninstalled system +// Node simply doesn't appear in the list. +func (v *VitePlus) ListInstalled(ctx context.Context) ([]semver.Version, error) { + res, err := runShell(ctx, "vp", "env", "list", "node", "--json") + if err != nil { + return nil, fmt.Errorf("vp env list node --json: %w", err) + } + return parseVitePlusInstalled(res.Stdout) +} + +// parseVitePlusInstalled turns raw `vp env list node --json` output +// into a sorted-ascending []semver.Version. Exposed (lowercase) for +// direct unit testing. +// +// Expected input: +// +// { +// "node": [ +// {"version": "24.22.1", "current": false, "default": false}, +// {"version": "26.13.0", "current": true, "default": true} +// ] +// } +// +// An empty array — or a missing "node" key — is valid: Vite+ is +// installed but has no managed runtimes yet. We return a non-nil empty +// slice in that case so callers can rely on len(result) == 0 rather +// than nil-checks. +// +// Unparseable versions are skipped rather than aborting the whole +// list, so a future Vite+ metadata format can't break `nodeup list`. +func parseVitePlusInstalled(stdout string) ([]semver.Version, error) { + out := strings.TrimSpace(stdout) + if out == "" { + // Empty stdout = nothing installed. Treat as an empty list + // rather than a JSON parse error. + return make([]semver.Version, 0), nil + } + + var list vitePlusEnvList + if err := json.Unmarshal([]byte(out), &list); err != nil { + return nil, fmt.Errorf("vp env list JSON: %w", err) + } + + versions := make([]semver.Version, 0, len(list.Node)) + for _, e := range list.Node { + if e.Version == "" { + continue + } + v, err := semver.NewVersion(strings.TrimPrefix(e.Version, "v")) + if err != nil { + // Skip unparseable versions rather than aborting the whole + // list. Forward-compatibility for new metadata formats. + continue + } + versions = append(versions, *v) + } + + // Sort ascending by semver. semver.Collection in v3 requires + // []*Version, so we use sort.Slice with semver.Compare. + sort.Slice(versions, func(i, j int) bool { + return versions[i].Compare(&versions[j]) < 0 + }) + return versions, nil +} + +// --- Mutation methods ---------------------------------------------------- +// +// Install, Uninstall, Use, SetDefault, GlobalNpmPrefix, and Current +// for Vite+ all shell out to the `vp` binary through runShell. +// +// Important Vite+ specifics: +// - Vite+ takes `node@` rather than a bare ``: `vp env install +// node@`. The bare form also works upstream, but the qualified +// form is unambiguous — a bare `22` could be read as a +// package-manager selector. +// - SetDefault runs `vp env default node@`, which persists the +// machine-wide default in Vite+'s config.json. This is what +// `nodeup upgrade` calls after Install. +// - Use runs `vp env use node@`, which writes a per-shell session +// file (e.g. .session-node-version). It does NOT reconfigure the +// user's already-running shell — Vite+'s shell wrapper does that +// by eval'ing the command's output, and nodeup can't. The session +// file does affect later shim calls made from the same job. +// - GlobalNpmPrefix points at /js_runtime/node//lib/node_modules. +// - Current runs `vp env current node --json` which emits a JSON +// object with the resolved version and the shim mode. + +// Install runs `vp env install node@`. Vite+ downloads the runtime +// into /js_runtime/node/ and verifies the release checksum. +func (v *VitePlus) Install(ver semver.Version) error { + res, err := runShell(context.Background(), "vp", "env", "install", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("vp env install node@%s: %w", ver, err) + } + _ = res + return nil +} + +// Uninstall runs `vp env uninstall node@`. Vite+ refuses to remove +// a version that isn't installed ("Node.js v is not installed") and +// exits non-zero, so the error surfaces to the caller rather than +// leaving a silent no-op behind. +func (v *VitePlus) Uninstall(ver semver.Version) error { + res, err := runShell(context.Background(), "vp", "env", "uninstall", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("vp env uninstall node@%s: %w", ver, err) + } + _ = res + return nil +} + +// Use runs `vp env use node@` for the current shell session. +// +// Note this writes a session file rather than reconfiguring the user's +// live shell — Vite+'s shell wrapper (installed by `vp env setup`) is +// what evals the output and exports VP_NODE_VERSION. nodeup shells out +// to the binary directly, so the effect is limited to shims invoked +// later from the same job. +func (v *VitePlus) Use(ver semver.Version) error { + res, err := runShell(context.Background(), "vp", "env", "use", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("vp env use node@%s: %w", ver, err) + } + _ = res + return nil +} + +// SetDefault runs `vp env default node@`, which persists the +// machine-wide default Node version in Vite+'s config.json. This is +// what `nodeup upgrade` calls after Install. +// +// Note that upstream stores dist-tags ("lts", "latest") unresolved so +// they follow the tag. We always pass an exact version, so the stored +// value is pinned. +func (v *VitePlus) SetDefault(ver semver.Version) error { + res, err := runShell(context.Background(), "vp", "env", "default", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("vp env default node@%s: %w", ver, err) + } + _ = res + return nil +} + +// GlobalNpmPrefix returns the per-version global npm directory for +// the given version. Vite+'s on-disk layout is: +// +// /js_runtime/node//lib/node_modules +// +// The data directory is resolved through vitePlusDataDirs() and we +// return the first candidate whose prefix actually exists. +func (v *VitePlus) GlobalNpmPrefix(ver semver.Version) (string, error) { + dirs := vitePlusDataDirs() + if len(dirs) == 0 { + return "", errors.New("vite-plus: cannot resolve the Vite+ data directory") + } + for _, dir := range dirs { + if dir == "" { + continue + } + prefix := filepath.Join(dir, "js_runtime", "node", ver.String(), "lib", "node_modules") + if _, err := os.Stat(prefix); err == nil { + return prefix, nil + } + } + return "", fmt.Errorf("vite-plus global npm prefix for %s not found under %s", ver, dirs[0]) +} + +// Current returns the version Vite+ currently has active for the user. +// Source: `vp env current node --json`. +// +// Real observed output: +// +// { +// "node": { +// "version": "26.13.0", +// "source": "lts", +// "bin_path": "/home/me/.local/share/vite-plus/js_runtime/node/26.13.0/bin/node", +// "installed": true, +// "mode": "managed" +// } +// } +// +// When the shims are in system-first mode, Vite+ reports +// `"mode": "system_first"` and the version it reports is the *system* +// Node's version — not a Vite+-managed runtime. We return an error in +// that case: the active Node isn't one Vite+ manages, so it must not +// be excluded from (or attributed to) the managed set. nodeup treats a +// failed Current() as "active version unknown" and fails closed on +// cleanup (issue #58). +func (v *VitePlus) Current(ctx context.Context) (semver.Version, error) { + res, err := runShell(ctx, "vp", "env", "current", "node", "--json") + if err != nil { + return semver.Version{}, fmt.Errorf("vp env current node --json: %w", err) + } + return parseVitePlusCurrent(res.Stdout) +} + +// parseVitePlusCurrent extracts the active version from `vp env +// current node --json` output. Exposed (lowercase) for direct unit +// testing. +// +// Real observed output: +// +// { +// "node": { +// "version": "26.13.0", +// "source": "lts", +// "installed": true, +// "mode": "managed" +// } +// } +// +// We return an error when: +// - the output is empty or not valid JSON, +// - the "node" object is missing or null (Vite+ can't resolve one), +// - the mode is "system_first" (the active Node is the system one), +// - the version is empty or isn't a semver. +func parseVitePlusCurrent(stdout string) (semver.Version, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return semver.Version{}, errors.New("vp env current node returned empty output") + } + + var cur vitePlusEnvCurrent + if err := json.Unmarshal([]byte(out), &cur); err != nil { + return semver.Version{}, fmt.Errorf("vp env current JSON: %w", err) + } + if cur.Node == nil { + return semver.Version{}, errors.New("vp env current did not report a node version") + } + if cur.Node.Mode == "system_first" { + return semver.Version{}, errors.New("vp env current: 'system_first' mode — the active Node is the system install, not a Vite+-managed runtime") + } + if cur.Node.Version == "" { + return semver.Version{}, errors.New("vp env current reported an empty node version") + } + v, err := semver.NewVersion(strings.TrimPrefix(cur.Node.Version, "v")) + if err != nil { + return semver.Version{}, fmt.Errorf("vp env current: parse %q: %w", cur.Node.Version, err) + } + return *v, nil +} diff --git a/internal/detector/viteplus_test.go b/internal/detector/viteplus_test.go new file mode 100644 index 0000000..adc683c --- /dev/null +++ b/internal/detector/viteplus_test.go @@ -0,0 +1,656 @@ +package detector + +import ( + "context" + "errors" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// --- parseVitePlusVersion ------------------------------------------------ + +func TestParseVitePlusVersion_HeaderLine(t *testing.T) { + // Observed on vp 1.4.0: + // $ vp --version + // vp v1.4.0 + // + // Local vite-plus: + // vite-plus Not found + // ... + got, err := parseVitePlusVersion("vp v1.4.0\n\nLocal vite-plus:\n vite-plus Not found\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.4.0" { + t.Errorf("got %q, want %q", got, "1.4.0") + } +} + +func TestParseVitePlusVersion_BareVersion(t *testing.T) { + // Defensive: some builds/wrappers print the version on its own. + got, err := parseVitePlusVersion("1.4.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.4.0" { + t.Errorf("got %q, want %q", got, "1.4.0") + } +} + +func TestParseVitePlusVersion_SkipsPreamble(t *testing.T) { + // A blank first line must not stop the scan. + got, err := parseVitePlusVersion("\n\nvp v1.4.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.4.0" { + t.Errorf("got %q, want %q", got, "1.4.0") + } +} + +func TestParseVitePlusVersion_Empty(t *testing.T) { + if _, err := parseVitePlusVersion(""); err == nil { + t.Fatal("expected error for empty output, got nil") + } +} + +func TestParseVitePlusVersion_NoVersionToken(t *testing.T) { + if _, err := parseVitePlusVersion("Local vite-plus:\n vite-plus Not found\n"); err == nil { + t.Fatal("expected error for output without a version token, got nil") + } +} + +// --- parseVitePlusInstalled ---------------------------------------------- + +func TestParseVitePlusInstalled_HappyPath(t *testing.T) { + // Real observed output of `vp env list node --json`: + // {"node":[{"version":"24.22.1","current":false,"default":false}, + // {"version":"26.13.0","current":true,"default":true}]} + out := `{"node":[{"version":"24.22.1","current":false,"default":false},{"version":"26.13.0","current":true,"default":true}]}` + got, err := parseVitePlusInstalled(out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2 (%v)", len(got), got) + } + // Sorted ascending. + if got[0].String() != "24.22.1" { + t.Errorf("got[0] = %q, want %q", got[0].String(), "24.22.1") + } + if got[1].String() != "26.13.0" { + t.Errorf("got[1] = %q, want %q", got[1].String(), "26.13.0") + } +} + +func TestParseVitePlusInstalled_SortsAscending(t *testing.T) { + out := `{"node":[{"version":"26.13.0"},{"version":"20.18.0"},{"version":"24.22.1"}]}` + got, err := parseVitePlusInstalled(out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.18.0", "24.22.1", "26.13.0"} + if len(got) != len(want) { + t.Fatalf("len = %d, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %q, want %q", i, got[i].String(), w) + } + } +} + +func TestParseVitePlusInstalled_EmptyArray(t *testing.T) { + got, err := parseVitePlusInstalled(`{"node":[]}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("got = nil, want a non-nil empty slice") + } + if len(got) != 0 { + t.Errorf("len = %d, want 0", len(got)) + } +} + +func TestParseVitePlusInstalled_MissingNodeKey(t *testing.T) { + // Vite+ omits the "node" key entirely when no runtimes are + // installed. + got, err := parseVitePlusInstalled(`{}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("got = nil, want a non-nil empty slice") + } + if len(got) != 0 { + t.Errorf("len = %d, want 0", len(got)) + } +} + +func TestParseVitePlusInstalled_EmptyStdout(t *testing.T) { + got, err := parseVitePlusInstalled(" \n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("got = nil, want a non-nil empty slice") + } + if len(got) != 0 { + t.Errorf("len = %d, want 0", len(got)) + } +} + +func TestParseVitePlusInstalled_SkipsUnparseableVersions(t *testing.T) { + // A future metadata format must not break `nodeup list` — skip + // the entries we can't parse and keep the rest. + out := `{"node":[{"version":""},{"version":"not-a-version"},{"version":"22.11.0"}]}` + got, err := parseVitePlusInstalled(out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 { + t.Fatalf("len = %d, want 1 (%v)", len(got), got) + } + if got[0].String() != "22.11.0" { + t.Errorf("got[0] = %q, want %q", got[0].String(), "22.11.0") + } +} + +func TestParseVitePlusInstalled_InvalidJSON(t *testing.T) { + if _, err := parseVitePlusInstalled("not json"); err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +// --- parseVitePlusCurrent ------------------------------------------------ + +func TestParseVitePlusCurrent_ManagedMode(t *testing.T) { + // Real observed output of `vp env current node --json`: + // {"node":{"version":"26.13.0","source":"lts","installed":true,"mode":"managed"}} + out := `{"node":{"version":"26.13.0","source":"lts","installed":true,"mode":"managed"}}` + got, err := parseVitePlusCurrent(out) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.String() != "26.13.0" { + t.Errorf("got %q, want %q", got.String(), "26.13.0") + } +} + +func TestParseVitePlusCurrent_SystemFirstModeFails(t *testing.T) { + // In system-first mode the reported version is the SYSTEM Node, + // not a Vite+-managed runtime. Reporting it would let nodeup + // wrongly treat a system install as the active managed version, + // so Current() must fail (nodeup then fails closed — issue #58). + out := `{"node":{"version":"22.11.0","source":"system PATH","installed":false,"mode":"system_first"}}` + if _, err := parseVitePlusCurrent(out); err == nil { + t.Fatal("expected error in system_first mode, got nil") + } +} + +func TestParseVitePlusCurrent_MissingNodeObject(t *testing.T) { + if _, err := parseVitePlusCurrent(`{"node":null}`); err == nil { + t.Fatal("expected error for null node object, got nil") + } + if _, err := parseVitePlusCurrent(`{}`); err == nil { + t.Fatal("expected error for missing node object, got nil") + } +} + +func TestParseVitePlusCurrent_EmptyOutput(t *testing.T) { + if _, err := parseVitePlusCurrent(""); err == nil { + t.Fatal("expected error for empty output, got nil") + } +} + +func TestParseVitePlusCurrent_InvalidJSON(t *testing.T) { + if _, err := parseVitePlusCurrent("not json"); err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestParseVitePlusCurrent_EmptyVersion(t *testing.T) { + out := `{"node":{"version":"","source":"lts","installed":true,"mode":"managed"}}` + if _, err := parseVitePlusCurrent(out); err == nil { + t.Fatal("expected error for empty version, got nil") + } +} + +func TestParseVitePlusCurrent_NonSemverVersion(t *testing.T) { + out := `{"node":{"version":"not-a-version","source":"lts","installed":true,"mode":"managed"}}` + if _, err := parseVitePlusCurrent(out); err == nil { + t.Fatal("expected error for non-semver version, got nil") + } +} + +// --- Version / ListInstalled methods ------------------------------------- + +func TestVitePlus_Version(t *testing.T) { + var captured string + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + captured = req + return &platform.RunResult{Stdout: "vp v1.4.0\n"}, nil + }) + + got, err := NewVitePlus().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.4.0" { + t.Errorf("got %q, want %q", got, "1.4.0") + } + if captured != "vp --version" { + t.Errorf("invoked %q, want %q", captured, "vp --version") + } +} + +func TestVitePlus_Version_PropagatesError(t *testing.T) { + wantErr := errors.New("simulated vp failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewVitePlus().Version() + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestVitePlus_ListInstalled(t *testing.T) { + var captured string + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + captured = req + return &platform.RunResult{Stdout: `{"node":[{"version":"26.13.0"}]}`}, nil + }) + + got, err := NewVitePlus().ListInstalled(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0].String() != "26.13.0" { + t.Fatalf("got %v, want [26.13.0]", got) + } + if captured != "vp env list node --json" { + t.Errorf("invoked %q, want %q", captured, "vp env list node --json") + } +} + +func TestVitePlus_ListInstalled_PropagatesError(t *testing.T) { + wantErr := errors.New("simulated vp failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewVitePlus().ListInstalled(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestVitePlus_Current(t *testing.T) { + var captured string + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + captured = req + return &platform.RunResult{Stdout: `{"node":{"version":"26.13.0","mode":"managed"}}`}, nil + }) + + got, err := NewVitePlus().Current(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.String() != "26.13.0" { + t.Errorf("got %q, want %q", got.String(), "26.13.0") + } + if captured != "vp env current node --json" { + t.Errorf("invoked %q, want %q", captured, "vp env current node --json") + } +} + +func TestVitePlus_Current_PropagatesError(t *testing.T) { + wantErr := errors.New("simulated vp failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewVitePlus().Current(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +// --- Mutation methods ---------------------------------------------------- + +func TestVitePlus_MutationMethodsInvokeShell(t *testing.T) { + // Vite+ takes the qualified `node@` spec form for every + // mutation. A bare version would be ambiguous. + v := NewVitePlus() + ver := semver.MustParse("22.11.0") + + type tc struct { + name string + call func() error + wantArg string + } + cases := []tc{ + {"Install", func() error { return v.Install(*ver) }, "env install node@22.11.0"}, + {"Uninstall", func() error { return v.Uninstall(*ver) }, "env uninstall node@22.11.0"}, + {"Use", func() error { return v.Use(*ver) }, "env use node@22.11.0"}, + {"SetDefault", func() error { return v.SetDefault(*ver) }, "env default node@22.11.0"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var captured string + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + captured = req + return &platform.RunResult{}, nil + }) + if err := c.call(); err != nil { + t.Fatalf("%s: unexpected error: %v", c.name, err) + } + want := "vp " + c.wantArg + if captured != want { + t.Errorf("%s invoked %q, want %q", c.name, captured, want) + } + }) + } +} + +func TestVitePlus_MutationMethodsPropagateError(t *testing.T) { + wantErr := errors.New("simulated vp failure") + v := NewVitePlus() + ver := semver.MustParse("22.11.0") + + cases := []struct { + name string + call func() error + }{ + {"Install", func() error { return v.Install(*ver) }}, + {"Uninstall", func() error { return v.Uninstall(*ver) }}, + {"Use", func() error { return v.Use(*ver) }}, + {"SetDefault", func() error { return v.SetDefault(*ver) }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + err := c.call() + if err == nil { + t.Fatalf("%s: expected error, got nil", c.name) + } + if !errors.Is(err, wantErr) { + t.Errorf("%s: error %v should wrap %v", c.name, err, wantErr) + } + }) + } +} + +// --- Detect -------------------------------------------------------------- + +func TestVitePlus_Detect_NoBinaryOnPath(t *testing.T) { + // No `vp` binary on PATH and no Vite+ data dir. Detect() must + // return false — and must not shell out. + // + // We point every candidate data dir at an empty temp dir so a + // developer machine (or CI runner) with Vite+ installed doesn't + // trip the on-disk branch. + t.Setenv("PATH", t.TempDir()) + clearVitePlusEnv(t) + empty := t.TempDir() + t.Setenv("VP_HOME", empty) + t.Setenv("VP_DATA_DIR", empty) + t.Setenv("XDG_DATA_HOME", empty) + t.Setenv("LOCALAPPDATA", empty) + isolateVitePlusHome(t, empty) + + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + t.Fatalf("Detect() must not invoke runShell (was called with %s %v)", name, a) + return nil, nil + } + t.Cleanup(func() { runShell = orig }) + + if NewVitePlus().Detect() { + t.Error("Detect() = true with no vp installed, want false") + } +} + +func TestVitePlus_Detect_FindsBinaryOnPath(t *testing.T) { + binDir := t.TempDir() + binName := "vp" + if runtime.GOOS == "windows" { + binName = "vp.exe" + } + if err := os.WriteFile(filepath.Join(binDir, binName), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", binDir) + clearVitePlusEnv(t) + isolateVitePlusHome(t, t.TempDir()) + + orig := runShell + called := false + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + called = true + return nil, nil + } + t.Cleanup(func() { runShell = orig }) + + if !NewVitePlus().Detect() { + t.Error("Detect() = false with vp on PATH, want true") + } + if called { + t.Error("Detect() invoked runShell — must be a pure on-disk check") + } +} + +func TestVitePlus_Detect_FindsBinaryInDataDir(t *testing.T) { + // `vp` isn't on PATH, but /bin/vp exists on disk. + binDir := t.TempDir() + t.Setenv("PATH", t.TempDir()) + clearVitePlusEnv(t) + t.Setenv("VP_HOME", binDir) + + binName := "vp" + if runtime.GOOS == "windows" { + binName = "vp.exe" + } + if err := os.MkdirAll(filepath.Join(binDir, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(binDir, "bin", binName), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + if !NewVitePlus().Detect() { + t.Error("Detect() = false with vp in the data dir, want true") + } +} + +// --- vitePlusDataDirs ---------------------------------------------------- + +// clearVitePlusEnv blanks every env var vitePlusDataDirs reads so a +// test runner with Vite+ installed doesn't make assertions noisy. +func clearVitePlusEnv(t *testing.T) { + t.Helper() + for _, v := range []string{"VP_HOME", "VP_DATA_DIR", "XDG_DATA_HOME", "LOCALAPPDATA"} { + t.Setenv(v, "") + } +} + +// isolateVitePlusHome points the userHomeDir seam at a throwaway +// directory so the legacy /.vite-plus candidate never resolves +// against the developer's real home. +func isolateVitePlusHome(t *testing.T, dir string) { + t.Helper() + orig := userHomeDir + t.Cleanup(func() { userHomeDir = orig }) + userHomeDir = func() (string, error) { return dir, nil } +} + +func TestVitePlusDataDirs_VPHomeWins(t *testing.T) { + clearVitePlusEnv(t) + t.Setenv("VP_HOME", "/srv/vite-plus") + t.Setenv("VP_DATA_DIR", "/srv/vite-plus-data") + + dirs := vitePlusDataDirs() + if len(dirs) == 0 { + t.Fatal("dirs = empty, want at least one entry") + } + if dirs[0] != "/srv/vite-plus" { + t.Errorf("dirs[0] = %q, want %q", dirs[0], "/srv/vite-plus") + } + if !containsString(dirs, "/srv/vite-plus-data") { + t.Errorf("dirs = %v, want it to contain %q", dirs, "/srv/vite-plus-data") + } +} + +func TestVitePlusDataDirs_XDGDefault(t *testing.T) { + clearVitePlusEnv(t) + + dirs := vitePlusDataDirs() + if len(dirs) == 0 { + t.Fatal("dirs = empty, want at least one entry") + } + want := filepath.Join(homeDirForTest(t), ".local", "share", "vite-plus") + if platform.IsWindows() { + want = filepath.Join(homeDirForTest(t), "AppData", "Local", "vite-plus", "data") + } + if !containsString(dirs, want) { + t.Errorf("dirs = %v, want it to contain %q", dirs, want) + } +} + +func TestVitePlusDataDirs_XDGDataHomeOverride(t *testing.T) { + clearVitePlusEnv(t) + if platform.IsWindows() { + t.Skip("XDG_DATA_HOME does not apply on Windows") + } + t.Setenv("XDG_DATA_HOME", "/srv/xdg") + + dirs := vitePlusDataDirs() + want := filepath.Join("/srv/xdg", "vite-plus") + if !containsString(dirs, want) { + t.Errorf("dirs = %v, want it to contain %q", dirs, want) + } +} + +func TestVitePlusDataDirs_LegacyHomeOnlyWhenCurrentLinkExists(t *testing.T) { + clearVitePlusEnv(t) + + home := t.TempDir() + origHome := userHomeDir + t.Cleanup(func() { userHomeDir = origHome }) + userHomeDir = func() (string, error) { return home, nil } + + // No "current" link → the legacy root must NOT be reported. + legacy := filepath.Join(home, vitePlusLegacyDirName) + if err := os.MkdirAll(legacy, 0o755); err != nil { + t.Fatal(err) + } + dirs := vitePlusDataDirs() + if containsString(dirs, legacy) { + t.Errorf("dirs = %v, want it to omit %q (no current link)", dirs, legacy) + } + + // With a "current" entry present → the legacy root is a candidate. + if err := os.WriteFile(filepath.Join(legacy, "current"), []byte("1.4.0"), 0o644); err != nil { + t.Fatal(err) + } + dirs = vitePlusDataDirs() + if !containsString(dirs, legacy) { + t.Errorf("dirs = %v, want it to contain %q", dirs, legacy) + } +} + +// --- GlobalNpmPrefix ----------------------------------------------------- + +func TestVitePlus_GlobalNpmPrefix(t *testing.T) { + clearVitePlusEnv(t) + root := t.TempDir() + t.Setenv("VP_HOME", root) + + ver := semver.MustParse("22.11.0") + prefix := filepath.Join(root, "js_runtime", "node", "22.11.0", "lib", "node_modules") + if err := os.MkdirAll(prefix, 0o755); err != nil { + t.Fatal(err) + } + + got, err := NewVitePlus().GlobalNpmPrefix(*ver) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != prefix { + t.Errorf("got %q, want %q", got, prefix) + } +} + +func TestVitePlus_GlobalNpmPrefix_NotFound(t *testing.T) { + clearVitePlusEnv(t) + t.Setenv("VP_HOME", t.TempDir()) + + ver := semver.MustParse("22.11.0") + if _, err := NewVitePlus().GlobalNpmPrefix(*ver); err == nil { + t.Fatal("expected error when the prefix doesn't exist, got nil") + } +} + +// --- Registry ------------------------------------------------------------ + +func TestVitePlus_RegisteredInRegistry(t *testing.T) { + if got := NewVitePlus().Name(); got != "vite-plus" { + t.Errorf("Name() = %q, want %q", got, "vite-plus") + } + if !IsAllowedManagerName("vite-plus") { + t.Error("IsAllowedManagerName(\"vite-plus\") = false, want true") + } + found := false + for _, m := range All() { + if m.Name() == "vite-plus" { + found = true + break + } + } + if !found { + t.Error("All() does not include the vite-plus manager") + } +} + +// --- helpers ------------------------------------------------------------- + +// homeDirForTest returns the effective home directory for the current +// platform, mirroring the fallback vitePlusDataDirs uses. +func homeDirForTest(t *testing.T) string { + t.Helper() + h, err := userHomeDir() + if err != nil || h == "" { + t.Skipf("no home directory available: %v", err) + } + return h +} + +// containsString reports whether haystack contains needle. +func containsString(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} From bc93511ed615ecb364927855bd9ee526d5a0456e Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Sun, 1 Nov 2026 19:11:15 +0600 Subject: [PATCH 2/2] fix(detector): check the .exe suffix when locating the vp binary on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-dir branch of VitePlus.Det() stat'd /bin/vp directly, but on Windows the executable is vp.exe — so a Vite+ install whose binary is not on PATH went undetected. LookPath applies PATHEXT for PATH lookups, but a direct filesystem stat has to apply the suffix itself. Co-Authored-By: puku-ai-2.8 --- internal/detector/viteplus.go | 20 ++++++++++++++++++-- internal/detector/viteplus_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/internal/detector/viteplus.go b/internal/detector/viteplus.go index 19c0314..b349cb2 100644 --- a/internal/detector/viteplus.go +++ b/internal/detector/viteplus.go @@ -225,8 +225,24 @@ func (v *VitePlus) Detect() bool { if dir == "" { continue } - bin := filepath.Join(dir, "bin", "vp") - if _, err := os.Stat(bin); err == nil { + if vitePlusBinaryExists(filepath.Join(dir, "bin")) { + return true + } + } + return false +} + +// vitePlusBinaryExists reports whether the `vp` binary lives in the +// given bin directory. +// +// On Windows the executable carries a ".exe" suffix, so we check both +// spellings. (exec.LookPath applies PATHEXT for PATH lookups, but a +// direct filesystem check has to do it itself.) Checking both on every +// platform costs one extra stat and keeps the behavior identical +// everywhere. +func vitePlusBinaryExists(binDir string) bool { + for _, name := range []string{"vp", "vp.exe"} { + if _, err := os.Stat(filepath.Join(binDir, name)); err == nil { return true } } diff --git a/internal/detector/viteplus_test.go b/internal/detector/viteplus_test.go index adc683c..00ae22b 100644 --- a/internal/detector/viteplus_test.go +++ b/internal/detector/viteplus_test.go @@ -483,6 +483,33 @@ func TestVitePlus_Detect_FindsBinaryInDataDir(t *testing.T) { } } +func TestVitePlusBinaryExists(t *testing.T) { + // The data-dir lookup is a plain filesystem stat, so it has to + // apply the Windows executable suffix itself (exec.LookPath does + // that via PATHEXT, but a direct stat does not). + binDir := t.TempDir() + + if vitePlusBinaryExists(binDir) { + t.Error("vitePlusBinaryExists = true for an empty dir, want false") + } + + // A bare "vp" is found on unix. On Windows the real binary is + // "vp.exe" — check both spellings so the helper's behavior is + // pinned identically on every OS. + for _, name := range []string{"vp", "vp.exe"} { + path := filepath.Join(binDir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + if !vitePlusBinaryExists(binDir) { + t.Errorf("vitePlusBinaryExists = false with %s present, want true", name) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + } +} + // --- vitePlusDataDirs ---------------------------------------------------- // clearVitePlusEnv blanks every env var vitePlusDataDirs reads so a