diff --git a/internal/detector/nvm_windows.go b/internal/detector/nvm_windows.go new file mode 100644 index 0000000..06fb9e8 --- /dev/null +++ b/internal/detector/nvm_windows.go @@ -0,0 +1,317 @@ +//go:build windows + +package detector + +import ( + "context" + "errors" + "fmt" + "os" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// NVMWindows is the Windows-only nvm-windows implementation +// (https://github.com/coreybutler/nvm-windows). Unlike unix nvm, +// nvm-windows is a real Go binary (nvm.exe) — not a shell function — +// so we can invoke it like any other command. No source-this-script +// tricks required. +// +// nvm-windows stores installed versions under $NVM_HOME (e.g., +// C:\Users\\AppData\Roaming\nvm) and exposes the active version +// through $NVM_SYMLINK (e.g., C:\nvm4w\nodejs → versioned dir). Both +// env vars are set by the upstream installer and survive across +// shells, which is the same model asdf uses for $ASDF_DIR. +// +// Detection accepts any of: +// - the nvm.exe binary is on PATH +// - $NVM_HOME env var is set (the upstream install root) +// - $NVM_SYMLINK env var is set (the active symlink dir) +// +// Phase 1 implements the detection surface only: +// - Detect : PATH lookup OR $NVM_HOME env OR $NVM_SYMLINK env +// - Version : `nvm version`, parsed (bare semver like "1.1.12" +// — the literal NvmVersion build-time constant is printed via +// fmt.Println in the dispatcher) +// - ListInstalled: `nvm list`, parsed for installed Node versions +// (lines look like " * X.Y.Z (Currently using -bit +// executable)" for the current version, " X.Y.Z" for others, +// and "No installations recognized." when the install is empty) +// +// Mutation methods (Install, Uninstall, Use, SetDefault, +// GlobalNpmPrefix) return an explicit "not implemented" error so +// callers can detect them at runtime instead of getting a silent +// zero-value result. They will be filled in when the upgrade command +// (Phase 4) needs to mutate state. +type NVMWindows struct{} + +// NewNVMWindows constructs a fresh nvm-windows detector. +func NewNVMWindows() *NVMWindows { return &NVMWindows{} } + +func (n *NVMWindows) Name() string { return "nvm-windows" } + +// runShell (declared in fnm.go) is the package-level seam used by +// NVMWindows to invoke the `nvm` binary. nvm-windows is a real binary +// (unlike unix NVM, which is a shell function). Tests overwrite +// runShell to capture arguments and return canned output without +// spawning a subprocess. Production code never reassigns it. + +// ErrNVMWindowsNotImplemented is returned by NVMWindows mutation +// methods that have not yet been implemented in Phase 1 (Install, +// Uninstall, Use, SetDefault, GlobalNpmPrefix). Returning this error +// instead of a zero value lets callers distinguish "I haven't done +// it yet" from "user passed a bad version" via errors.Is. +var ErrNVMWindowsNotImplemented = errors.New("nvm-windows mutation commands not yet implemented") + +// nvmWindowsRoot returns the nvm-windows data root — where installed +// versions and settings live. Resolution order: +// 1. $NVM_HOME environment variable (the official override set by +// the upstream installer) +// 2. "" — we deliberately do NOT fall back to a hard-coded +// %APPDATA%\nvm path because that varies between Windows +// releases and nodeup should not pretend to know where a given +// user's installer dropped the files. +// +// Returns "" if neither can be resolved. Callers must treat "" as +// "nvm-windows not installed" — only the env var counts, since the +// binary might be on PATH while pointing at a moved install (rare +// but observed). +func nvmWindowsRoot() string { + if r := strings.TrimSpace(os.Getenv("NVM_HOME")); r != "" { + return r + } + return "" +} + +// nvmWindowsSymlink returns the active-version symlink directory +// (typically C:\nvm4w\nodejs). Set by the upstream installer; users +// can override via the NVM_SYMLINK environment variable. Returns "" +// when unset — callers must treat that as "nvm-windows not +// installed". +func nvmWindowsSymlink() string { + return strings.TrimSpace(os.Getenv("NVM_SYMLINK")) +} + +// Detect returns true when nvm-windows appears to be installed. We +// accept any of: +// 1. the nvm.exe binary is on PATH (via platform.LookupManagerBinary +// — it adds the .exe suffix automatically on Windows), OR +// 2. $NVM_HOME env var is set (user has explicitly pointed us at +// an install root), OR +// 3. $NVM_SYMLINK env var is set (the upstream installer sets both +// together, but either one alone is a strong signal) +// +// Per the Manager contract, Detect MUST be cheap — none of these +// branches spawn a subprocess. +func (n *NVMWindows) Detect() bool { + if platform.LookupManagerBinary("nvm") != "" { + return true + } + if nvmWindowsRoot() != "" { + return true + } + if nvmWindowsSymlink() != "" { + return true + } + return false +} + +// Version returns nvm-windows' own version string, e.g. "1.1.12". +// +// Per the upstream dispatcher (src/nvm.go), the case for "version" +// (and its aliases "v", "-v", "--version", "-version", "--v") runs +// +// fmt.Println(NvmVersion) +// +// where NvmVersion is a Go string variable set at build time via +// ldflags (default: "1.1.12"). The output is a bare semver token +// followed by a single newline — no "v" prefix, no branding. +// +// We take the first whitespace-separated token from stdout and +// strip an optional "v" prefix defensively in case a fork re-adds +// one. We do NOT validate semver here — the upstream constant is a +// plain version string today, but a future fork could emit a +// git-describe-style "1.1.12-4-gabc1234" which still parses as +// semver. Returning the raw token matches the other manager +// detectors (asdf, nodenv, mise, n). +func (n *NVMWindows) Version() (string, error) { + res, err := runShell(context.Background(), "nvm", "version") + if err != nil { + return "", fmt.Errorf("nvm version: %w", err) + } + return parseNVMWindowsVersion(res.Stdout) +} + +// parseNVMWindowsVersion extracts the version token from `nvm +// version` output. Exposed (lowercase) for direct unit testing. +// +// Real observed output (nvm-windows 1.1.12): +// +// $ nvm version +// 1.1.12 +// +// (Trailing newline from fmt.Println.) +// +// Some forks prepend "v" (e.g., "v1.1.12") — we strip it defensively. +// +// Empty stdout → error. This can happen if the binary is on PATH +// but cannot read its own embedded metadata (very rare; usually a +// corrupted install). Either way, an empty version string is not +// actionable for callers, so we surface it as an error rather than +// silently returning "". +func parseNVMWindowsVersion(stdout string) (string, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return "", errors.New("nvm version returned empty output") + } + fields := strings.Fields(out) + if len(fields) == 0 { + return "", errors.New("nvm version returned no tokens") + } + // Take the first token and strip an optional "v" prefix. + return strings.TrimPrefix(fields[0], "v"), nil +} + +// ListInstalled returns every Node.js version nvm-windows has +// installed, sorted ascending. Source: `nvm list` (alias `nvm ls`). +// +// Per the upstream source (src/nvm.go list() function), the installed +// branch emits a leading blank line, then for each installed version: +// +// \n +// * X.Y.Z (Currently using 64-bit executable) <- the active one +// X.Y.Z <- the rest +// +// or, when nothing is installed: +// +// No installations recognized. +// +// The "v" prefix that nvm-windows stores internally is stripped via +// regex before printing (`regexp.MustCompile("v").ReplaceAllString` +// replaces every "v" with "" — which can in pathological cases +// strip a "v" out of the middle of a version, but real-world Node +// semver never contains a "v" between digits, so the substitution is +// safe in practice). We re-strip a leading "v" defensively in case a +// fork skips that step. +// +// The current-version line carries a trailing " (Currently using +// -bit executable)" suffix where is "32" or "64" +// (upstream hard-codes those two values in the format string; arm64 +// is supported but the printed marker stays 64 on 64-bit Windows). +// We strip everything after the first whitespace after the digits +// before handing the token to semver. +// +// There is no "system" sentinel line (unlike nodenv) — nvm-windows +// is exclusively a managed install. +func (n *NVMWindows) ListInstalled() ([]semver.Version, error) { + res, err := runShell(context.Background(), "nvm", "list") + if err != nil { + return nil, fmt.Errorf("nvm list: %w", err) + } + return parseNVMWindowsInstalled(res.Stdout) +} + +// parseNVMWindowsInstalled turns raw `nvm list` output into a +// sorted-ascending []semver.Version. Exposed (lowercase) for direct +// unit testing. +// +// Lines look like: +// +// - 18.20.4 (Currently using 64-bit executable) +// 20.11.1 +// 22.5.0 +// +// or, when empty: +// +// No installations recognized. +// +// The leading blank line that upstream emits is just whitespace and +// gets trimmed to empty. +// +// We: +// 1. skip blank lines and the "No installations recognized." +// sentinel +// 2. find the first digit and take everything from there to the +// next whitespace (drops the " (Currently using -bit +// executable)" suffix on the current-version line) +// 3. hand the remainder to semver.NewVersion +// 4. skip lines that don't parse (forward-compat for future +// metadata formats) +// +// Returns a non-nil empty slice when no parseable versions are +// present — callers rely on this for "nvm-windows installed, +// nothing managed yet". +func parseNVMWindowsInstalled(stdout string) ([]semver.Version, error) { + versions := make([]semver.Version, 0) + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + // Filter the "No installations recognized." sentinel. + // We compare the trimmed prefix because upstream prints + // it on its own line; we don't need exact-match. + if strings.HasPrefix(line, "No installations recognized") { + continue + } + // Find the first digit. Everything before it is the + // prefix decoration ("*", " *", " ", etc.). + idx := strings.IndexFunc(line, func(r rune) bool { + return r >= '0' && r <= '9' + }) + if idx < 0 { + // No digits at all — not a version line. + continue + } + verStr := line[idx:] + // The current-version line ends with " (Currently using + // -bit executable)". We don't want that in the + // semver token, so stop at the first whitespace after + // the digits. + if sp := strings.IndexFunc(verStr, func(r rune) bool { + return r == ' ' || r == '\t' + }); sp >= 0 { + verStr = verStr[:sp] + } + // Defensive: strip a leading "v" in case a fork skipped + // the regex substitution. Upstream's "1.1.12" build of + // nvm-windows strips the "v" itself, so this is normally + // a no-op. + verStr = strings.TrimPrefix(verStr, "v") + v, err := semver.NewVersion(verStr) + if err != nil { + // Skip unparseable lines rather than aborting the + // whole list. Forward-compatibility for new + // metadata formats nvm-windows might add. + continue + } + versions = append(versions, *v) + } + // semver.Collection in v3.5.0 is []*Version (pointers), so a + // value slice doesn't satisfy it. 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 stubs ----------------------------------------------------- +// +// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return +// ErrNVMWindowsNotImplemented. They will be filled in when the +// upgrade command (Phase 4) needs to mutate state. Returning an +// explicit sentinel error (rather than nil) makes "not implemented" +// provably distinguishable from "succeeded". + +func (n *NVMWindows) Install(ver semver.Version) error { return ErrNVMWindowsNotImplemented } +func (n *NVMWindows) Uninstall(ver semver.Version) error { return ErrNVMWindowsNotImplemented } +func (n *NVMWindows) Use(ver semver.Version) error { return ErrNVMWindowsNotImplemented } +func (n *NVMWindows) SetDefault(ver semver.Version) error { return ErrNVMWindowsNotImplemented } +func (n *NVMWindows) GlobalNpmPrefix(ver semver.Version) (string, error) { + return "", ErrNVMWindowsNotImplemented +} diff --git a/internal/detector/nvm_windows_test.go b/internal/detector/nvm_windows_test.go new file mode 100644 index 0000000..41b2086 --- /dev/null +++ b/internal/detector/nvm_windows_test.go @@ -0,0 +1,702 @@ +//go:build windows + +package detector + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// --- parseNVMWindowsVersion --------------------------------------------- + +func TestParseNVMWindowsVersion_StandardOutput(t *testing.T) { + // Observed on nvm-windows 1.1.12: + // $ nvm version + // 1.1.12 + // + // The upstream dispatcher calls fmt.Println(NvmVersion), which + // prints the bare semver followed by a single newline. There is + // no "nvm " branding prefix (unlike fnm/nodenv/etc.) and no "v" + // prefix. + got, err := parseNVMWindowsVersion("1.1.12\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.1.12" { + t.Errorf("got %q, want %q", got, "1.1.12") + } +} + +func TestParseNVMWindowsVersion_VPrefixed(t *testing.T) { + // Defensive: a fork that re-adds the "v" prefix should still + // yield the bare semver. + got, err := parseNVMWindowsVersion("v1.1.12\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.1.12" { + t.Errorf("got %q, want %q", got, "1.1.12") + } +} + +func TestParseNVMWindowsVersion_GitDescribeSuffix(t *testing.T) { + // A git-checkout build of nvm-windows could emit a + // git-describe-style version (e.g., "1.1.12-4-gabc1234"). We + // don't validate semver here — the caller decides. The parser + // just needs to return the first token. + got, err := parseNVMWindowsVersion("1.1.12-4-gabc1234\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.1.12-4-gabc1234" { + t.Errorf("got %q, want %q", got, "1.1.12-4-gabc1234") + } +} + +func TestParseNVMWindowsVersion_TrailingWhitespace(t *testing.T) { + // Defensive: handle trailing whitespace and a CR (in case the + // upstream switches to fmt.Print with "\r\n" later). + got, err := parseNVMWindowsVersion(" 1.1.12 \r\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.1.12" { + t.Errorf("got %q, want %q", got, "1.1.12") + } +} + +func TestParseNVMWindowsVersion_Empty(t *testing.T) { + _, err := parseNVMWindowsVersion("") + if err == nil { + t.Error("expected error for empty input") + } +} + +func TestParseNVMWindowsVersion_WhitespaceOnly(t *testing.T) { + _, err := parseNVMWindowsVersion("\n \n") + if err == nil { + t.Error("expected error for whitespace-only input") + } +} + +func TestParseNVMWindowsVersion_MultiToken(t *testing.T) { + // Defensive: a fork that prints extra metadata after the + // version (e.g., "1.1.12 (build 1234)") should still yield the + // first token. Upstream never does this — NvmVersion is set via + // ldflags to a single bare semver — but we accept the shape so + // we don't break on a future fork. + got, err := parseNVMWindowsVersion("1.1.12 (build 1234)\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.1.12" { + t.Errorf("got %q, want %q", got, "1.1.12") + } +} + +// --- parseNVMWindowsInstalled ------------------------------------------- + +func TestParseNVMWindowsInstalled_RealOutput(t *testing.T) { + // Real observed output of `nvm list` (nvm-windows 1.1.12): + // $ nvm list + // + // * 18.20.4 (Currently using 64-bit executable) + // 20.11.1 + // 22.5.0 + // + // Note: `nvm list` (NOT `nvm ls`) emits the marker+version. The + // marker is " * " (two spaces + star) for the current version + // and " " (four spaces, no star) for the others. We strip + // the marker and keep just the version token. The + // " (Currently using -bit executable)" suffix on the + // current-version line is dropped before semver parsing. + stdout := "\n * 18.20.4 (Currently using 64-bit executable)\n 20.11.1\n 22.5.0\n" + got, err := parseNVMWindowsInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseNVMWindowsInstalled_ThirtyTwoBitMarker(t *testing.T) { + // Real observed output on a 32-bit nvm-windows install: + // the current-version suffix uses "32-bit" instead of + // "64-bit". We must accept either. + stdout := "\n * 18.20.4 (Currently using 32-bit executable)\n 20.11.1\n" + got, err := parseNVMWindowsInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseNVMWindowsInstalled_FiltersEmptySentinel(t *testing.T) { + // When no versions are installed, upstream emits a single line + // "No installations recognized." We must filter it out — it + // isn't a version line. + stdout := "No installations recognized.\n" + got, err := parseNVMWindowsInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("got nil slice, want empty non-nil slice") + } + if len(got) != 0 { + t.Errorf("got %d versions, want 0", len(got)) + } +} + +func TestParseNVMWindowsInstalled_EmptySentinelWithLeadingBlank(t *testing.T) { + // The empty-install branch starts with a blank line (the + // upstream code unconditionally prints \n before any version + // iteration). Confirm we still filter correctly when the + // sentinel is preceded by blank lines. + stdout := "\n\nNo installations recognized.\n" + got, err := parseNVMWindowsInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("got %d versions, want 0", len(got)) + } +} + +func TestParseNVMWindowsInstalled_UnsortedInput(t *testing.T) { + // nvm-windows may emit versions in install order. We must + // sort them ascending by semver before returning. + stdout := "\n 22.5.0\n * 18.20.4 (Currently using 64-bit executable)\n 20.11.1\n" + got, err := parseNVMWindowsInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseNVMWindowsInstalled_EmptyStdout(t *testing.T) { + // No installed versions, no sentinel line — only happens if + // the upstream `list` helper printed nothing. We treat empty + // stdout as "no versions, no error". + got, err := parseNVMWindowsInstalled("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("got nil slice, want empty non-nil slice") + } + if len(got) != 0 { + t.Errorf("got %d versions, want 0", len(got)) + } +} + +func TestParseNVMWindowsInstalled_OnlyBlankLines(t *testing.T) { + got, err := parseNVMWindowsInstalled("\n\n\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("got nil slice, want empty non-nil slice") + } + if len(got) != 0 { + t.Errorf("got %d versions, want 0", len(got)) + } +} + +func TestParseNVMWindowsInstalled_DefensiveVPrefix(t *testing.T) { + // A fork that forgets the upstream regex-substitution could + // emit "v18.20.4" — we must strip the leading "v" before + // handing to semver. + stdout := "\n * v18.20.4 (Currently using 64-bit executable)\n v20.11.1\n" + got, err := parseNVMWindowsInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseNVMWindowsInstalled_SkipsUnparseableLines(t *testing.T) { + // Lines that look like version lines but don't parse as + // semver should be skipped silently rather than aborting the + // whole list. Forward-compat for future metadata nvm-windows + // might add. + stdout := " garbage-no-version\n 20.11.1\n some-other-text\n" + got, err := parseNVMWindowsInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0].String() != "20.11.1" { + t.Errorf("got %v, want [20.11.1]", got) + } +} + +func TestParseNVMWindowsInstalled_CRLineEndings(t *testing.T) { + // Defensive: some Windows console captures emit "\r\n". + // strings.Split on "\n" leaves a trailing "\r" on each line + // after TrimSpace runs — TrimSpace strips that, so parsing + // must work identically. + stdout := "\r\n * 18.20.4 (Currently using 64-bit executable)\r\n 20.11.1\r\n" + got, err := parseNVMWindowsInstalled(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +// --- Method tests ------------------------------------------------------ + +func TestNVMWindows_Name(t *testing.T) { + if got := NewNVMWindows().Name(); got != "nvm-windows" { + t.Errorf("Name() = %q, want %q", got, "nvm-windows") + } +} + +func TestNVMWindows_Version_Success(t *testing.T) { + // Capture the exact command and args passed to runShell. + var gotName string + var gotArgs []string + withStubShell(t, + func(name string, a []string) { + gotName = name + gotArgs = a + }, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "1.1.12\n"}, nil + }, + ) + + got, err := NewNVMWindows().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.1.12" { + t.Errorf("Version() = %q, want %q", got, "1.1.12") + } + if gotName != "nvm" { + t.Errorf("runShell name = %q, want %q", gotName, "nvm") + } + if len(gotArgs) != 1 || gotArgs[0] != "version" { + t.Errorf("runShell args = %v, want [version]", gotArgs) + } +} + +func TestNVMWindows_Version_VPrefixed(t *testing.T) { + // Defensive: forks that emit "v1.1.12" should still yield + // "1.1.12". + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "v1.1.12\n"}, nil + }, + ) + got, err := NewNVMWindows().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.1.12" { + t.Errorf("Version() = %q, want %q", got, "1.1.12") + } +} + +func TestNVMWindows_Version_RunShellError(t *testing.T) { + // runShell error must propagate as a wrapped error so callers + // can distinguish "binary missing" from "binary present but + // version unparseable". + wantErr := errSentinelForTest + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return nil, wantErr + }, + ) + + _, err := NewNVMWindows().Version() + if err == nil { + t.Fatal("expected error from runShell failure, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestNVMWindows_Version_EmptyOutput(t *testing.T) { + // runShell succeeds but the output is empty (rare — usually + // a corrupted install). The returned error must NOT wrap + // errSentinelForTest (it's a parser-level error, not a shell + // error). + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: ""}, nil + }, + ) + _, err := NewNVMWindows().Version() + if err == nil { + t.Fatal("expected parsing error, got nil") + } +} + +func TestNVMWindows_ListInstalled_Success(t *testing.T) { + // Capture the exact command and args passed to runShell. + var gotName string + var gotArgs []string + withStubShell(t, + func(name string, a []string) { + gotName = name + gotArgs = a + }, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "\n * 18.20.4 (Currently using 64-bit executable)\n 20.11.1\n 22.5.0\n"}, nil + }, + ) + + got, err := NewNVMWindows().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.20.4", "20.11.1", "22.5.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } + if gotName != "nvm" { + t.Errorf("runShell name = %q, want %q", gotName, "nvm") + } + if len(gotArgs) != 1 || gotArgs[0] != "list" { + t.Errorf("runShell args = %v, want [list]", gotArgs) + } +} + +func TestNVMWindows_ListInstalled_EmptyStdout(t *testing.T) { + // `nvm list` exited 0 with no stdout — only happens if the + // upstream `list` helper printed nothing (very rare). We + // treat empty stdout as "no versions, no error". + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: ""}, nil + }, + ) + got, err := NewNVMWindows().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("got nil slice, want empty non-nil slice") + } + if len(got) != 0 { + t.Errorf("got %d versions, want 0", len(got)) + } +} + +func TestNVMWindows_ListInstalled_RunShellError(t *testing.T) { + // runShell failure (binary missing, permission denied, etc.) + // must propagate. This is the typical failure mode when nvm + // is on PATH but a malformed install blocks the listing. + wantErr := errSentinelForTest + withStubShell(t, nil, + func(req string) (*platform.RunResult, error) { + return nil, wantErr + }, + ) + + _, err := NewNVMWindows().ListInstalled() + if err == nil { + t.Fatal("expected error from runShell failure, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +// --- Detect tests ------------------------------------------------------- + +func TestNVMWindows_Detect_NoBinaryNoEnv(t *testing.T) { + // All three branches false: no binary on PATH, no + // $NVM_HOME, no $NVM_SYMLINK. Detect() must return false + // without spawning runShell. + t.Setenv("NVM_HOME", "") + t.Setenv("NVM_SYMLINK", "") + // Force runShell to fail loudly if Detect() touches it. + 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 NewNVMWindows().Detect() { + t.Error("Detect() = true with no PATH, no $NVM_HOME, no $NVM_SYMLINK; want false") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-PATH/env check") + } +} + +func TestNVMWindows_Detect_FindsBinaryOnPath(t *testing.T) { + // nvm.exe on PATH. Detect() must return true. The file is + // build-tagged `//go:build windows`, so the binary name + // always carries the .exe suffix — no `runtime.GOOS` + // conditional needed. + binDir := t.TempDir() + bin := filepath.Join(binDir, "nvm.exe") + if err := os.WriteFile(bin, []byte("@echo off\r\n"), 0o755); err != nil { + t.Fatal(err) + } + // Set PATH to ONLY our bin dir. exec.LookPath walks PATH + // itself, so a single-entry PATH is sufficient and removes + // any chance of a stray nvm binary elsewhere on the runner + // shadowing our stub. + t.Setenv("PATH", binDir) + t.Setenv("NVM_HOME", "") + t.Setenv("NVM_SYMLINK", "") + + // Detect() must not invoke runShell — the on-disk check is + // sufficient. Force a failure if it does. + 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 !NewNVMWindows().Detect() { + t.Error("Detect() = false with nvm.exe on PATH, want true") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-PATH check") + } +} + +func TestNVMWindows_Detect_HonorsNVMHOME(t *testing.T) { + // $NVM_HOME set should make Detect() return true even when + // no nvm.exe is on PATH and $NVM_SYMLINK is unset. This is + // the override path for users with a custom install root + // whose installer didn't put nvm.exe on PATH (rare but + // observed when the installer is invoked manually). + t.Setenv("NVM_HOME", `C:\custom\nvm`) + t.Setenv("NVM_SYMLINK", "") + t.Setenv("PATH", t.TempDir()) // empty PATH + + // Force runShell to fail loudly if Detect() touches it. + 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 !NewNVMWindows().Detect() { + t.Error("Detect() = false with $NVM_HOME set, want true") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-env check") + } +} + +func TestNVMWindows_Detect_HonorsNVMSYMLINK(t *testing.T) { + // $NVM_SYMLINK set (without $NVM_HOME and without PATH) is + // a strong install signal — the upstream installer sets + // both, but either alone is enough for us. + t.Setenv("NVM_HOME", "") + t.Setenv("NVM_SYMLINK", `C:\nvm4w\nodejs`) + t.Setenv("PATH", t.TempDir()) // empty PATH + + // Force runShell to fail loudly if Detect() touches it. + 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 !NewNVMWindows().Detect() { + t.Error("Detect() = false with $NVM_SYMLINK set, want true") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-env check") + } +} + +func TestNVMWindows_Detect_EmptyNVMHOME_FallsThrough(t *testing.T) { + // $NVM_HOME explicitly empty string must be treated as + // "not set" — fall through to the symlink check, then PATH. + t.Setenv("NVM_HOME", "") + t.Setenv("NVM_SYMLINK", "") + t.Setenv("PATH", t.TempDir()) // empty PATH + + // Force runShell to fail loudly if Detect() touches it. + 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 NewNVMWindows().Detect() { + t.Error("Detect() = true with all three branches empty, want false") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-PATH/env check") + } +} + +func TestNVMWindows_Detect_WhitespaceNVMHOME_FallsThrough(t *testing.T) { + // $NVM_HOME set to whitespace-only should be treated as + // "not set" — defensive against copy-paste accidents. + t.Setenv("NVM_HOME", " ") + t.Setenv("NVM_SYMLINK", "") + t.Setenv("PATH", t.TempDir()) // empty PATH + + // Force runShell to fail loudly if Detect() touches it. + 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 NewNVMWindows().Detect() { + t.Error("Detect() = true with whitespace-only $NVM_HOME, want false") + } + if called { + t.Error("Detect() invoked runShell — must be a pure-PATH/env check") + } +} + +// --- Mutation stubs ----------------------------------------------------- + +func TestNVMWindows_MutationMethods_NotImplemented(t *testing.T) { + // Phase 1: Install / Uninstall / Use / SetDefault / + // GlobalNpmPrefix return ErrNVMWindowsNotImplemented. This + // sentinel lets callers distinguish "not implemented" from + // other errors via errors.Is. + n := NewNVMWindows() + ver, _ := semver.NewVersion("20.0.0") + + if err := n.Install(*ver); !errors.Is(err, ErrNVMWindowsNotImplemented) { + t.Errorf("Install() = %v, want ErrNVMWindowsNotImplemented", err) + } + if err := n.Uninstall(*ver); !errors.Is(err, ErrNVMWindowsNotImplemented) { + t.Errorf("Uninstall() = %v, want ErrNVMWindowsNotImplemented", err) + } + if err := n.Use(*ver); !errors.Is(err, ErrNVMWindowsNotImplemented) { + t.Errorf("Use() = %v, want ErrNVMWindowsNotImplemented", err) + } + if err := n.SetDefault(*ver); !errors.Is(err, ErrNVMWindowsNotImplemented) { + t.Errorf("SetDefault() = %v, want ErrNVMWindowsNotImplemented", err) + } + if p, err := n.GlobalNpmPrefix(*ver); !errors.Is(err, ErrNVMWindowsNotImplemented) { + t.Errorf("GlobalNpmPrefix() err = %v, want ErrNVMWindowsNotImplemented", err) + } else if p != "" { + t.Errorf("GlobalNpmPrefix() prefix = %q, want \"\"", p) + } +} + +// --- nvmWindowsRoot / nvmWindowsSymlink helpers ------------------------- + +func TestNVMWindows_NvmWindowsRootHelper_PrefersEnv(t *testing.T) { + // $NVM_HOME takes precedence — there's no home-dir fallback + // because the default location varies between Windows + // releases. + t.Setenv("NVM_HOME", `C:\custom\nvm`) + got := nvmWindowsRoot() + if got != `C:\custom\nvm` { + t.Errorf("nvmWindowsRoot() = %q, want %q", got, `C:\custom\nvm`) + } +} + +func TestNVMWindows_NvmWindowsRootHelper_EmptyWhenUnset(t *testing.T) { + // $NVM_HOME unset → "" (no home-dir fallback). + t.Setenv("NVM_HOME", "") + got := nvmWindowsRoot() + if got != "" { + t.Errorf("nvmWindowsRoot() = %q, want empty string", got) + } +} + +func TestNVMWindows_NvmWindowsRootHelper_TrimsWhitespace(t *testing.T) { + // $NVM_HOME set to whitespace-only should be treated as + // "not set" — fall through to "". + t.Setenv("NVM_HOME", " ") + got := nvmWindowsRoot() + if got != "" { + t.Errorf("nvmWindowsRoot() = %q, want empty string (whitespace env should not win)", got) + } +} + +func TestNVMWindows_NvmWindowsSymlinkHelper_ReturnsEnv(t *testing.T) { + t.Setenv("NVM_SYMLINK", `C:\nvm4w\nodejs`) + got := nvmWindowsSymlink() + if got != `C:\nvm4w\nodejs` { + t.Errorf("nvmWindowsSymlink() = %q, want %q", got, `C:\nvm4w\nodejs`) + } +} + +func TestNVMWindows_NvmWindowsSymlinkHelper_EmptyWhenUnset(t *testing.T) { + t.Setenv("NVM_SYMLINK", "") + got := nvmWindowsSymlink() + if got != "" { + t.Errorf("nvmWindowsSymlink() = %q, want empty string", got) + } +} + +func TestNVMWindows_NvmWindowsSymlinkHelper_TrimsWhitespace(t *testing.T) { + t.Setenv("NVM_SYMLINK", " ") + got := nvmWindowsSymlink() + if got != "" { + t.Errorf("nvmWindowsSymlink() = %q, want empty string (whitespace env should not win)", got) + } +} diff --git a/internal/detector/registry_windows.go b/internal/detector/registry_windows.go index c2d17db..98b3db9 100644 --- a/internal/detector/registry_windows.go +++ b/internal/detector/registry_windows.go @@ -2,32 +2,6 @@ package detector -import "github.com/Masterminds/semver/v3" - -// NVMWindows is the Windows-only nvm-windows implementation -// (https://github.com/coreybutler/nvm-windows). Unlike nvm on unix, -// nvm-windows ships a real binary (nvm.exe) so we can call it like any -// other command — no shell-sourcing tricks. -// -// Implementation status: stub. Real implementation lands in Phase 5. -type NVMWindows struct{} - -// NewNVMWindows constructs a fresh nvm-windows detector. -func NewNVMWindows() *NVMWindows { return &NVMWindows{} } - -func (n *NVMWindows) Name() string { return "nvm-windows" } - -func (n *NVMWindows) Detect() bool { return false } -func (n *NVMWindows) Version() (string, error) { return "", nil } -func (n *NVMWindows) ListInstalled() ([]semver.Version, error) { return nil, nil } -func (n *NVMWindows) Install(v semver.Version) error { return nil } -func (n *NVMWindows) Uninstall(v semver.Version) error { return nil } -func (n *NVMWindows) Use(v semver.Version) error { return nil } -func (n *NVMWindows) SetDefault(v semver.Version) error { return nil } -func (n *NVMWindows) GlobalNpmPrefix(v semver.Version) (string, error) { - return "", nil -} - // All returns every manager implementation nodeup knows about on Windows. // Identical to the unix All() but appends NewNVMWindows at the end so // ResolveManager prefers fnm/nvm first.