diff --git a/CHANGELOG.md b/CHANGELOG.md index 9827a79..550b619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Post-upgrade cleanup prompt (`nodeup upgrade`): after a successful + upgrade, nodeup asks whether to delete the old Node.js versions + left behind. The prompt offers three options โ€” `y` deletes every + candidate, typing a specific version (e.g. `20.18.0`) deletes only + that one, and `N` (or empty enter) skips. Candidates are computed + as `installed \ {new LTS, new Current, currently active}`, where + the active version is detected via ` current` (or per- + manager equivalent). The new `Manager.Current()` interface method + backs the exclusion โ€” nvm-windows returns the + `ErrNVMWindowsNotImplemented` sentinel and the exclusion is + skipped. New flags: `--cleanup` (auto-confirm), `--cleanup-version + ` (repeatable, picks specific versions), plus the existing + `--no-cleanup` to skip the prompt entirely. Config equivalents + (`cleanup.auto`, `cleanup.prompt`) ship under the same names; see + `docs/managers.md#post-upgrade-cleanup` for the full precedence + table. Closes #41. +- Native mutation commands for all 7 working managers (fnm, nvm, + Volta, asdf, mise, n, nodenv): `Install`, `Uninstall`, `Use`, + `SetDefault`, and `GlobalNpmPrefix` are now real shell-outs rather + than stubs returning `ErrXxxNotImplemented`. Volta's `SetDefault` + and n's `SetDefault` are intentional no-ops (those managers pin + per-project, not per-machine). `GlobalNpmPrefix` returns the + per-version npm global modules directory for each manager's on- + disk layout, which the migration step needs to enumerate packages. + nvm-windows remains unsupported (no install/uninstall CLI on that + platform); its stubs still return + `ErrNVMWindowsNotImplemented` so callers can detect and skip. + Per-manager cleanup behavior is documented in + `docs/managers.md#post-upgrade-cleanup`. Closes #40. + +### Added (continued) - YAML config file support (`internal/config`): the documented schema from `docs/configuration.md` is now first-class. Settings live in `~/.nodeup/config.yaml` (override with `$NODEUP_CONFIG` or redirect diff --git a/README.md b/README.md index 6cac481..0d2718c 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,15 @@ nodeup upgrade --dry-run nodeup upgrade --yes ``` +After a successful upgrade, `nodeup upgrade` prompts you about removing old +Node.js versions โ€” answering **y** deletes all of them, typing a specific +version (e.g. `20.18.0`) deletes only that one, and **N** (or empty enter) +keeps everything. The candidates exclude the new LTS / Current we just +installed plus the version that's currently active on your shell. Skip +the prompt entirely with `--no-cleanup` or pre-decide with `--cleanup`. +See [docs/managers.md](docs/managers.md#post-upgrade-cleanup) for the +full set of cleanup flags and per-manager behavior. + If you have multiple managers installed, `nodeup` will prompt you to pick one the first time and remember it in `~/.nodeup/config.yaml`. @@ -234,7 +243,7 @@ This is the **v1.0.0 development line**. See `CHANGELOG.md` for what's done. | Version | Status | Notes | |---|---|---| -| v1.0.0 | ๐Ÿ›  in development | Phase 1 โœ… โ€” 8/8 managers detected. Phase 2 โœ… โ€” `nodeup check` with nodejs.org/dist/index.json fetch + TTL cache. Phase 3 โœ… โ€” package snapshot/restore + migration report. Phase 4 โœ… โ€” end-to-end `nodeup upgrade`. Phase 5 โœ… โ€” YAML config file + `config` subcommands (show / get / set / init). Phase 6 โœ… โ€” cross-platform polish: `QuotePath` for paths with spaces, interrupted-upgrade sentinel + replay, system-node classifier with warnings. Phase 7 (GoReleaser config + brew/scoop taps + npm wrapper) is the remaining work โ€” see issues #17 and #18. | +| v1.0.0 | ๐Ÿ›  in development | Phase 1 โœ… โ€” 8/8 managers detected. Phase 2 โœ… โ€” `nodeup check` with nodejs.org/dist/index.json fetch + TTL cache. Phase 3 โœ… โ€” package snapshot/restore + migration report. Phase 4 โœ… โ€” end-to-end `nodeup upgrade`. Phase 5 โœ… โ€” YAML config file + `config` subcommands (show / get / set / init). Phase 6 โœ… โ€” cross-platform polish: `QuotePath` for paths with spaces, interrupted-upgrade sentinel + replay, system-node classifier with warnings. Phase 7 โœ… โ€” post-upgrade cleanup prompt (`--cleanup` / `--cleanup-version` / `--no-cleanup` / `cleanup.auto` / `cleanup.prompt`) plus native mutation commands for all 7 supported managers. Distribution (GoReleaser + brew/scoop taps + npm wrapper) is also Phase 7 โ€” see issues #17 and #18. | Phase 1 is the **detection surface** โ€” every manager is recognized and the version + installed-list reads return real data (PRs #1โ€“#8). Subsequent diff --git a/docs/managers.md b/docs/managers.md index 205a4c1..d17f26a 100644 --- a/docs/managers.md +++ b/docs/managers.md @@ -77,4 +77,87 @@ bin dir (e.g., `fnm env --use-on-cd | source`, or add points at a binary inside the manager's data dir, nodeup's next run will classify it as `manager` and proceed without warning. -> _Status: Phase 1 โœ… done โ€” all 8 managers detected ยท last updated 2026-06-29_ \ No newline at end of file +> _Status: Phase 7 in progress โ€” post-upgrade cleanup + native mutation commands per manager._ + +## Post-upgrade cleanup + +When `nodeup upgrade` finishes installing the new LTS and/or Current +versions, it asks whether you want to delete the old ones. The +prompt is enabled by default (so the upgrade doesn't lose data +silently), but the behavior is configurable. + +### Candidates + +A version is a **cleanup candidate** if all three are true: + +1. The manager has it installed (` list` / equivalent). +2. It's NOT one of the versions we just installed (new LTS, new + Current). +3. It's NOT the version that's currently active on your shell. We + detect this via ` current` (or ` version`, + ` list --format=plain`, etc. โ€” see per-manager table + below). If the manager doesn't expose a "current version" query, + the exclusion is skipped (better to over-prompt than to leave a + broken shell). + +For example, if you had 18.20.4, 20.18.0, and 22.11.0 installed and +upgraded to 22.11.0 (LTS) + 24.15.0 (Current), with 20.18.0 active, +the candidates are: just **18.20.4**. The new versions and the +active version are off-limits. + +### Flags + +| Flag | Effect | +|--------------------------|-----------------------------------------------------------------| +| (no flag) | Prompt: `y` deletes all / typed version deletes one / `N` skips | +| `--cleanup` | Skip the prompt; auto-confirm deletion of every candidate | +| `--cleanup-version ` | Skip the prompt; only delete the specified versions (repeatable; pairs with `--cleanup`) | +| `--no-cleanup` | Skip the prompt AND don't delete anything | +| `--yes` | Implies `--cleanup` for non-interactive runs (e.g., CI) | + +Config equivalents (`~/.nodeup/config.yaml`): + +```yaml +cleanup: + auto: false # set true to skip the all-or-nothing prompt + prompt: true # set false to skip per-version confirm +``` + +Precedence (highest first): + +1. `--no-cleanup` โ€” never prompt, never delete +2. `--cleanup` (or `cleanup.auto: true` in config) โ€” auto-confirm +3. `--cleanup-version ` โ€” restrict to specific versions +4. `cleanup.prompt: false` โ€” skip per-version confirm +5. Default โ€” interactive prompt + +### Per-manager behavior + +| Manager | Install cmd | Uninstall cmd | Current query | Notes | +|----------------|------------------------------|--------------------------|--------------------------------------|-------| +| **fnm** | `fnm install ` | `fnm uninstall ` | `fnm current` | Refuses to uninstall the active version. The prompt excludes it. | +| **nvm** | `source nvm.sh && nvm install -s ` | `โ€ฆ nvm uninstall ` | `โ€ฆ nvm current` (`system` / `none` โ†’ "unknown") | Uses `-s` to suppress prompts. | +| **Volta** | `volta install node@` | `volta uninstall node@` | First `node@` row of `volta list --format=plain` | `SetDefault` is a no-op (Volta pins per-project, not per-machine). | +| **asdf** | `asdf install nodejs ` | `asdf uninstall nodejs ` | `asdf current nodejs` | Plugin name is `nodejs` (not `node`). | +| **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`. | +| **nvm-windows**| **unsupported** | **unsupported** | returns `ErrNVMWindowsNotImplemented` | `nvm-windows` doesn't expose a CLI for install/uninstall โ€” the upgrade command leaves the install list alone and prints a clear note. | + +For nvm-windows, the upgrade proceeds normally but the cleanup +prompt is suppressed with a note: "nvm-windows cleanup is not yet +implemented โ€” leave the old versions in place or remove them +manually via `nvm uninstall ` from an elevated shell." + +### Failure modes + +- **Manager not on PATH** โ€” the cleanup step is skipped entirely; + the upgrade itself still completes (Install/SetDefault will + have already failed loudly if the manager wasn't there). +- **Uninstall fails** (permission denied, currently-active + version, locked file, ...) โ€” we record the failure and continue + with the next candidate. The summary at the end lists both + successes and failures, so you can clean up the leftovers + manually. +- **Ctrl-C mid-prompt** โ€” we exit cleanly; the upgrade is already + done, the prompt is the only thing that gets interrupted. diff --git a/internal/cli/cleanup.go b/internal/cli/cleanup.go new file mode 100644 index 0000000..ac6313f --- /dev/null +++ b/internal/cli/cleanup.go @@ -0,0 +1,414 @@ +package cli + +import ( + "bufio" + "fmt" + "io" + "sort" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/detector" +) + +// cleanupIO holds the input and output streams for a cleanup prompt +// run. Tests inject pipes here so they can drive stdin / capture +// stdout without touching the real terminal. +type cleanupIO struct { + in *bufio.Reader + out io.Writer +} + +// cleanupDecision captures the user's answers to the post-upgrade +// prompt, so the caller (upgrade.go) can either confirm or skip. +type cleanupDecision struct { + // deleteAll means the user answered "y" to the all-or-nothing + // prompt โ€” every candidate will be deleted (one-by-one confirm + // still happens so accidental "y" doesn't nuke everything). + deleteAll bool + // deleteOne is set when the user picks a specific version to + // delete by name. Empty string means "no specific version + // selected". + deleteOne string + // skip means the user picked "N" / empty at the all-or-nothing + // prompt โ€” nothing more to ask, the whole cleanup is over. + skip bool +} + +// cleanupConfig holds the behavior toggles for the prompt. The CLI +// upgrade flow reads these from cfg.Cleanup + the on-the-fly flags. +type cleanupConfig struct { + // AutoDeleteAll, when true, skips the all-or-nothing prompt and + // proceeds to delete every candidate (still with per-version + // confirm unless PerVersion is also false). + // + // Sourced from either cfg.Cleanup.Auto OR --cleanup, both of + // which map to AutoDeleteAll = true. + AutoDeleteAll bool + + // PerVersion, when true, prompts before each individual + // deletion. Sourced from cfg.Cleanup.Prompt (default true). + // When false, the per-version prompt is skipped and we delete + // everything that survived the all-or-nothing prompt. + PerVersion bool + + // NonInteractive, when true, makes runCleanupPrompt a no-op + // return so the upgrade flow can skip cleanup entirely. + // Sourced from --no-cleanup. + NonInteractive bool + + // Prefiltered, when non-nil, is the set of versions the user + // explicitly asked to delete via --cleanup-version (repeatable). + // When set, the all-or-nothing prompt is skipped and only + // these versions are offered for deletion. + Prefiltered []semver.Version +} + +// cleanupResult summarizes what happened during a cleanup run. The +// upgrade command reports this to the user after the prompt loop +// completes (success or partial-failure). +type cleanupResult struct { + Deleted []semver.Version + Skipped []semver.Version + Failed []cleanupFailure +} + +type cleanupFailure struct { + Version semver.Version + Err error +} + +// runCleanupPrompt is the post-upgrade prompt entry point. It walks +// the user through deciding what (if anything) to delete, then +// invokes m.Uninstall for each chosen version. Returns a result for +// the caller to summarize. +// +// Parameters: +// +// - cfg โ€” behavior toggles (AutoDeleteAll / PerVersion / +// NonInteractive / Prefiltered). All derived from CLI flags + +// config file before this call. +// - toInstall โ€” the versions nodeup just installed (LTS + Current). +// These are excluded from "old versions" candidates. +// - installed โ€” every version the manager had BEFORE the upgrade. +// Used to compute the candidate set. +// - active โ€” the version that's currently active on PATH (e.g., +// the OLD default before we set a new one). Excluded from +// candidates so the user's shell doesn't break. +// - m โ€” the manager implementation, used for Uninstall(). +// - io โ€” input/output streams. Tests inject pipes here. +// +// Errors from individual Uninstall() calls are collected but do NOT +// stop the loop โ€” one failed delete doesn't block the next attempt. +func runCleanupPrompt(cfg cleanupConfig, toInstall, installed []semver.Version, active semver.Version, m detector.Manager, streams cleanupIO) (cleanupResult, error) { + var result cleanupResult + + // Step 1: Non-interactive skip. + if cfg.NonInteractive { + return result, nil + } + + // Step 2: Compute the candidates set: installed \ {new LTS, + // new Current, active}. + candidates := cleanupCandidates(toInstall, installed, active) + if len(candidates) == 0 { + // Nothing eligible to delete โ€” print a friendly note and + // return success. + fmt.Fprintln(streams.out, "No old versions to clean up.") + return result, nil + } + + // Step 3: Determine which candidates we'll offer. + var toOffer []semver.Version + if len(cfg.Prefiltered) > 0 { + // User passed --cleanup-version; only offer those that are + // actually in the candidate set. + toOffer = intersectCandidates(candidates, cfg.Prefiltered) + if len(toOffer) == 0 { + fmt.Fprintln(streams.out, "No matching versions to clean up.") + return result, nil + } + } else if !cfg.AutoDeleteAll { + // Default path: prompt "delete all old versions? [y/N]" + decision, err := promptAllOrNothing(candidates, streams) + if err != nil { + return result, fmt.Errorf("cleanup prompt: %w", err) + } + if decision.skip { + fmt.Fprintln(streams.out, "Cleanup skipped.") + return result, nil + } + if !decision.deleteAll && decision.deleteOne == "" { + // User pressed something we didn't understand; treat as skip. + fmt.Fprintln(streams.out, "Cleanup skipped (unrecognized answer).") + return result, nil + } + if decision.deleteOne != "" { + // User picked a specific version by number. + v, perr := semver.NewVersion(decision.deleteOne) + if perr != nil || !inCandidates(*v, candidates) { + fmt.Fprintln(streams.out, "Cleanup skipped (invalid version choice).") + return result, nil + } + toOffer = []semver.Version{*v} + } else { + // deleteAll: every candidate is on the table. + toOffer = candidates + } + } else { + // AutoDeleteAll (e.g. --cleanup or cfg.Cleanup.Auto): skip the + // all-or-nothing prompt. + toOffer = candidates + } + + // Step 4: Per-version loop. + for _, v := range toOffer { + confirm := true + if cfg.PerVersion { + answer, err := promptPerVersion(v, streams) + if err != nil { + result.Failed = append(result.Failed, cleanupFailure{v, err}) + continue + } + if !answer { + result.Skipped = append(result.Skipped, v) + continue + } + _ = confirm + } + + if err := m.Uninstall(v); err != nil { + result.Failed = append(result.Failed, cleanupFailure{v, err}) + fmt.Fprintf(streams.out, " Failed to delete %s: %v\n", v, err) + continue + } + result.Deleted = append(result.Deleted, v) + fmt.Fprintf(streams.out, " Deleted %s\n", v) + } + + return result, nil +} + +// cleanupCandidates returns installed \ {new LTS, new Current, active}, +// sorted ascending. The exclusions matter because: +// - new LTS / new Current โ€” we just installed them, so they ARE +// in `installed` (via the `toInstall` list) and obviously must +// not be deleted by the cleanup step. +// - active โ€” the OLD default. Deleting it would break the +// user's shell until they re-`source` their manager rc file. +// We never delete what's currently active. +// +// Empty inputs are tolerated: if none of toInstall / active are +// non-empty, only the empty-set exclusion applies. +func cleanupCandidates(toInstall, installed []semver.Version, active semver.Version) []semver.Version { + exclude := make(map[string]struct{}, len(toInstall)+1) + for _, v := range toInstall { + exclude[v.String()] = struct{}{} + } + if !active.Equal(&semver.Version{}) { + exclude[active.String()] = struct{}{} + } + + out := make([]semver.Version, 0, len(installed)) + for _, v := range installed { + if _, ok := exclude[v.String()]; ok { + continue + } + out = append(out, v) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Compare(&out[j]) < 0 + }) + return out +} + +// intersectCandidates returns the elements of `want` that are also in +// `candidates`. Order matches `want`'s order (so a user-supplied +// `--cleanup-version 22 20` keeps that order rather than alphabetical). +// We filter by string equality (semver.Compare should agree, but the +// string is what we'd print). +func intersectCandidates(candidates, want []semver.Version) []semver.Version { + set := make(map[string]struct{}, len(candidates)) + for _, c := range candidates { + set[c.String()] = struct{}{} + } + out := make([]semver.Version, 0, len(want)) + for _, w := range want { + if _, ok := set[w.String()]; ok { + out = append(out, w) + } + } + return out +} + +// inCandidates reports whether v is in the candidates set. Uses +// string equality to avoid any semver-compare edge cases (e.g., +// build metadata). +func inCandidates(v semver.Version, candidates []semver.Version) bool { + for _, c := range candidates { + if c.String() == v.String() { + return true + } + } + return false +} + +// promptAllOrNothing prints the candidate list and asks the user +// whether to delete them all, delete a specific one, or skip. The +// expected input is one of: +// +// y / Y โ€” delete-all +// โ€” delete that specific version (matched against +// the printed list) +// empty / n / N โ€” skip +// anything else โ€” re-prompt (we give one re-prompt then skip) +// +// Reads a single line from `in` (ignoring trailing newline). Returns +// the decision and any I/O error. +func promptAllOrNothing(candidates []semver.Version, streams cleanupIO) (cleanupDecision, error) { + fmt.Fprintf(streams.out, "\nOld Node.js versions still on disk (%d):\n", len(candidates)) + for _, v := range candidates { + fmt.Fprintf(streams.out, " v%s\n", v) + } + fmt.Fprintln(streams.out, "") + fmt.Fprintln(streams.out, "What would you like to do?") + fmt.Fprintln(streams.out, " y Delete all of the above") + fmt.Fprintln(streams.out, " Delete one specific version (e.g. 22.11.0)") + fmt.Fprintln(streams.out, " N Skip cleanup") + + // First attempt + line, err := readPromptLine(streams) + if err != nil { + return cleanupDecision{}, err + } + answer := normalizeAnswer(line) + switch answer { + case "y", "yes": + return cleanupDecision{deleteAll: true}, nil + case "", "n", "no": + return cleanupDecision{skip: true}, nil + } + // Treat a version-like answer as a one-shot specific-version pick. + // We accept anything that has at least one digit, since the printed + // versions are bare semvers. + if v, perr := semver.NewVersion(answer); perr == nil { + return cleanupDecision{deleteOne: v.String()}, nil + } + // Garbage input: re-prompt once. + fmt.Fprintln(streams.out, "(Please answer y, a specific version, or N.)") + line2, err := readPromptLine(streams) + if err != nil { + return cleanupDecision{}, err + } + answer2 := normalizeAnswer(line2) + switch answer2 { + case "y", "yes": + return cleanupDecision{deleteAll: true}, nil + case "", "n", "no": + return cleanupDecision{skip: true}, nil + } + if v, perr := semver.NewVersion(answer2); perr == nil { + return cleanupDecision{deleteOne: v.String()}, nil + } + // Still unrecognized โ€” treat as skip. + return cleanupDecision{skip: true}, nil +} + +// promptPerVersion prints "Delete vX.Y.Z? [y/N]" and reads one line. +// Returns true on y/Y/yes (case-insensitive), false on anything +// else (including empty input). +func promptPerVersion(v semver.Version, streams cleanupIO) (bool, error) { + fmt.Fprintf(streams.out, "Delete v%s? [y/N] ", v) + line, err := readPromptLine(streams) + if err != nil { + return false, err + } + switch normalizeAnswer(line) { + case "y", "yes": + return true, nil + default: + return false, nil + } +} + +// readPromptLine reads one trimmed line from `in`. We use a buffered +// reader rather than bufio.Scanner because Scanner drops trailing +// tokens on long lines (>64 KiB) โ€” pathological for an `nodeup` +// prompt but worth handling defensively. +func readPromptLine(streams cleanupIO) (string, error) { + line, err := streams.in.ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + // Strip trailing \r\n or \n. + line = trimNewline(line) + return line, nil +} + +func trimNewline(s string) string { + for s != "" && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') { + s = s[:len(s)-1] + } + return s +} + +// normalizeAnswer lower-cases and trims the input. +func normalizeAnswer(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + out = append(out, c) + } + // Trim leading/trailing spaces (manual loop avoids pulling in + // strings just for this hot-path helper). + start, end := 0, len(out) + for start < end && (out[start] == ' ' || out[start] == '\t') { + start++ + } + for end > start && (out[end-1] == ' ' || out[end-1] == '\t') { + end-- + } + return string(out[start:end]) +} + +// formatCleanupResult renders the post-prompt summary the upgrade +// command appends to "Upgrade complete!". Intended for cases where +// the user ran the cleanup and saw per-version prompts โ€” a single +// line at the bottom is enough. +func formatCleanupResult(r cleanupResult) string { + if len(r.Deleted) == 0 && len(r.Failed) == 0 { + return "" + } + out := "" + if len(r.Deleted) > 0 { + out += "Deleted: " + for i, v := range r.Deleted { + if i > 0 { + out += ", " + } + out += "v" + v.String() + } + } + if len(r.Skipped) > 0 { + if out != "" { + out += " " + } + out += "Skipped: " + for i, v := range r.Skipped { + if i > 0 { + out += ", " + } + out += "v" + v.String() + } + } + if len(r.Failed) > 0 { + if out != "" { + out += " " + } + out += fmt.Sprintf("%d failed", len(r.Failed)) + } + return out +} diff --git a/internal/cli/cleanup_test.go b/internal/cli/cleanup_test.go new file mode 100644 index 0000000..392cf99 --- /dev/null +++ b/internal/cli/cleanup_test.go @@ -0,0 +1,564 @@ +package cli + +import ( + "bufio" + "bytes" + "errors" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/detector" +) + +// stubManager is a minimal Manager implementation that records +// Uninstall calls so tests can assert which versions were deleted. +// Every other method returns a zero value โ€” the cleanup prompt only +// exercises Uninstall + (optionally) Current. +type stubManager struct { + name string + uninstalls []semver.Version + failOn map[string]error // version string โ†’ error to return + currentV *semver.Version // nil = Current() returns an error + currentErr error +} + +func (s *stubManager) Name() string { return s.name } +func (s *stubManager) Detect() bool { return true } +func (s *stubManager) Version() (string, error) { return "0.0.0-test", nil } +func (s *stubManager) ListInstalled() ([]semver.Version, error) { return nil, nil } +func (s *stubManager) Install(semver.Version) error { return nil } +func (s *stubManager) Use(semver.Version) error { return nil } +func (s *stubManager) SetDefault(semver.Version) error { return nil } +func (s *stubManager) GlobalNpmPrefix(semver.Version) (string, error) { return "", nil } +func (s *stubManager) Current() (semver.Version, error) { + if s.currentErr != nil { + return semver.Version{}, s.currentErr + } + if s.currentV == nil { + return semver.Version{}, errors.New("no active version") + } + return *s.currentV, nil +} +func (s *stubManager) Uninstall(v semver.Version) error { + if err, ok := s.failOn[v.String()]; ok { + return err + } + s.uninstalls = append(s.uninstalls, v) + return nil +} + +// newCleanupIO bundles an in-memory stdin/stdout pair for prompt +// tests. Tests pipe user input through `in` and read the rendered +// prompt from `out` for assertions. +func newCleanupIO(input string) (cleanupIO, *bytes.Buffer) { + var out bytes.Buffer + return cleanupIO{ + in: bufio.NewReader(strings.NewReader(input)), + out: &out, + }, &out +} + +func mustVer(t *testing.T, s string) semver.Version { + t.Helper() + v, err := semver.NewVersion(s) + if err != nil { + t.Fatalf("parse %q: %v", s, err) + } + return *v +} + +// --- cleanupCandidates ------------------------------------------------- + +func TestCleanupCandidates_ExcludesNewAndActive(t *testing.T) { + installed := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "20.18.0"), + mustVer(t, "22.11.0"), + mustVer(t, "24.15.0"), + } + toInstall := []semver.Version{ + mustVer(t, "22.11.0"), // new LTS โ€” exclude + mustVer(t, "24.15.0"), // new Current โ€” exclude + } + active := mustVer(t, "20.18.0") // currently active โ€” exclude + + got := cleanupCandidates(toInstall, installed, active) + want := []string{"18.20.4"} + if len(got) != len(want) { + t.Fatalf("got %d candidates (%v), want %d (%v)", len(got), got, len(want), want) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestCleanupCandidates_NoActive(t *testing.T) { + // When m.Current() errors out (e.g., nvm-windows), we pass the + // zero semver.Version โ€” exclusion should still apply to + // toInstall but not to any "active" version (since none was + // detected). + installed := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "22.11.0"), + } + toInstall := []semver.Version{mustVer(t, "22.11.0")} + + got := cleanupCandidates(toInstall, installed, semver.Version{}) + want := []string{"18.20.4"} + if len(got) != len(want) { + t.Fatalf("got %d, want %d", len(got), len(want)) + } + if got[0].String() != want[0] { + t.Errorf("got %s, want %s", got[0], want[0]) + } +} + +func TestCleanupCandidates_AllExcluded(t *testing.T) { + // Every installed version is either new or active โ†’ nothing left. + installed := []semver.Version{ + mustVer(t, "22.11.0"), + mustVer(t, "20.18.0"), + } + got := cleanupCandidates(installed, installed, mustVer(t, "20.18.0")) + if len(got) != 0 { + t.Errorf("expected empty candidates, got %v", got) + } +} + +func TestCleanupCandidates_EmptyInstalled(t *testing.T) { + got := cleanupCandidates(nil, nil, semver.Version{}) + if len(got) != 0 { + t.Errorf("expected empty, got %v", got) + } +} + +// --- runCleanupPrompt -------------------------------------------------- + +func TestCleanupPrompt_EmptyStdinSkipsAll(t *testing.T) { + // Empty input = no. Default N means skip everything. + streams, out := newCleanupIO("") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{PerVersion: true} + + candidates := []semver.Version{mustVer(t, "18.20.4")} + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 0 { + t.Errorf("expected 0 deleted, got %v", result.Deleted) + } + if !strings.Contains(out.String(), "Cleanup skipped") { + t.Errorf("expected skip message, got %q", out.String()) + } +} + +func TestCleanupPrompt_YesDeletesAll(t *testing.T) { + streams, _ := newCleanupIO("y\n") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{PerVersion: false} // no per-version confirm so "y\n" deletes everything + + candidates := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "20.18.0"), + } + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 2 { + t.Errorf("expected 2 deleted, got %v", result.Deleted) + } + if len(mgr.uninstalls) != 2 { + t.Errorf("expected 2 Uninstall calls, got %v", mgr.uninstalls) + } +} + +func TestCleanupPrompt_PerVersionConfirm(t *testing.T) { + // With PerVersion=true, "y" to all-or-nothing still requires a + // per-version "y" to actually delete. User says y, y, n โ€” only + // the first should be deleted. + streams, _ := newCleanupIO("y\ny\nn\n") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{PerVersion: true} + + candidates := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "20.18.0"), + } + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 1 { + t.Errorf("expected 1 deleted, got %v", result.Deleted) + } + if len(result.Skipped) != 1 { + t.Errorf("expected 1 skipped, got %v", result.Skipped) + } + if result.Deleted[0].String() != "18.20.4" { + t.Errorf("expected 18.20.4 deleted, got %s", result.Deleted[0]) + } + if result.Skipped[0].String() != "20.18.0" { + t.Errorf("expected 20.18.0 skipped, got %s", result.Skipped[0]) + } +} + +func TestCleanupPrompt_NoSkips(t *testing.T) { + streams, out := newCleanupIO("n\n") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{PerVersion: true} + + candidates := []semver.Version{mustVer(t, "18.20.4")} + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 0 { + t.Errorf("expected 0 deleted, got %v", result.Deleted) + } + if !strings.Contains(out.String(), "Cleanup skipped") { + t.Errorf("expected skip message, got %q", out.String()) + } +} + +func TestCleanupPrompt_SpecificVersion(t *testing.T) { + // User picks "20.18.0" by typing it. We should delete only that one. + streams, _ := newCleanupIO("20.18.0\n") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{PerVersion: false} // no per-version confirm + + candidates := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "20.18.0"), + mustVer(t, "22.11.0"), + } + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 1 { + t.Errorf("expected 1 deleted, got %v", result.Deleted) + } + if result.Deleted[0].String() != "20.18.0" { + t.Errorf("expected to delete 20.18.0, got %s", result.Deleted[0]) + } +} + +func TestCleanupPrompt_SpecificVersionInvalidSkips(t *testing.T) { + // User types something that's neither y/N nor a version โ€” treat as skip. + streams, out := newCleanupIO("bogus\n") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{PerVersion: false} + + candidates := []semver.Version{mustVer(t, "18.20.4")} + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 0 { + t.Errorf("expected 0 deleted, got %v", result.Deleted) + } + if !strings.Contains(out.String(), "Cleanup skipped") { + t.Errorf("expected skip message, got %q", out.String()) + } +} + +func TestCleanupPrompt_AutoDeleteAll(t *testing.T) { + // --cleanup: no all-or-nothing prompt; straight to deletion. + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{AutoDeleteAll: true, PerVersion: true} + + candidates := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "20.18.0"), + } + // Per-version prompts come AFTER AutoDeleteAll skips the first + // prompt. We send two "y\n" responses (one per candidate). + // To do that, build a multi-line input. + var inBuf bytes.Buffer + inBuf.WriteString("y\n") + inBuf.WriteString("y\n") + streams := cleanupIO{ + in: bufio.NewReader(&inBuf), + out: &bytes.Buffer{}, + } + + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 2 { + t.Errorf("expected 2 deleted, got %v", result.Deleted) + } +} + +func TestCleanupPrompt_NonInteractiveNoOp(t *testing.T) { + // --no-cleanup: no input read, no output written, no deletes. + streams, out := newCleanupIO("") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{NonInteractive: true} + + candidates := []semver.Version{mustVer(t, "18.20.4")} + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 0 { + t.Errorf("expected 0 deleted, got %v", result.Deleted) + } + if out.Len() != 0 { + t.Errorf("expected no output for non-interactive, got %q", out.String()) + } +} + +func TestCleanupPrompt_NoCandidatesNoPrompt(t *testing.T) { + // When every installed version is excluded, no prompt appears. + streams, out := newCleanupIO("") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{PerVersion: true} + + installed := []semver.Version{mustVer(t, "22.11.0")} + result, err := runCleanupPrompt(cfg, installed, installed, mustVer(t, "22.11.0"), mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 0 { + t.Errorf("expected 0 deleted, got %v", result.Deleted) + } + if !strings.Contains(out.String(), "No old versions to clean up") { + t.Errorf("expected 'no old versions' message, got %q", out.String()) + } +} + +func TestCleanupPrompt_PrefilteredOnly(t *testing.T) { + // --cleanup-version 20.18.0: only that version should be offered, + // and the all-or-nothing prompt should be skipped. + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{ + PerVersion: true, // per-version prompt still happens + Prefiltered: []semver.Version{mustVer(t, "20.18.0")}, + } + + candidates := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "20.18.0"), + mustVer(t, "22.11.0"), + } + // Per-version "y" for the one prefiltered version. + var inBuf bytes.Buffer + inBuf.WriteString("y\n") + streams := cleanupIO{ + in: bufio.NewReader(&inBuf), + out: &bytes.Buffer{}, + } + + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 1 { + t.Errorf("expected 1 deleted, got %v", result.Deleted) + } + if result.Deleted[0].String() != "20.18.0" { + t.Errorf("expected 20.18.0, got %s", result.Deleted[0]) + } +} + +func TestCleanupPrompt_PrefilteredNoMatch(t *testing.T) { + // --cleanup-version 99.0.0 (not installed) โ†’ friendly note, + // no deletes. + streams, out := newCleanupIO("") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{ + Prefiltered: []semver.Version{mustVer(t, "99.0.0")}, + } + + candidates := []semver.Version{mustVer(t, "18.20.4")} + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 0 { + t.Errorf("expected 0 deleted, got %v", result.Deleted) + } + if !strings.Contains(out.String(), "No matching versions") { + t.Errorf("expected 'no matching versions' message, got %q", out.String()) + } +} + +func TestCleanupPrompt_UninstallErrorCollected(t *testing.T) { + // If Uninstall fails on one version, we still try the next. + streams, _ := newCleanupIO("y\ny\n") + mgr := &stubManager{ + name: "fnm", + failOn: map[string]error{ + "18.20.4": errors.New("permission denied"), + }, + } + cfg := cleanupConfig{PerVersion: false} + + candidates := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "20.18.0"), + } + result, err := runCleanupPrompt(cfg, nil, candidates, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 1 { + t.Errorf("expected 1 deleted, got %v", result.Deleted) + } + if result.Deleted[0].String() != "20.18.0" { + t.Errorf("expected 20.18.0 deleted, got %s", result.Deleted[0]) + } + if len(result.Failed) != 1 { + t.Errorf("expected 1 failure, got %v", result.Failed) + } + if result.Failed[0].Version.String() != "18.20.4" { + t.Errorf("expected failed version 18.20.4, got %s", result.Failed[0].Version) + } +} + +func TestCleanupPrompt_ExcludesActive(t *testing.T) { + // Active version (per m.Current) must NOT appear in candidates. + streams, _ := newCleanupIO("y\n") + mgr := &stubManager{ + name: "fnm", + currentV: ptrVer("20.18.0"), + } + cfg := cleanupConfig{PerVersion: false} + + installed := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "20.18.0"), // active โ€” should be excluded + mustVer(t, "22.11.0"), + } + result, err := runCleanupPrompt(cfg, nil, installed, *mgr.currentV, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 2 { + t.Fatalf("expected 2 deleted (active excluded), got %v", result.Deleted) + } + for _, v := range result.Deleted { + if v.String() == "20.18.0" { + t.Errorf("active version 20.18.0 should NOT have been deleted") + } + } +} + +func TestCleanupPrompt_ExcludesNewVersions(t *testing.T) { + // Newly-installed versions must NOT appear in candidates. + streams, _ := newCleanupIO("y\n") + mgr := &stubManager{name: "fnm"} + cfg := cleanupConfig{PerVersion: false} + + installed := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "22.11.0"), // new LTS โ€” exclude + mustVer(t, "24.15.0"), // new Current โ€” exclude + } + toInstall := []semver.Version{ + mustVer(t, "22.11.0"), + mustVer(t, "24.15.0"), + } + result, err := runCleanupPrompt(cfg, toInstall, installed, semver.Version{}, mgr, streams) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Deleted) != 1 { + t.Fatalf("expected 1 deleted (new versions excluded), got %v", result.Deleted) + } + if result.Deleted[0].String() != "18.20.4" { + t.Errorf("expected 18.20.4 deleted, got %s", result.Deleted[0]) + } +} + +// --- intersectCandidates ----------------------------------------------- + +func TestIntersectCandidates_OrderPreserved(t *testing.T) { + candidates := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "20.18.0"), + mustVer(t, "22.11.0"), + } + want := []semver.Version{ + mustVer(t, "22.11.0"), // user asked in this order + mustVer(t, "18.20.4"), + } + got := intersectCandidates(candidates, want) + if len(got) != 2 { + t.Fatalf("got %d, want 2", len(got)) + } + if got[0].String() != "22.11.0" || got[1].String() != "18.20.4" { + t.Errorf("order not preserved: got %v", got) + } +} + +func TestIntersectCandidates_FiltersMissing(t *testing.T) { + candidates := []semver.Version{mustVer(t, "18.20.4")} + want := []semver.Version{ + mustVer(t, "18.20.4"), + mustVer(t, "99.0.0"), // not installed + } + got := intersectCandidates(candidates, want) + if len(got) != 1 { + t.Fatalf("got %d, want 1", len(got)) + } + if got[0].String() != "18.20.4" { + t.Errorf("got %s, want 18.20.4", got[0]) + } +} + +// --- formatCleanupResult ----------------------------------------------- + +func TestFormatCleanupResult_Empty(t *testing.T) { + if got := formatCleanupResult(cleanupResult{}); got != "" { + t.Errorf("expected empty string, got %q", got) + } +} + +func TestFormatCleanupResult_DeletedOnly(t *testing.T) { + r := cleanupResult{ + Deleted: []semver.Version{mustVer(t, "18.20.4"), mustVer(t, "20.18.0")}, + } + got := formatCleanupResult(r) + if !strings.Contains(got, "v18.20.4") || !strings.Contains(got, "v20.18.0") { + t.Errorf("got %q, expected both versions listed", got) + } + if !strings.HasPrefix(got, "Deleted:") { + t.Errorf("got %q, expected leading 'Deleted:'", got) + } +} + +func TestFormatCleanupResult_WithSkippedAndFailed(t *testing.T) { + r := cleanupResult{ + Deleted: []semver.Version{mustVer(t, "18.20.4")}, + Skipped: []semver.Version{mustVer(t, "20.18.0")}, + Failed: []cleanupFailure{{Version: mustVer(t, "22.11.0"), Err: errors.New("nope")}}, + } + got := formatCleanupResult(r) + for _, want := range []string{"v18.20.4", "v20.18.0", "1 failed"} { + if !strings.Contains(got, want) { + t.Errorf("got %q, expected to contain %q", got, want) + } + } +} + +// --- helpers ------------------------------------------------------------ + +func ptrVer(s string) *semver.Version { + v, err := semver.NewVersion(s) + if err != nil { + panic(err) + } + return v +} + +// Compile-time check that stubManager implements detector.Manager. +var _ detector.Manager = (*stubManager)(nil) diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index db7e3cc..4188f3a 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -1,6 +1,7 @@ package cli import ( + "bufio" "context" "fmt" "io" @@ -30,6 +31,8 @@ migrate your global npm packages across automatically.`, cmd.Flags().Bool("dry-run", false, "show the plan without making changes") cmd.Flags().Bool("no-migrate", false, "skip global package migration") 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().BoolP("yes", "y", false, "non-interactive, assume yes to all prompts") cmd.Flags().Bool("offline", false, "use cached data, don't hit nodejs.org") @@ -43,12 +46,23 @@ func runUpgrade(cmd *cobra.Command, args []string) error { dryRun, _ := cmd.Flags().GetBool("dry-run") noMigrate, _ := cmd.Flags().GetBool("no-migrate") noCleanup, _ := cmd.Flags().GetBool("no-cleanup") - _ = noCleanup // TODO: implement cleanup logic in Phase 7 + autoCleanup, _ := cmd.Flags().GetBool("cleanup") + cleanupVersionsRaw, _ := cmd.Flags().GetStringSlice("cleanup-version") managerFlag, _ := cmd.Flags().GetString("manager") yes, _ := cmd.Flags().GetBool("yes") - _ = yes // TODO: use for non-interactive mode offline, _ := cmd.Flags().GetBool("offline") + // --cleanup-version must be parseable semvers โ€” fail fast if not, + // so a typo doesn't silently delete nothing. + cleanupVersions := make([]semver.Version, 0, len(cleanupVersionsRaw)) + for _, raw := range cleanupVersionsRaw { + v, err := parseVersion(raw) + if err != nil { + return fmt.Errorf("--cleanup-version %q: %w", raw, err) + } + cleanupVersions = append(cleanupVersions, *v) + } + // Load the effective config (defaults < file < env). The --manager // CLI flag, if provided, takes precedence over everything else. cfg, err := loadConfigOrDefault() @@ -63,6 +77,38 @@ func runUpgrade(cmd *cobra.Command, args []string) error { skipMigrate := noMigrate || !cfg.Packages.Migrate _ = skipMigrate // referenced below in snapshot/restore sections + // Cleanup behavior toggles. Resolution order (highest first): + // --no-cleanup : never prompt, never delete + // --cleanup : auto-delete all (no all-or-nothing prompt) + // --cleanup-version : delete only these versions + // cfg.Cleanup.Auto : auto-delete all (no prompt) + // cfg.Cleanup.Prompt=false: skip the per-version confirm + // default : prompt, one y/N per cleanup action + // + // The cleanupConfig struct is what runCleanupPrompt consumes. + cleanupCfg := cleanupConfig{ + NonInteractive: noCleanup, + PerVersion: cfg.Cleanup.Prompt, + Prefiltered: cleanupVersions, + } + switch { + case noCleanup: + // already set; no other knobs apply + case autoCleanup: + cleanupCfg.AutoDeleteAll = true + cleanupCfg.Prefiltered = cleanupVersions // combine with --cleanup-version if both set + case cfg.Cleanup.Auto: + cleanupCfg.AutoDeleteAll = true + cleanupCfg.Prefiltered = cleanupVersions + } + // --yes implies auto-delete-all so non-interactive runs don't hang. + if yes { + cleanupCfg.NonInteractive = false + cleanupCfg.AutoDeleteAll = true + cleanupCfg.PerVersion = false + } + _ = yes + // From here on, anything that mutates disk in a way that needs // replay-by-sentinel bookkeeping is wrapped in a sentinel lifecycle. // We use a flag (rather than os.Exit) so deferred cleanup runs even @@ -241,6 +287,51 @@ func runUpgrade(cmd *cobra.Command, args []string) error { } } + // Post-upgrade cleanup prompt. Phase 7: after a successful + // upgrade, ask the user whether to delete old versions. We + // exclude the versions we just installed and the currently- + // active version (if we can detect it). + if !cleanupCfg.NonInteractive { + // Best-effort detection of the currently-active version. + // A failure here is non-fatal: we just skip the exclusion + // rather than aborting a successful upgrade. + var active semver.Version + if cur, cerr := m.Current(); cerr == nil { + active = cur + } + + // Build a values slice from the toInstall pointers so the + // cleanup helpers (which take []semver.Version) don't have + // to special-case pointers. + newVersions := make([]semver.Version, 0, len(toInstall)) + for _, v := range toInstall { + newVersions = append(newVersions, *v) + } + + // Use cmd.IO for stdin/stdout when available; fall back to + // os.Stdin / cmd.OutOrStdout() so non-interactive shells + // still work. We deliberately do NOT use cmd.SetIn/SetOut + // (test injection happens via the io package). + result, cerr := runCleanupPrompt( + cleanupCfg, + newVersions, + installedVersions, + active, + m, + cleanupIO{ + in: bufio.NewReader(cmd.InOrStdin()), + out: cmd.OutOrStdout(), + }, + ) + if cerr != nil { + // Non-fatal: log and proceed to the success message. + cmd.Printf("Warning: cleanup encountered an error: %v\n", cerr) + } + if summary := formatCleanupResult(result); summary != "" { + cmd.Printf("Cleanup: %s\n", summary) + } + } + cmd.Printf("Upgrade complete!\n") return nil } diff --git a/internal/detector/asdf.go b/internal/detector/asdf.go index 9bccc10..79fb516 100644 --- a/internal/detector/asdf.go +++ b/internal/detector/asdf.go @@ -234,18 +234,148 @@ func parseASDFInstalled(stdout string) ([]semver.Version, error) { return versions, nil } -// --- Mutation stubs ----------------------------------------------------- -// -// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return -// ErrASDFNotImplemented. 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 (a *ASDF) Install(ver semver.Version) error { return ErrASDFNotImplemented } -func (a *ASDF) Uninstall(ver semver.Version) error { return ErrASDFNotImplemented } -func (a *ASDF) Use(ver semver.Version) error { return ErrASDFNotImplemented } -func (a *ASDF) SetDefault(ver semver.Version) error { return ErrASDFNotImplemented } +// --- Mutation methods ---------------------------------------------------- +// +// Install, Uninstall, Use, SetDefault, GlobalNpmPrefix, and Current +// for ASDF all shell out to the `asdf` binary through runShell. +// +// Important ASDF specifics: +// - ASDF takes the plugin name as a positional arg: `asdf install +// nodejs `, not `asdf install `. The plugin is `nodejs` +// (not `node` โ€” Volta/mise use `node` but ASDF uses `nodejs`). +// - SetDefault runs `asdf global nodejs ` which writes the +// `nodejs X.Y.Z` line to ~/.tool-versions. This is what `nodeup +// upgrade` calls after Install. +// - Use runs `asdf shell nodejs ` which sets the version only +// for the current shell (via $ASDF_NODEJS_VERSION). Persistence +// comes from SetDefault. +// - GlobalNpmPrefix points at the per-version global npm modules +// directory under $ASDF_DATA_DIR/installs/nodejs//lib/node_modules. +// - Current reads `asdf current nodejs` and parses the version +// from the second column. + +// Install runs `asdf install nodejs `. ASDF's plugin name is +// `nodejs` (per asdf-nodejs plugin convention). +func (a *ASDF) Install(ver semver.Version) error { + res, err := runShell(context.Background(), "asdf", "install", "nodejs", ver.String()) + if err != nil { + return fmt.Errorf("asdf install nodejs %s: %w", ver, err) + } + _ = res + return nil +} + +// Uninstall runs `asdf uninstall nodejs `. ASDF allows +// uninstalling the active version (the next shell call falls back +// to the system Node or the previous tool-versions pin). +func (a *ASDF) Uninstall(ver semver.Version) error { + res, err := runShell(context.Background(), "asdf", "uninstall", "nodejs", ver.String()) + if err != nil { + return fmt.Errorf("asdf uninstall nodejs %s: %w", ver, err) + } + _ = res + return nil +} + +// Use runs `asdf shell nodejs ` for the current shell. The +// `asdf shell` subcommand sets the version via the +// $ASDF_NODEJS_VERSION env var, which only the current shell sees โ€” +// to persist across sessions, call SetDefault. +func (a *ASDF) Use(ver semver.Version) error { + res, err := runShell(context.Background(), "asdf", "shell", "nodejs", ver.String()) + if err != nil { + return fmt.Errorf("asdf shell nodejs %s: %w", ver, err) + } + _ = res + return nil +} + +// SetDefault runs `asdf global nodejs ` which writes the line +// `nodejs ` to ~/.tool-versions. This is what `nodeup upgrade` +// calls after Install. +func (a *ASDF) SetDefault(ver semver.Version) error { + res, err := runShell(context.Background(), "asdf", "global", "nodejs", ver.String()) + if err != nil { + return fmt.Errorf("asdf global nodejs %s: %w", ver, err) + } + _ = res + return nil +} + +// GlobalNpmPrefix returns the per-version global npm directory for +// the given version. ASDF's on-disk layout is: +// +// $ASDF_DATA_DIR/installs/nodejs//lib/node_modules +// +// We probe the conventional path rather than calling `asdf exec` to +// avoid spawning a subprocess for a pure path computation. The +// directory may exist but be unreadable; in that case we return a +// wrapped error so the migration step can fall back. func (a *ASDF) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", ErrASDFNotImplemented + dir := asdfDataDir() + if dir == "" { + return "", errors.New("asdf: cannot resolve ASDF_DATA_DIR or ~/.asdf") + } + prefix := filepath.Join(dir, "installs", "nodejs", ver.String(), "lib", "node_modules") + if _, err := os.Stat(prefix); err != nil { + return "", fmt.Errorf("asdf global npm prefix for %s (looked at %s): %w", ver, prefix, err) + } + return prefix, nil +} + +// Current returns the version ASDF currently has selected. Source: +// `asdf current nodejs`. The output looks like: +// +// nodejs 22.11.0 (set by /home/user/.tool-versions) +// nodejs 20.18.0 (set by /home/user/project/.tool-versions) +// nodejs system (no installed version) +// +// We take the second token (the version column) and feed it to +// semver.NewVersion. We treat "system" / "None" as errors so the +// cleanup prompt doesn't try to exclude them. +func (a *ASDF) Current() (semver.Version, error) { + res, err := runShell(context.Background(), "asdf", "current", "nodejs") + if err != nil { + return semver.Version{}, fmt.Errorf("asdf current nodejs: %w", err) + } + return parseASDFCurrent(res.Stdout) +} + +// parseASDFCurrent extracts the active version from `asdf current +// nodejs` output. Exposed (lowercase) for direct unit testing. +// +// Real observed output: +// +// nodejs 22.11.0 (set by /home/user/.tool-versions) +// nodejs 20.18.0 (set by /home/user/project/.tool-versions) +// +// We split each line into tokens. The version is the second token +// (after `nodejs`). Anything that doesn't parse (including the +// literal "system") is returned as an error. +func parseASDFCurrent(stdout string) (semver.Version, error) { + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + if fields[0] != "nodejs" { + continue + } + if fields[1] == "system" || fields[1] == "None" { + // ASDF prints "None" when the tool is unset. Both + // cases mean "no active version", so we error out + // and let the cleanup prompt skip exclusion. + return semver.Version{}, fmt.Errorf("asdf current: %q is not a managed version", fields[1]) + } + v, err := semver.NewVersion(fields[1]) + if err != nil { + return semver.Version{}, fmt.Errorf("asdf current: parse %q: %w", fields[1], err) + } + return *v, nil + } + return semver.Version{}, errors.New("asdf current nodejs did not contain a version row") } diff --git a/internal/detector/asdf_test.go b/internal/detector/asdf_test.go index 0cdc4e8..3094a94 100644 --- a/internal/detector/asdf_test.go +++ b/internal/detector/asdf_test.go @@ -416,31 +416,118 @@ func TestASDF_Detect_EmptyASDF_DATA_DIR_FallsThrough(t *testing.T) { } } -// --- Mutation stubs ----------------------------------------------------- +// --- Mutation methods ------------------------------------------------- -func TestASDF_MutationMethods_NotImplemented(t *testing.T) { - // Phase 1: Install / Uninstall / Use / SetDefault / GlobalNpmPrefix - // return ErrASDFNotImplemented. This sentinel lets callers - // distinguish "not implemented" from other errors via errors.Is. +func TestASDF_MutationMethodsInvokeShell(t *testing.T) { + // ASDF mutation commands all take `nodejs ` (the plugin name + // is `nodejs`, not `node`). We verify each call wraps the + // version with the plugin prefix so the CLI doesn't accidentally + // call `asdf install 22.5.0`. a := NewASDF() - ver, _ := semver.NewVersion("20.0.0") + ver, err := semver.NewVersion("22.5.0") + if err != nil { + t.Fatal(err) + } - if err := a.Install(*ver); !errors.Is(err, ErrASDFNotImplemented) { - t.Errorf("Install() = %v, want ErrASDFNotImplemented", err) + type tc struct { + name string + call func() error + wantArg string + } + cases := []tc{ + {"Install", func() error { return a.Install(*ver) }, "install nodejs 22.5.0"}, + {"Uninstall", func() error { return a.Uninstall(*ver) }, "uninstall nodejs 22.5.0"}, + {"Use", func() error { return a.Use(*ver) }, "shell nodejs 22.5.0"}, + {"SetDefault", func() error { return a.SetDefault(*ver) }, "global nodejs 22.5.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 := "asdf " + c.wantArg + if captured != want { + t.Errorf("%s invoked %q, want %q", c.name, captured, want) + } + }) } - if err := a.Uninstall(*ver); !errors.Is(err, ErrASDFNotImplemented) { - t.Errorf("Uninstall() = %v, want ErrASDFNotImplemented", err) +} + +func TestASDF_Uninstall_PropagatesError(t *testing.T) { + wantErr := errors.New("simulated asdf failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + v := semver.MustParse("20.0.0") + err := NewASDF().Uninstall(*v) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) } - if err := a.Use(*ver); !errors.Is(err, ErrASDFNotImplemented) { - t.Errorf("Use() = %v, want ErrASDFNotImplemented", err) +} + +// --- parseASDFCurrent -------------------------------------------------- + +func TestParseASDFCurrent_StandardOutput(t *testing.T) { + // Real observed output of `asdf current nodejs`: + stdout := "nodejs 22.11.0 (set by /home/user/.tool-versions)\n" + v, err := parseASDFCurrent(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.String() != "22.11.0" { + t.Errorf("got %q, want %q", v.String(), "22.11.0") + } +} + +func TestParseASDFCurrent_SystemIsError(t *testing.T) { + stdout := "nodejs system\n" + _, err := parseASDFCurrent(stdout) + if err == nil { + t.Error("expected error for 'system' (not a managed version)") + } +} + +func TestParseASDFCurrent_EmptyOutput(t *testing.T) { + _, err := parseASDFCurrent("") + if err == nil { + t.Error("expected error on empty input") + } +} + +func TestASDFCurrent_InvokesShell(t *testing.T) { + var captured string + withStubShell(t, + nil, + func(req string) (*platform.RunResult, error) { + captured = req + if req != "asdf current nodejs" { + t.Errorf("unexpected runShell call: %q", req) + } + return &platform.RunResult{Stdout: "nodejs 22.11.0\n"}, nil + }, + ) + + got, err := NewASDF().Current() + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if err := a.SetDefault(*ver); !errors.Is(err, ErrASDFNotImplemented) { - t.Errorf("SetDefault() = %v, want ErrASDFNotImplemented", err) + if got.String() != "22.11.0" { + t.Errorf("got %q, want %q", got.String(), "22.11.0") } - if p, err := a.GlobalNpmPrefix(*ver); !errors.Is(err, ErrASDFNotImplemented) { - t.Errorf("GlobalNpmPrefix() err = %v, want ErrASDFNotImplemented", err) - } else if p != "" { - t.Errorf("GlobalNpmPrefix() prefix = %q, want \"\"", p) + if captured == "" { + t.Error("expected asdf current nodejs to be invoked") } } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 6622935..08fced9 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -66,6 +66,15 @@ type Manager interface { // GlobalNpmPrefix returns the directory where npm installs globals // for the given Node.js version. Used to enumerate packages. GlobalNpmPrefix(v semver.Version) (string, error) + + // Current returns the version that is currently active on PATH. + // Used by the upgrade command to exclude the active version from + // post-upgrade cleanup candidates. May return an error if the + // manager has no way to query the active version (e.g., + // nvm-windows โ€” that one returns a sentinel "not implemented" + // error). Callers should treat errors as "active version unknown" + // and proceed without excluding it. + Current() (semver.Version, error) } // Registry holds the list of managers nodeup knows about. It is built diff --git a/internal/detector/fnm.go b/internal/detector/fnm.go index 549dbd9..554967f 100644 --- a/internal/detector/fnm.go +++ b/internal/detector/fnm.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" + "runtime" "sort" "strings" @@ -152,18 +155,186 @@ func parseFNMInstalled(stdout string) ([]semver.Version, error) { return versions, nil } -// --- Mutation stubs ----------------------------------------------------- +// --- Mutation methods ---------------------------------------------------- // -// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return -// ErrFNMNotImplemented. 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". +// Install, Uninstall, Use, SetDefault, GlobalNpmPrefix, and Current all +// shell out to the `fnm` binary through runShell. They were left as +// stubs in Phase 1; filling them in was deferred until the upgrade +// command (Phase 4) actually needed them. +// +// All shell-outs follow the same error-wrapping convention: a non-zero +// exit becomes a wrapped error containing the captured stderr, so the +// user can see WHY the manager refused (e.g., "version not found"). +// `errors.Is(err, platform.ErrNotFound)` is preserved through the +// wrapping so callers can detect a missing binary independently of +// the manager's own refusal. + +// Install runs `fnm install `. fnm installs into the per-user +// $FNM_DIR (or platform default) and prints the resolved path on +// success. Stderr is folded into the error on non-zero exit. +func (f *FNM) Install(v semver.Version) error { + res, err := runShell(context.Background(), "fnm", "install", v.String()) + if err != nil { + return fmt.Errorf("fnm install %s: %w", v, err) + } + _ = res // success path has no payload to consume + return nil +} + +// Uninstall runs `fnm uninstall `. fnm refuses if the version is +// the current default โ€” callers that want to remove the default must +// run SetDefault to a different version first. +func (f *FNM) Uninstall(v semver.Version) error { + res, err := runShell(context.Background(), "fnm", "uninstall", v.String()) + if err != nil { + return fmt.Errorf("fnm uninstall %s: %w", v, err) + } + _ = res + return nil +} + +// Use runs `fnm use ` for the current shell. Note that this only +// affects the subprocess's environment โ€” it does NOT persist a +// per-shell version across sessions. For persistence, callers should +// use SetDefault. +func (f *FNM) Use(v semver.Version) error { + res, err := runShell(context.Background(), "fnm", "use", v.String()) + if err != nil { + return fmt.Errorf("fnm use %s: %w", v, err) + } + _ = res + return nil +} -func (f *FNM) Install(v semver.Version) error { return ErrFNMNotImplemented } -func (f *FNM) Uninstall(v semver.Version) error { return ErrFNMNotImplemented } -func (f *FNM) Use(v semver.Version) error { return ErrFNMNotImplemented } -func (f *FNM) SetDefault(v semver.Version) error { return ErrFNMNotImplemented } +// SetDefault runs `fnm default ` to pin the version for new +// shells. This is what `nodeup upgrade` calls after Install. +func (f *FNM) SetDefault(v semver.Version) error { + res, err := runShell(context.Background(), "fnm", "default", v.String()) + if err != nil { + return fmt.Errorf("fnm default %s: %w", v, err) + } + _ = res + return nil +} + +// GlobalNpmPrefix returns the per-version global npm directory for +// the given version. fnm's on-disk layout is: +// +// $FNM_DIR/node-versions//installation/lib/node_modules +// +// (the `installation/` subdir is the resolved symlink target โ€” older +// fnm versions skipped it, but v1.30+ adds it consistently). +// +// We probe the conventional path rather than calling `fnm exec` +// because: +// - the path is stable across fnm versions (any future change +// would be a layout-breaking release) +// - we don't want to spawn a subprocess just to compute a path +// - the directory might exist but be unreadable โ€” we return a +// non-nil error so the migration step can fall back to a +// different strategy (e.g., re-run npm install -g) func (f *FNM) GlobalNpmPrefix(v semver.Version) (string, error) { - return "", ErrFNMNotImplemented + dir := fnmNodeVersionDir(v) + if dir == "" { + return "", errors.New("fnm: cannot resolve node-versions directory") + } + prefix := filepath.Join(dir, "installation", "lib", "node_modules") + if _, err := os.Stat(prefix); err != nil { + if os.IsNotExist(err) { + // Older fnm layout (no `installation/` subdir). + prefix = filepath.Join(dir, "lib", "node_modules") + if _, err2 := os.Stat(prefix); err2 != nil { + return "", fmt.Errorf("fnm global npm prefix for %s not found at %s or %s", v, prefix, filepath.Join(dir, "installation", "lib", "node_modules")) + } + } else { + return "", fmt.Errorf("stat fnm prefix %s: %w", prefix, err) + } + } + return prefix, nil +} + +// fnmNodeVersionDir returns the directory fnm stores in. +// Resolution order: +// 1. $FNM_DIR (the official override) +// 2. ~/.local/share/fnm (Linux default per fnm install script) +// 3. ~/Library/Application Support/fnm (macOS default) +// +// Returns "" if none resolve. The exact filename is `` with no +// "v" prefix (fnm strips it during install). +func fnmNodeVersionDir(v semver.Version) string { + dir := fnmDir() + if dir == "" { + return "" + } + return filepath.Join(dir, "node-versions", v.String()) +} + +// fnmDir mirrors the resolution logic fnm itself uses to pick its +// data dir. Resolution order: +// 1. $FNM_DIR (the official override) +// 2. $XDG_DATA_HOME/fnm (XDG default on Linux) +// 3. ~/.local/share/fnm (Linux fallback per fnm install script) +// 4. ~/Library/Application Support/fnm (macOS) +// +// Returns "" if none resolve. We deliberately don't try the Windows +// %AppData%\fnm path here โ€” the production code only runs on the +// host OS, so we don't need cross-platform fallbacks. +// +// homeDir (declared in volta.go) is the package-level seam used so +// tests can stub out the home-dir lookup without monkey-patching +// os.UserHomeDir itself. +func fnmDir() string { + if d := strings.TrimSpace(os.Getenv("FNM_DIR")); d != "" { + return d + } + if xd := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); xd != "" { + return filepath.Join(xd, "fnm") + } + home, err := homeDir() + if err != nil || home == "" { + return "" + } + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support", "fnm") + } + return filepath.Join(home, ".local", "share", "fnm") +} + +// Current returns the version fnm has currently selected for new +// shells โ€” i.e., `fnm current`. The output is a bare semver like +// "v22.11.0" or "22.11.0"; fnm 1.39+ includes a "v" prefix, older +// versions don't. We strip the prefix defensively. +func (f *FNM) Current() (semver.Version, error) { + res, err := runShell(context.Background(), "fnm", "current") + if err != nil { + return semver.Version{}, fmt.Errorf("fnm current: %w", err) + } + return parseFNMCurrent(res.Stdout) +} + +// parseFNMCurrent extracts the version from `fnm current` output. +// Exposed (lowercase) for direct unit testing. +// +// Observed output (fnm 1.39.0): +// +// v22.11.0 +// +// Older versions emitted bare "22.11.0". We strip an optional "v" +// prefix and feed the remainder to semver.NewVersion. +func parseFNMCurrent(stdout string) (semver.Version, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return semver.Version{}, errors.New("fnm current returned empty output") + } + // Take the first whitespace-separated token (in case fnm ever + // appends metadata, like fnm 1.40+ might). + fields := strings.Fields(out) + if len(fields) == 0 { + return semver.Version{}, errors.New("fnm current returned no tokens") + } + v, err := semver.NewVersion(strings.TrimPrefix(fields[0], "v")) + if err != nil { + return semver.Version{}, fmt.Errorf("fnm current: parse %q: %w", fields[0], err) + } + return *v, nil } diff --git a/internal/detector/fnm_test.go b/internal/detector/fnm_test.go index 5ab05b3..287ad3c 100644 --- a/internal/detector/fnm_test.go +++ b/internal/detector/fnm_test.go @@ -247,30 +247,122 @@ func TestFNM_ListInstalled_RunShellError(t *testing.T) { } } -func TestFNM_MutationMethodsReturnErrFNMNotImplemented(t *testing.T) { - // Phase 1 only implements the detection surface. Mutation methods - // must return ErrFNMNotImplemented so callers can distinguish - // "not yet implemented" from "succeeded with nil error". +func TestFNM_MutationMethodsInvokeShell(t *testing.T) { + // Phase 4 (the upgrade command) needs the mutation methods to + // actually shell out to fnm โ€” not return a stub. Each call + // should produce a single runShell invocation with a known argv. f := NewFNM() v, err := semver.NewVersion("22.11.0") if err != nil { t.Fatal(err) } - if err := f.Install(*v); !errors.Is(err, ErrFNMNotImplemented) { - t.Errorf("Install: got %v, want ErrFNMNotImplemented", err) + type tc struct { + name string + call func() error + wantArg string + } + cases := []tc{ + {"Install", func() error { return f.Install(*v) }, "install 22.11.0"}, + {"Uninstall", func() error { return f.Uninstall(*v) }, "uninstall 22.11.0"}, + {"Use", func() error { return f.Use(*v) }, "use 22.11.0"}, + {"SetDefault", func() error { return f.SetDefault(*v) }, "default 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 := "fnm " + c.wantArg + if captured != want { + t.Errorf("%s invoked %q, want %q", c.name, captured, want) + } + }) + } +} + +func TestFNM_Uninstall_PropagatesError(t *testing.T) { + // fnm refuses to uninstall the default; that error must surface + // to the caller wrapped, so the CLI can show a useful message + // instead of silently leaving the version on disk. + wantErr := errors.New("cannot uninstall current version") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + v, _ := semver.NewVersion("20.0.0") + err := NewFNM().Uninstall(*v) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +// --- parseFNMCurrent ---------------------------------------------------- + +func TestParseFNMCurrent_StandardOutput(t *testing.T) { + // Observed on fnm 1.39.0: + // $ fnm current + // v22.11.0 + v, err := parseFNMCurrent("v22.11.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if err := f.Uninstall(*v); !errors.Is(err, ErrFNMNotImplemented) { - t.Errorf("Uninstall: got %v, want ErrFNMNotImplemented", err) + if v.String() != "22.11.0" { + t.Errorf("got %q, want %q", v.String(), "22.11.0") } - if err := f.Use(*v); !errors.Is(err, ErrFNMNotImplemented) { - t.Errorf("Use: got %v, want ErrFNMNotImplemented", err) +} + +func TestParseFNMCurrent_BareVersion(t *testing.T) { + // Older fnm versions drop the "v" prefix. + v, err := parseFNMCurrent("20.18.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.String() != "20.18.0" { + t.Errorf("got %q, want %q", v.String(), "20.18.0") + } +} + +func TestParseFNMCurrent_Empty(t *testing.T) { + _, err := parseFNMCurrent("") + if err == nil { + t.Error("expected error on empty input") + } +} + +func TestFNMCurrent_InvokesShell(t *testing.T) { + var captured string + withStubShell(t, + nil, + func(req string) (*platform.RunResult, error) { + captured = req + if req != "fnm current" { + t.Errorf("unexpected runShell call: %q", req) + } + return &platform.RunResult{Stdout: "v22.11.0\n"}, nil + }, + ) + + got, err := NewFNM().Current() + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if err := f.SetDefault(*v); !errors.Is(err, ErrFNMNotImplemented) { - t.Errorf("SetDefault: got %v, want ErrFNMNotImplemented", err) + if got.String() != "22.11.0" { + t.Errorf("got %q, want %q", got.String(), "22.11.0") } - if _, err := f.GlobalNpmPrefix(*v); !errors.Is(err, ErrFNMNotImplemented) { - t.Errorf("GlobalNpmPrefix: got %v, want ErrFNMNotImplemented", err) + if captured == "" { + t.Error("expected fnm current to be invoked") } } diff --git a/internal/detector/mise.go b/internal/detector/mise.go index d5fb8a0..c34192b 100644 --- a/internal/detector/mise.go +++ b/internal/detector/mise.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "os" + "path/filepath" "sort" "strings" @@ -266,18 +268,145 @@ func parseMiseInstalled(stdout string) ([]semver.Version, error) { return versions, nil } -// --- Mutation stubs ----------------------------------------------------- +// --- Mutation methods ---------------------------------------------------- // -// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return -// ErrMiseNotImplemented. 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". +// Install, Uninstall, Use, SetDefault, GlobalNpmPrefix, and Current +// for Mise all shell out to the `mise` binary through runShell. +// +// Important Mise specifics: +// - Mise takes `node@` rather than ``: `mise install node@`. +// - SetDefault runs `mise use --global node@` which writes the +// pin to ~/.config/mise/config.toml. This is what `nodeup +// upgrade` calls after Install. +// - Use runs `mise use node@` which writes the pin to the +// local ./mise.toml (NOT what we want for a system-wide upgrade โ€” +// `nodeup upgrade` calls SetDefault instead, but the per-shell +// Use is exposed for completeness). +// - GlobalNpmPrefix points at $MISE_DATA_DIR/installs/node//lib/node_modules. +// - Current runs `mise current node` which emits just ``. + +// Install runs `mise install node@`. Mise takes the tool name as +// part of the spec (`node@`), not as a separate positional arg. +func (m *Mise) Install(ver semver.Version) error { + res, err := runShell(context.Background(), "mise", "install", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("mise install node@%s: %w", ver, err) + } + _ = res + return nil +} + +// Uninstall runs `mise uninstall node@`. Mise silently allows +// uninstalling the active version โ€” the next shell call resolves to +// the previous tool-versions pin or fails if there is none. +func (m *Mise) Uninstall(ver semver.Version) error { + res, err := runShell(context.Background(), "mise", "uninstall", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("mise uninstall node@%s: %w", ver, err) + } + _ = res + return nil +} + +// Use runs `mise use node@` for the current shell. Note this +// writes the pin to the LOCAL ./mise.toml (per-project), not the +// global config โ€” `nodeup upgrade` calls SetDefault instead. +func (m *Mise) Use(ver semver.Version) error { + res, err := runShell(context.Background(), "mise", "use", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("mise use node@%s: %w", ver, err) + } + _ = res + return nil +} + +// SetDefault runs `mise use --global node@` which writes the +// `node = ""` line to ~/.config/mise/config.toml. This is what +// `nodeup upgrade` calls after Install. +func (m *Mise) SetDefault(ver semver.Version) error { + res, err := runShell(context.Background(), "mise", "use", "--global", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("mise use --global node@%s: %w", ver, err) + } + _ = res + return nil +} -func (m *Mise) Install(ver semver.Version) error { return ErrMiseNotImplemented } -func (m *Mise) Uninstall(ver semver.Version) error { return ErrMiseNotImplemented } -func (m *Mise) Use(ver semver.Version) error { return ErrMiseNotImplemented } -func (m *Mise) SetDefault(ver semver.Version) error { return ErrMiseNotImplemented } +// GlobalNpmPrefix returns the per-version global npm directory for +// the given version. Mise's on-disk layout is: +// +// $MISE_DATA_DIR/installs/node//lib/node_modules +// +// Default $MISE_DATA_DIR is ~/.local/share/mise. func (m *Mise) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", ErrMiseNotImplemented + dir := miseDataDir() + if dir == "" { + return "", errors.New("mise: cannot resolve MISE_DATA_DIR or ~/.local/share/mise") + } + prefix := filepath.Join(dir, "installs", "node", ver.String(), "lib", "node_modules") + if _, err := os.Stat(prefix); err != nil { + return "", fmt.Errorf("mise global npm prefix for %s (looked at %s): %w", ver, prefix, err) + } + return prefix, nil +} + +// miseDataDir returns Mise's data root โ€” where installs live. +// Resolution order: +// 1. $MISE_DATA_DIR (the official override) +// 2. ~/.local/share/mise (the documented default per mise install) +// +// Returns "" if neither resolves. +func miseDataDir() string { + if d := strings.TrimSpace(os.Getenv("MISE_DATA_DIR")); d != "" { + return d + } + home, err := homeDir() + if err != nil || home == "" { + return "" + } + return filepath.Join(home, ".local", "share", "mise") +} + +// Current returns the version Mise currently has active for the +// user. Source: `mise current node`. The output is a bare semver +// like "22.11.0" (with optional "v" prefix on some builds). +func (m *Mise) Current() (semver.Version, error) { + res, err := runShell(context.Background(), "mise", "current", "node") + if err != nil { + return semver.Version{}, fmt.Errorf("mise current node: %w", err) + } + return parseMiseCurrent(res.Stdout) +} + +// parseMiseCurrent extracts the active version from `mise current +// node` output. Exposed (lowercase) for direct unit testing. +// +// Real observed output (mise 2026.6.15): +// +// 22.11.0 +// +// or, when no version is active: +// +// # some mise builds print nothing or "system" +// +// We take the first non-empty line, strip whitespace, strip an +// optional "v" prefix, and feed the remainder to semver.NewVersion. +// The literal "system" is not a managed version and we return an +// error so callers skip the active-version exclusion. +func parseMiseCurrent(stdout string) (semver.Version, error) { + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + if line == "system" { + return semver.Version{}, errors.New("mise current: 'system' is not a managed version") + } + v, err := semver.NewVersion(strings.TrimPrefix(line, "v")) + if err != nil { + return semver.Version{}, fmt.Errorf("mise current: parse %q: %w", line, err) + } + return *v, nil + } + return semver.Version{}, errors.New("mise current node returned empty output") } diff --git a/internal/detector/mise_test.go b/internal/detector/mise_test.go index ae36941..9de2bbe 100644 --- a/internal/detector/mise_test.go +++ b/internal/detector/mise_test.go @@ -520,31 +520,123 @@ func TestMise_Detect_FindsBinaryOnPath(t *testing.T) { } } -// --- Mutation stubs ----------------------------------------------------- +// --- Mutation methods ------------------------------------------------- -func TestMise_MutationMethods_NotImplemented(t *testing.T) { - // Phase 1: Install / Uninstall / Use / SetDefault / - // GlobalNpmPrefix return ErrMiseNotImplemented. This sentinel - // lets callers distinguish "not implemented" from other - // errors via errors.Is. +func TestMise_MutationMethodsInvokeShell(t *testing.T) { + // Mise mutation commands all take `node@` (tool prefix + + // version). We verify each call wraps the version with the tool + // prefix so the CLI doesn't accidentally call `mise install 22.5.0`. m := NewMise() - ver, _ := semver.NewVersion("20.0.0") + ver, err := semver.NewVersion("22.5.0") + if err != nil { + t.Fatal(err) + } + + type tc struct { + name string + call func() error + wantArg string + } + cases := []tc{ + {"Install", func() error { return m.Install(*ver) }, "install node@22.5.0"}, + {"Uninstall", func() error { return m.Uninstall(*ver) }, "uninstall node@22.5.0"}, + {"Use", func() error { return m.Use(*ver) }, "use node@22.5.0"}, + {"SetDefault", func() error { return m.SetDefault(*ver) }, "use --global node@22.5.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 := "mise " + c.wantArg + if captured != want { + t.Errorf("%s invoked %q, want %q", c.name, captured, want) + } + }) + } +} + +func TestMise_Uninstall_PropagatesError(t *testing.T) { + wantErr := errors.New("simulated mise failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) - if err := m.Install(*ver); !errors.Is(err, ErrMiseNotImplemented) { - t.Errorf("Install() = %v, want ErrMiseNotImplemented", err) + v := semver.MustParse("20.0.0") + err := NewMise().Uninstall(*v) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +// --- parseMiseCurrent -------------------------------------------------- + +func TestParseMiseCurrent_Bare(t *testing.T) { + v, err := parseMiseCurrent("22.11.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if err := m.Uninstall(*ver); !errors.Is(err, ErrMiseNotImplemented) { - t.Errorf("Uninstall() = %v, want ErrMiseNotImplemented", err) + if v.String() != "22.11.0" { + t.Errorf("got %q, want %q", v.String(), "22.11.0") } - if err := m.Use(*ver); !errors.Is(err, ErrMiseNotImplemented) { - t.Errorf("Use() = %v, want ErrMiseNotImplemented", err) +} + +func TestParseMiseCurrent_WithPrefix(t *testing.T) { + v, err := parseMiseCurrent("v22.11.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.String() != "22.11.0" { + t.Errorf("got %q, want %q", v.String(), "22.11.0") + } +} + +func TestParseMiseCurrent_SystemIsError(t *testing.T) { + _, err := parseMiseCurrent("system\n") + if err == nil { + t.Error("expected error for 'system' (not a managed version)") + } +} + +func TestParseMiseCurrent_Empty(t *testing.T) { + _, err := parseMiseCurrent("") + if err == nil { + t.Error("expected error on empty input") + } +} + +func TestMiseCurrent_InvokesShell(t *testing.T) { + var captured string + withStubShell(t, + nil, + func(req string) (*platform.RunResult, error) { + captured = req + if req != "mise current node" { + t.Errorf("unexpected runShell call: %q", req) + } + return &platform.RunResult{Stdout: "22.11.0\n"}, nil + }, + ) + + got, err := NewMise().Current() + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if err := m.SetDefault(*ver); !errors.Is(err, ErrMiseNotImplemented) { - t.Errorf("SetDefault() = %v, want ErrMiseNotImplemented", err) + if got.String() != "22.11.0" { + t.Errorf("got %q, want %q", got.String(), "22.11.0") } - if p, err := m.GlobalNpmPrefix(*ver); !errors.Is(err, ErrMiseNotImplemented) { - t.Errorf("GlobalNpmPrefix() err = %v, want ErrMiseNotImplemented", err) - } else if p != "" { - t.Errorf("GlobalNpmPrefix() prefix = %q, want \"\"", p) + if captured == "" { + t.Error("expected mise current node to be invoked") } } diff --git a/internal/detector/n.go b/internal/detector/n.go index 3d76a7a..375b207 100644 --- a/internal/detector/n.go +++ b/internal/detector/n.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "sort" "strings" @@ -233,18 +235,142 @@ func parseNInstalled(stdout string) ([]semver.Version, error) { return versions, nil } -// --- Mutation stubs ----------------------------------------------------- +// --- Mutation methods ---------------------------------------------------- // -// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return -// ErrNNotImplemented. 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". +// Install, Uninstall, Use, SetDefault, GlobalNpmPrefix, and Current +// for n all shell out to the `n` binary through runShell. +// +// Important n specifics: +// - Install / Use take a bare ``: `n install `, `n `. +// - Uninstall takes a bare ``: `n uninstall `. +// - SetDefault is implicit: `n` always uses the latest installed +// version as the active one. There's no "default" command. +// We return nil (no-op) so callers can invoke SetDefault +// uniformly without special-casing n. +// - GlobalNpmPrefix follows n's on-disk layout: each version is +// in $N_PREFIX/n/versions/node// with the global prefix +// at .../lib/node_modules. +// - Current is derived from `n current` (added in n 8+); older +// n versions return a non-zero exit, which we propagate as an +// error. + +// Install runs `n install `. n auto-uses the newly-installed +// version, so there's no separate "default" step. +func (n *N) Install(ver semver.Version) error { + res, err := runShell(context.Background(), "n", "install", ver.String()) + if err != nil { + return fmt.Errorf("n install %s: %w", ver, err) + } + _ = res + return nil +} -func (n *N) Install(ver semver.Version) error { return ErrNNotImplemented } -func (n *N) Uninstall(ver semver.Version) error { return ErrNNotImplemented } -func (n *N) Use(ver semver.Version) error { return ErrNNotImplemented } -func (n *N) SetDefault(ver semver.Version) error { return ErrNNotImplemented } +// Uninstall runs `n uninstall `. n refuses to uninstall the +// active version (similar to fnm/nvm) โ€” callers must `n ` +// first to switch active versions. +func (n *N) Uninstall(ver semver.Version) error { + res, err := runShell(context.Background(), "n", "uninstall", ver.String()) + if err != nil { + return fmt.Errorf("n uninstall %s: %w", ver, err) + } + _ = res + return nil +} + +// Use runs `n ` (the bare-form invocation) to switch the active +// version in the current shell. This sets the symlink in +// $N_PREFIX/bin/ via the n script's symlink_update logic. +func (n *N) Use(ver semver.Version) error { + res, err := runShell(context.Background(), "n", ver.String()) + if err != nil { + return fmt.Errorf("n %s: %w", ver, err) + } + _ = res + return nil +} + +// SetDefault is a no-op for n โ€” n auto-uses the latest installed +// version, so there's no "default" concept to set. We intentionally +// return nil so callers can invoke SetDefault uniformly without +// special-casing n. +func (n *N) SetDefault(ver semver.Version) error { + _ = ver // no-op + return nil +} + +// GlobalNpmPrefix returns the per-version global npm directory for +// the given version. n's on-disk layout is: +// +// $N_PREFIX/n/versions/node//lib/node_modules +// +// Default $N_PREFIX is /usr/local. func (n *N) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", ErrNNotImplemented + dir := nPrefix() + if dir == "" { + return "", errors.New("n: cannot resolve N_PREFIX or /usr/local") + } + prefix := filepath.Join(dir, "n", "versions", "node", ver.String(), "lib", "node_modules") + if _, err := os.Stat(prefix); err != nil { + return "", fmt.Errorf("n global npm prefix for %s (looked at %s): %w", ver, prefix, err) + } + return prefix, nil +} + +// nPrefix returns n's install root. Resolution order: +// 1. $N_PREFIX (the official override) +// 2. $N_CACHE_PREFIX (the alternative override) +// 3. /usr/local (the documented default) +// +// Returns "" if none resolve. The default is hard-coded rather than +// computed from $HOME because the upstream install script explicitly +// targets /usr/local. +func nPrefix() string { + if p := strings.TrimSpace(os.Getenv("N_PREFIX")); p != "" { + return p + } + if p := strings.TrimSpace(os.Getenv("N_CACHE_PREFIX")); p != "" { + // N_CACHE_PREFIX is the *cache* prefix (where downloads live), + // but if N_PREFIX isn't set, n defaults to using N_CACHE_PREFIX + // as the install root too. Match that behavior. + return p + } + return "/usr/local" +} + +// Current returns the version n currently has active for the user. +// Source: `n current` (added in n 8+). The output is a bare semver +// like "22.11.0". On older n versions that don't have `n current`, +// the call returns a non-zero exit code and we propagate that error. +func (n *N) Current() (semver.Version, error) { + res, err := runShell(context.Background(), "n", "current") + if err != nil { + return semver.Version{}, fmt.Errorf("n current: %w", err) + } + return parseNCurrent(res.Stdout) +} + +// parseNCurrent extracts the active version from `n current` output. +// Exposed (lowercase) for direct unit testing. +// +// Real observed output (n 10.2.0): +// +// 22.11.0 +// +// We take the first non-empty line, trim whitespace, strip an +// optional "v" prefix, and feed the remainder to semver.NewVersion. +// Older n versions that don't support `current` exit non-zero +// before stdout โ€” that error is propagated by runShell. +func parseNCurrent(stdout string) (semver.Version, error) { + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + v, err := semver.NewVersion(strings.TrimPrefix(line, "v")) + if err != nil { + return semver.Version{}, fmt.Errorf("n current: parse %q: %w", line, err) + } + return *v, nil + } + return semver.Version{}, errors.New("n current returned empty output") } diff --git a/internal/detector/n_test.go b/internal/detector/n_test.go index 3b5f4e9..dbe91b6 100644 --- a/internal/detector/n_test.go +++ b/internal/detector/n_test.go @@ -452,31 +452,134 @@ func TestN_Detect_FindsBinaryOnPath(t *testing.T) { } } -// --- Mutation stubs ----------------------------------------------------- +// --- Mutation methods ------------------------------------------------- -func TestN_MutationMethods_NotImplemented(t *testing.T) { - // Phase 1: Install / Uninstall / Use / SetDefault / - // GlobalNpmPrefix return ErrNNotImplemented. This sentinel - // lets callers distinguish "not implemented" from other - // errors via errors.Is. +func TestN_MutationMethodsInvokeShell(t *testing.T) { + // n's mutation commands all take a bare `` (no tool prefix). m := NewN() - ver, _ := semver.NewVersion("20.0.0") + ver, err := semver.NewVersion("22.5.0") + if err != nil { + t.Fatal(err) + } - if err := m.Install(*ver); !errors.Is(err, ErrNNotImplemented) { - t.Errorf("Install() = %v, want ErrNNotImplemented", err) + type tc struct { + name string + call func() error + wantArg string + } + cases := []tc{ + {"Install", func() error { return m.Install(*ver) }, "install 22.5.0"}, + {"Uninstall", func() error { return m.Uninstall(*ver) }, "uninstall 22.5.0"}, + {"Use", func() error { return m.Use(*ver) }, "22.5.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 := "n " + c.wantArg + if captured != want { + t.Errorf("%s invoked %q, want %q", c.name, captured, want) + } + }) } - if err := m.Uninstall(*ver); !errors.Is(err, ErrNNotImplemented) { - t.Errorf("Uninstall() = %v, want ErrNNotImplemented", err) +} + +func TestN_SetDefaultIsNoOp(t *testing.T) { + // n auto-uses the latest installed version, so SetDefault is a + // no-op. We verify it returns nil without invoking the shell. + m := NewN() + ver, err := semver.NewVersion("22.5.0") + if err != nil { + t.Fatal(err) + } + + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + t.Fatalf("SetDefault must not invoke runShell for n (called %s %v)", name, a) + return nil, nil } - if err := m.Use(*ver); !errors.Is(err, ErrNNotImplemented) { - t.Errorf("Use() = %v, want ErrNNotImplemented", err) + t.Cleanup(func() { runShell = orig }) + + if err := m.SetDefault(*ver); err != nil { + t.Errorf("SetDefault = %v, want nil", err) + } +} + +func TestN_Uninstall_PropagatesError(t *testing.T) { + wantErr := errors.New("simulated n failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + v := semver.MustParse("20.0.0") + err := NewN().Uninstall(*v) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +// --- parseNCurrent ---------------------------------------------------- + +func TestParseNCurrent_Bare(t *testing.T) { + v, err := parseNCurrent("22.11.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.String() != "22.11.0" { + t.Errorf("got %q, want %q", v.String(), "22.11.0") + } +} + +func TestParseNCurrent_WithPrefix(t *testing.T) { + v, err := parseNCurrent("v22.11.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.String() != "22.11.0" { + t.Errorf("got %q, want %q", v.String(), "22.11.0") + } +} + +func TestParseNCurrent_Empty(t *testing.T) { + _, err := parseNCurrent("") + if err == nil { + t.Error("expected error on empty input") + } +} + +func TestNCurrent_InvokesShell(t *testing.T) { + var captured string + withStubShell(t, + nil, + func(req string) (*platform.RunResult, error) { + captured = req + if req != "n current" { + t.Errorf("unexpected runShell call: %q", req) + } + return &platform.RunResult{Stdout: "22.11.0\n"}, nil + }, + ) + + got, err := NewN().Current() + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if err := m.SetDefault(*ver); !errors.Is(err, ErrNNotImplemented) { - t.Errorf("SetDefault() = %v, want ErrNNotImplemented", err) + if got.String() != "22.11.0" { + t.Errorf("got %q, want %q", got.String(), "22.11.0") } - if p, err := m.GlobalNpmPrefix(*ver); !errors.Is(err, ErrNNotImplemented) { - t.Errorf("GlobalNpmPrefix() err = %v, want ErrNNotImplemented", err) - } else if p != "" { - t.Errorf("GlobalNpmPrefix() prefix = %q, want \"\"", p) + if captured == "" { + t.Error("expected n current to be invoked") } } diff --git a/internal/detector/nodenv.go b/internal/detector/nodenv.go index 00f4552..eea4487 100644 --- a/internal/detector/nodenv.go +++ b/internal/detector/nodenv.go @@ -300,18 +300,129 @@ func parseNodenvInstalled(stdout string) ([]semver.Version, error) { return versions, nil } -// --- Mutation stubs ----------------------------------------------------- +// --- Mutation methods ---------------------------------------------------- // -// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return -// ErrNodenvNotImplemented. 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". +// Install, Uninstall, Use, SetDefault, GlobalNpmPrefix, and Current +// for Nodenv all shell out to the `nodenv` binary through runShell. +// +// Important Nodenv specifics: +// - Install / Uninstall take a bare ``: `nodenv install `. +// - Use runs `nodenv shell ` (current shell only). +// - SetDefault runs `nodenv global ` which writes to +// ~/.nodenv/version. This is what `nodeup upgrade` calls after +// Install. +// - GlobalNpmPrefix points at $NODENV_ROOT/versions//lib/node_modules. +// - Current runs `nodenv version` which prints the active version +// (or "system" if no version is set). + +// Install runs `nodenv install `. Nodenv delegates to +// node-build (the bundled compiler-collection), so this can take a +// while for a fresh install. +func (nd *Nodenv) Install(ver semver.Version) error { + res, err := runShell(context.Background(), "nodenv", "install", ver.String()) + if err != nil { + return fmt.Errorf("nodenv install %s: %w", ver, err) + } + _ = res + return nil +} + +// Uninstall runs `nodenv uninstall `. Nodenv allows uninstalling +// the active version (the next shell call falls back to the system +// Node or the previous ~/.nodenv/version pin). +func (nd *Nodenv) Uninstall(ver semver.Version) error { + res, err := runShell(context.Background(), "nodenv", "uninstall", ver.String()) + if err != nil { + return fmt.Errorf("nodenv uninstall %s: %w", ver, err) + } + _ = res + return nil +} + +// Use runs `nodenv shell ` for the current shell. The shell subcommand +// sets the version via the $NODENV_NODE_VERSION env var, which only the +// current shell sees โ€” to persist across sessions, call SetDefault. +func (nd *Nodenv) Use(ver semver.Version) error { + res, err := runShell(context.Background(), "nodenv", "shell", ver.String()) + if err != nil { + return fmt.Errorf("nodenv shell %s: %w", ver, err) + } + _ = res + return nil +} + +// SetDefault runs `nodenv global ` which writes `` (no "v" +// prefix) to ~/.nodenv/version. This is what `nodeup upgrade` calls +// after Install. +func (nd *Nodenv) SetDefault(ver semver.Version) error { + res, err := runShell(context.Background(), "nodenv", "global", ver.String()) + if err != nil { + return fmt.Errorf("nodenv global %s: %w", ver, err) + } + _ = res + return nil +} -func (nd *Nodenv) Install(ver semver.Version) error { return ErrNodenvNotImplemented } -func (nd *Nodenv) Uninstall(ver semver.Version) error { return ErrNodenvNotImplemented } -func (nd *Nodenv) Use(ver semver.Version) error { return ErrNodenvNotImplemented } -func (nd *Nodenv) SetDefault(ver semver.Version) error { return ErrNodenvNotImplemented } +// GlobalNpmPrefix returns the per-version global npm directory for +// the given version. Nodenv's on-disk layout is: +// +// $NODENV_ROOT/versions//lib/node_modules func (nd *Nodenv) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", ErrNodenvNotImplemented + dir := nodenvRoot() + if dir == "" { + return "", errors.New("nodenv: cannot resolve NODENV_ROOT or ~/.nodenv") + } + prefix := filepath.Join(dir, "versions", ver.String(), "lib", "node_modules") + if _, err := os.Stat(prefix); err != nil { + return "", fmt.Errorf("nodenv global npm prefix for %s (looked at %s): %w", ver, prefix, err) + } + return prefix, nil +} + +// Current returns the version Nodenv currently has active for the +// user. Source: `nodenv version`. The output is a bare semver like +// "22.11.0" (no prefix on the version itself), or "system" when +// the system Node is the active one. We treat "system" as an error +// so the cleanup prompt doesn't try to exclude it. +func (nd *Nodenv) Current() (semver.Version, error) { + res, err := runShell(context.Background(), "nodenv", "version") + if err != nil { + return semver.Version{}, fmt.Errorf("nodenv version: %w", err) + } + return parseNodenvCurrent(res.Stdout) +} + +// parseNodenvCurrent extracts the active version from +// `nodenv version` output. Exposed (lowercase) for direct unit +// testing. +// +// Real observed output (nodenv 1.6.2): +// +// 22.11.0 (set by /home/user/.nodenv/version) +// +// or, when no version is set: +// +// system +// +// We take the first whitespace-separated token, strip an optional +// "v" prefix, and feed the remainder to semver.NewVersion. The +// literal "system" is not a managed version and we return an +// error so callers skip the active-version exclusion. +func parseNodenvCurrent(stdout string) (semver.Version, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return semver.Version{}, errors.New("nodenv version returned empty output") + } + fields := strings.Fields(out) + if len(fields) == 0 { + return semver.Version{}, errors.New("nodenv version returned no tokens") + } + if fields[0] == "system" { + return semver.Version{}, errors.New("nodenv current: 'system' is not a managed version") + } + v, err := semver.NewVersion(strings.TrimPrefix(fields[0], "v")) + if err != nil { + return semver.Version{}, fmt.Errorf("nodenv current: parse %q: %w", fields[0], err) + } + return *v, nil } diff --git a/internal/detector/nodenv_test.go b/internal/detector/nodenv_test.go index f2442e3..46bc8e1 100644 --- a/internal/detector/nodenv_test.go +++ b/internal/detector/nodenv_test.go @@ -511,32 +511,115 @@ func TestNodenv_Detect_FindsBinaryOnPath(t *testing.T) { } } -// --- Mutation stubs ----------------------------------------------------- +// --- Mutation methods ------------------------------------------------- -func TestNodenv_MutationMethods_NotImplemented(t *testing.T) { - // Phase 1: Install / Uninstall / Use / SetDefault / - // GlobalNpmPrefix return ErrNodenvNotImplemented. This - // sentinel lets callers distinguish "not implemented" from - // other errors via errors.Is. +func TestNodenv_MutationMethodsInvokeShell(t *testing.T) { + // Nodenv mutation commands all take a bare `` (no plugin + // prefix โ€” Nodenv is Node-only, unlike asdf). nd := NewNodenv() - ver, _ := semver.NewVersion("20.0.0") + ver, err := semver.NewVersion("22.5.0") + if err != nil { + t.Fatal(err) + } + + type tc struct { + name string + call func() error + wantArg string + } + cases := []tc{ + {"Install", func() error { return nd.Install(*ver) }, "install 22.5.0"}, + {"Uninstall", func() error { return nd.Uninstall(*ver) }, "uninstall 22.5.0"}, + {"Use", func() error { return nd.Use(*ver) }, "shell 22.5.0"}, + {"SetDefault", func() error { return nd.SetDefault(*ver) }, "global 22.5.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 := "nodenv " + c.wantArg + if captured != want { + t.Errorf("%s invoked %q, want %q", c.name, captured, want) + } + }) + } +} + +func TestNodenv_Uninstall_PropagatesError(t *testing.T) { + wantErr := errors.New("simulated nodenv failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + v := semver.MustParse("20.0.0") + err := NewNodenv().Uninstall(*v) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +// --- parseNodenvCurrent ------------------------------------------------- + +func TestParseNodenvCurrent_StandardOutput(t *testing.T) { + // Real observed output of `nodenv version`: + stdout := "22.11.0 (set by /home/user/.nodenv/version)\n" + v, err := parseNodenvCurrent(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.String() != "22.11.0" { + t.Errorf("got %q, want %q", v.String(), "22.11.0") + } +} - if err := nd.Install(*ver); !errors.Is(err, ErrNodenvNotImplemented) { - t.Errorf("Install() = %v, want ErrNodenvNotImplemented", err) +func TestParseNodenvCurrent_SystemIsError(t *testing.T) { + _, err := parseNodenvCurrent("system\n") + if err == nil { + t.Error("expected error for 'system' (not a managed version)") } - if err := nd.Uninstall(*ver); !errors.Is(err, ErrNodenvNotImplemented) { - t.Errorf("Uninstall() = %v, want ErrNodenvNotImplemented", err) +} + +func TestParseNodenvCurrent_Empty(t *testing.T) { + _, err := parseNodenvCurrent("") + if err == nil { + t.Error("expected error on empty input") } - if err := nd.Use(*ver); !errors.Is(err, ErrNodenvNotImplemented) { - t.Errorf("Use() = %v, want ErrNodenvNotImplemented", err) +} + +func TestNodenvCurrent_InvokesShell(t *testing.T) { + var captured string + withStubShell(t, + nil, + func(req string) (*platform.RunResult, error) { + captured = req + if req != "nodenv version" { + t.Errorf("unexpected runShell call: %q", req) + } + return &platform.RunResult{Stdout: "22.11.0 (set by /home/user/.nodenv/version)\n"}, nil + }, + ) + + got, err := NewNodenv().Current() + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if err := nd.SetDefault(*ver); !errors.Is(err, ErrNodenvNotImplemented) { - t.Errorf("SetDefault() = %v, want ErrNodenvNotImplemented", err) + if got.String() != "22.11.0" { + t.Errorf("got %q, want %q", got.String(), "22.11.0") } - if p, err := nd.GlobalNpmPrefix(*ver); !errors.Is(err, ErrNodenvNotImplemented) { - t.Errorf("GlobalNpmPrefix() err = %v, want ErrNodenvNotImplemented", err) - } else if p != "" { - t.Errorf("GlobalNpmPrefix() prefix = %q, want \"\"", p) + if captured == "" { + t.Error("expected nodenv version to be invoked") } } diff --git a/internal/detector/nvm.go b/internal/detector/nvm.go index 44dc288..9f1b7ba 100644 --- a/internal/detector/nvm.go +++ b/internal/detector/nvm.go @@ -231,18 +231,173 @@ func parseNVMInstalledEntries(entries []os.DirEntry) ([]semver.Version, error) { return versions, nil } -// --- Mutation stubs ----------------------------------------------------- -// -// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return -// ErrNVMNotImplemented. They will be filled in when the upgrade command -// (Phase 3) needs to mutate state. Returning an explicit sentinel error -// (rather than nil) makes "not implemented" provably distinguishable -// from "succeeded". - -func (n *NVM) Install(v semver.Version) error { return ErrNVMNotImplemented } -func (n *NVM) Uninstall(v semver.Version) error { return ErrNVMNotImplemented } -func (n *NVM) Use(v semver.Version) error { return ErrNVMNotImplemented } -func (n *NVM) SetDefault(v semver.Version) error { return ErrNVMNotImplemented } +// --- Mutation methods ---------------------------------------------------- +// +// Install, Uninstall, Use, SetDefault, GlobalNpmPrefix, and Current +// for NVM all go through runScript because nvm is a shell function +// (not a binary on PATH) โ€” we must `source` nvm.sh before invoking +// any `nvm` subcommand. runScript picks the right shell per OS via +// platform.RunShellScript. +// +// `nvm install` is special: it requires `nvm.sh` to be sourced AND +// is the only nvm subcommand that prompts on stdin if the version is +// already installed. We do NOT handle the prompt โ€” callers that want +// non-interactive behavior should pass `nvm install -s` (silent: no +// prompt, exit non-zero if already installed). +// +// Like the other managers, error wrapping preserves +// `errors.Is(err, platform.ErrNotFound)` so callers can detect a +// missing shell / source file independently of nvm's own refusals. + +// nvmScriptInvocation builds the bash one-liner that sources nvm.sh +// and runs the given nvm subcommand. Centralizing this here keeps the +// quoting (QuotePath) consistent across every mutation method. +// +// We quote the script path because it's almost always under $HOME +// and users with spaces in their home dir are common on macOS. +func nvmScriptInvocation(subcmd string) (string, error) { + script := nvmScriptPath() + if script == "" { + return "", errors.New("nvm not detected: cannot resolve nvm.sh path") + } + return fmt.Sprintf("source %s && nvm %s", platform.QuotePath(script), subcmd), nil +} + +// Install runs `nvm install ` after sourcing nvm.sh. We pass `-s` +// (silent / no prompt) so the upgrade command fails cleanly if the +// version is already installed rather than hanging on a stdin +// prompt in a non-interactive shell. +func (n *NVM) Install(v semver.Version) error { + script, err := nvmScriptInvocation(fmt.Sprintf("install -s %s", v.String())) + if err != nil { + return err + } + res, err := runScript(context.Background(), script) + if err != nil { + return fmt.Errorf("nvm install %s: %w", v, err) + } + _ = res + return nil +} + +// Uninstall runs `nvm uninstall `. nvm refuses to uninstall the +// currently-active version (similar to fnm); callers that want to +// remove the active default should SetDefault first. +func (n *NVM) Uninstall(v semver.Version) error { + script, err := nvmScriptInvocation(fmt.Sprintf("uninstall %s", v.String())) + if err != nil { + return err + } + res, err := runScript(context.Background(), script) + if err != nil { + return fmt.Errorf("nvm uninstall %s: %w", v, err) + } + _ = res + return nil +} + +// Use runs `nvm use ` for the current shell. Like fnm's Use, +// this only affects the subprocess's environment โ€” for persistence +// across sessions, call SetDefault. +func (n *NVM) Use(v semver.Version) error { + script, err := nvmScriptInvocation(fmt.Sprintf("use %s", v.String())) + if err != nil { + return err + } + res, err := runScript(context.Background(), script) + if err != nil { + return fmt.Errorf("nvm use %s: %w", v, err) + } + _ = res + return nil +} + +// SetDefault runs `nvm alias default ` to pin the version for new +// shells. This is what `nodeup upgrade` calls after Install. +func (n *NVM) SetDefault(v semver.Version) error { + script, err := nvmScriptInvocation(fmt.Sprintf("alias default %s", v.String())) + if err != nil { + return err + } + res, err := runScript(context.Background(), script) + if err != nil { + return fmt.Errorf("nvm alias default %s: %w", v, err) + } + _ = res + return nil +} + +// GlobalNpmPrefix returns the per-version global npm prefix for the +// given version. nvm's on-disk layout is: +// +// $NVM_DIR/versions/node//lib/node_modules +// +// We probe the conventional path rather than calling `nvm exec` to +// avoid spawning a subprocess for a pure path computation. The +// directory may exist but be unreadable; in that case we return a +// wrapped error so the migration step can fall back to a different +// strategy. func (n *NVM) GlobalNpmPrefix(v semver.Version) (string, error) { - return "", ErrNVMNotImplemented + dir := nvmDir() + if dir == "" { + return "", errors.New("nvm: cannot resolve NVM_DIR or ~/.nvm") + } + // nvm names the dirs with a leading "v" prefix. + prefix := filepath.Join(dir, "versions", "node", "v"+v.String(), "lib", "node_modules") + if _, err := os.Stat(prefix); err != nil { + return "", fmt.Errorf("nvm global npm prefix for %s (looked at %s): %w", v, prefix, err) + } + return prefix, nil +} + +// Current returns the version nvm currently has selected for new +// shells โ€” i.e., the resolved value of `nvm current`. The output is +// either "vX.Y.Z" (with prefix) or "system" (when the active version +// is the system Node). We map "system" to an error so callers can +// treat it as "no nvm-managed version is active" rather than +// silently treating system as a managed version. +func (n *NVM) Current() (semver.Version, error) { + script, err := nvmScriptInvocation("current") + if err != nil { + return semver.Version{}, err + } + res, err := runScript(context.Background(), script) + if err != nil { + return semver.Version{}, fmt.Errorf("nvm current: %w", err) + } + return parseNVMCurrent(res.Stdout) +} + +// parseNVMCurrent extracts the version from `nvm current` output. +// Exposed (lowercase) for direct unit testing. +// +// Observed output (nvm 0.40.5): +// +// v22.11.0 +// +// or, when no nvm-managed version is active: +// +// system +// +// We strip an optional "v" prefix and feed the remainder to +// semver.NewVersion. The literal "system" is not a managed version +// and we return an error so callers skip the active-version +// exclusion (treating it as "we don't know what's active"). +func parseNVMCurrent(stdout string) (semver.Version, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return semver.Version{}, errors.New("nvm current returned empty output") + } + if out == "system" || out == "none" { + // nvm prints "none" when no version is selected (rare; + // typically only after a `nvm deactivate`). Treat both as + // "no nvm-managed active version" so the cleanup prompt + // doesn't try to exclude "system" / "none". + return semver.Version{}, fmt.Errorf("nvm current: %q is not a managed version", out) + } + v, err := semver.NewVersion(strings.TrimPrefix(out, "v")) + if err != nil { + return semver.Version{}, fmt.Errorf("nvm current: parse %q: %w", out, err) + } + return *v, nil } diff --git a/internal/detector/nvm_test.go b/internal/detector/nvm_test.go index d0177b3..6c4756a 100644 --- a/internal/detector/nvm_test.go +++ b/internal/detector/nvm_test.go @@ -401,42 +401,112 @@ func TestNVM_ListInstalled_NoNVMDir(t *testing.T) { } } -// --- Mutation stubs ---------------------------------------------------- - -func TestNVM_MutationMethodsReturnErrNVMNotImplemented(t *testing.T) { - n := NewNVM() - // semver.MustParse returns *semver.Version; the Manager interface - // declares value receivers so we deref at the call site. - v := *semver.MustParse("20.0.0") - - tests := []struct { - name string - fn func() error - }{ - {"Install", func() error { return n.Install(v) }}, - {"Uninstall", func() error { return n.Uninstall(v) }}, - {"Use", func() error { return n.Use(v) }}, - {"SetDefault", func() error { return n.SetDefault(v) }}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - err := tc.fn() - if err == nil { - t.Fatalf("%s returned nil, want ErrNVMNotImplemented", tc.name) - } - if !errors.Is(err, ErrNVMNotImplemented) { - t.Errorf("%s returned %v, want errors.Is(_, ErrNVMNotImplemented)", tc.name, err) - } - }) - } -} - -func TestNVM_GlobalNpmPrefixReturnsErrNVMNotImplemented(t *testing.T) { - n := NewNVM() - v := *semver.MustParse("20.0.0") - _, err := n.GlobalNpmPrefix(v) - if !errors.Is(err, ErrNVMNotImplemented) { - t.Errorf("got %v, want errors.Is(_, ErrNVMNotImplemented)", err) +// --- Mutation methods ------------------------------------------------- + +func TestNVM_Install_SourcesAndInvokes(t *testing.T) { + // The Install path must (a) source nvm.sh, (b) call `nvm install -s `, + // and (c) return nil on a clean exit. We assert all three. + withStubNVMScript(t) + rec := withStubScript(t, "", nil) + + v := semver.MustParse("22.11.0") + if err := NewNVM().Install(*v); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(rec.script, "source ") { + t.Errorf("script missing source: %q", rec.script) + } + if !strings.Contains(rec.script, "nvm install -s 22.11.0") { + t.Errorf("script missing `nvm install -s 22.11.0`: %q", rec.script) + } +} + +func TestNVM_Uninstall_SourcesAndInvokes(t *testing.T) { + withStubNVMScript(t) + rec := withStubScript(t, "", nil) + + v := semver.MustParse("20.0.0") + if err := NewNVM().Uninstall(*v); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(rec.script, "nvm uninstall 20.0.0") { + t.Errorf("script missing `nvm uninstall 20.0.0`: %q", rec.script) + } +} + +func TestNVM_SetDefault_SourcesAndInvokes(t *testing.T) { + withStubNVMScript(t) + rec := withStubScript(t, "", nil) + + v := semver.MustParse("22.11.0") + if err := NewNVM().SetDefault(*v); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(rec.script, "nvm alias default 22.11.0") { + t.Errorf("script missing `nvm alias default 22.11.0`: %q", rec.script) + } +} + +func TestNVM_Uninstall_PropagatesError(t *testing.T) { + // nvm refuses to uninstall the active version โ€” that refusal + // must propagate wrapped, so the CLI can show a useful message. + withStubNVMScript(t) + withStubScript(t, "", errSentinelForTest) + + v := semver.MustParse("20.0.0") + err := NewNVM().Uninstall(*v) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, errSentinelForTest) { + t.Errorf("error %v should wrap %v", err, errSentinelForTest) + } +} + +func TestNVM_Install_NoNVMScript(t *testing.T) { + // NVM_DIR pointing at an empty temp dir โ€” no nvm.sh inside. + t.Setenv("NVM_DIR", t.TempDir()) + v := semver.MustParse("22.11.0") + if err := NewNVM().Install(*v); err == nil { + t.Error("expected error when nvm cannot be located") + } +} + +// --- parseNVMCurrent ---------------------------------------------------- + +func TestParseNVMCurrent_WithPrefix(t *testing.T) { + v, err := parseNVMCurrent("v22.11.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.String() != "22.11.0" { + t.Errorf("got %q, want %q", v.String(), "22.11.0") + } +} + +func TestParseNVMCurrent_BareVersion(t *testing.T) { + v, err := parseNVMCurrent("20.18.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.String() != "20.18.0" { + t.Errorf("got %q, want %q", v.String(), "20.18.0") + } +} + +func TestParseNVMCurrent_SystemIsError(t *testing.T) { + // "system" is NOT a managed version; we must surface an error so + // the cleanup prompt doesn't try to exclude it. + _, err := parseNVMCurrent("system\n") + if err == nil { + t.Error("expected error for 'system' (not a managed version)") + } +} + +func TestParseNVMCurrent_Empty(t *testing.T) { + _, err := parseNVMCurrent("") + if err == nil { + t.Error("expected error on empty input") } } diff --git a/internal/detector/nvm_windows.go b/internal/detector/nvm_windows.go index 06fb9e8..9c3289d 100644 --- a/internal/detector/nvm_windows.go +++ b/internal/detector/nvm_windows.go @@ -307,6 +307,13 @@ func parseNVMWindowsInstalled(stdout string) ([]semver.Version, error) { // upgrade command (Phase 4) needs to mutate state. Returning an // explicit sentinel error (rather than nil) makes "not implemented" // provably distinguishable from "succeeded". +// +// Current() also returns ErrNVMWindowsNotImplemented โ€” nvm-windows +// has a `nvm current` subcommand but it's notoriously flaky on +// newer builds (returns "" or "Unknown" with non-zero exit). The +// cleanup prompt treats Current() errors as "active version +// unknown" and proceeds without exclusion, so the sentinel is +// fine here. func (n *NVMWindows) Install(ver semver.Version) error { return ErrNVMWindowsNotImplemented } func (n *NVMWindows) Uninstall(ver semver.Version) error { return ErrNVMWindowsNotImplemented } @@ -315,3 +322,11 @@ func (n *NVMWindows) SetDefault(ver semver.Version) error { return ErrNVMWindows func (n *NVMWindows) GlobalNpmPrefix(ver semver.Version) (string, error) { return "", ErrNVMWindowsNotImplemented } + +// Current is not implemented for nvm-windows. Returning the +// sentinel error lets the upgrade prompt treat the active version +// as unknown and proceed without excluding it from the cleanup +// candidates. +func (n *NVMWindows) Current() (semver.Version, error) { + return semver.Version{}, ErrNVMWindowsNotImplemented +} diff --git a/internal/detector/nvm_windows_test.go b/internal/detector/nvm_windows_test.go index 41b2086..4772186 100644 --- a/internal/detector/nvm_windows_test.go +++ b/internal/detector/nvm_windows_test.go @@ -645,6 +645,18 @@ func TestNVMWindows_MutationMethods_NotImplemented(t *testing.T) { } } +func TestNVMWindows_CurrentReturnsSentinel(t *testing.T) { + // Current() on nvm-windows is unimplemented (the upstream + // `nvm current` subcommand is unreliable on newer builds). The + // cleanup prompt treats the error as "active version unknown" + // and proceeds without exclusion โ€” so returning the sentinel + // here is the right behavior. + _, err := NewNVMWindows().Current() + if !errors.Is(err, ErrNVMWindowsNotImplemented) { + t.Errorf("Current() err = %v, want ErrNVMWindowsNotImplemented", err) + } +} + // --- nvmWindowsRoot / nvmWindowsSymlink helpers ------------------------- func TestNVMWindows_NvmWindowsRootHelper_PrefersEnv(t *testing.T) { diff --git a/internal/detector/system_node_test.go b/internal/detector/system_node_test.go index 46493ab..d8a8d51 100644 --- a/internal/detector/system_node_test.go +++ b/internal/detector/system_node_test.go @@ -608,6 +608,7 @@ func (f fakeManager) Uninstall(semver.Version) error { return ni func (f fakeManager) Use(semver.Version) error { return nil } func (f fakeManager) SetDefault(semver.Version) error { return nil } func (f fakeManager) GlobalNpmPrefix(semver.Version) (string, error) { return "", nil } +func (f fakeManager) Current() (semver.Version, error) { return semver.Version{}, nil } func equalStringSlices(a, b []string) bool { if len(a) != len(b) { diff --git a/internal/detector/volta.go b/internal/detector/volta.go index d1634f2..c04ca2a 100644 --- a/internal/detector/volta.go +++ b/internal/detector/volta.go @@ -249,18 +249,155 @@ func parseVoltaInstalledEntries(entries []os.DirEntry) ([]semver.Version, error) return versions, nil } -// --- Mutation stubs ----------------------------------------------------- +// --- Mutation methods ---------------------------------------------------- // -// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return -// ErrVoltaNotImplemented. 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". +// Install, Uninstall, Use, SetDefault, GlobalNpmPrefix, and Current +// for Volta all shell out to the `volta` binary through runShell. +// +// Important Volta specifics: +// - Install / Uninstall / Use take a tool name (`node`), not a bare +// version: `volta install node@` not `volta install `. +// - SetDefault is a no-op for Volta โ€” Volta pins versions per +// project via `package.json` (the `volta` field), not per +// machine. We return nil so callers can call SetDefault +// uniformly without special-casing Volta. +// - GlobalNpmPrefix follows Volta's on-disk layout under +// $VOLTA_HOME/tools/image/node//lib/node_modules. Volta does +// NOT have a per-version npm global prefix the way fnm/nvm do +// โ€” Volta's global prefix is shared across versions. We point +// at the image's bundled npm modules dir as a reasonable proxy. +// - Current is derived from `volta list --format=plain` (the +// first row is the active node version). Volta does not have a +// `volta current` subcommand. + +// Install runs `volta install node@`. Volta resolves the version +// against its installed catalog and pins it as the active default +// for the current user (no per-project pin unless the user already +// has a `volta` field in package.json). +func (v *Volta) Install(ver semver.Version) error { + res, err := runShell(context.Background(), "volta", "install", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("volta install node@%s: %w", ver, err) + } + _ = res + return nil +} -func (v *Volta) Install(ver semver.Version) error { return ErrVoltaNotImplemented } -func (v *Volta) Uninstall(ver semver.Version) error { return ErrVoltaNotImplemented } -func (v *Volta) Use(ver semver.Version) error { return ErrVoltaNotImplemented } -func (v *Volta) SetDefault(ver semver.Version) error { return ErrVoltaNotImplemented } +// Uninstall runs `volta uninstall node@`. Volta silently allows +// uninstalling the active version โ€” the next shell call resolves to +// the system Node (or fails outright if system Node is missing). +func (v *Volta) Uninstall(ver semver.Version) error { + res, err := runShell(context.Background(), "volta", "uninstall", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("volta uninstall node@%s: %w", ver, err) + } + _ = res + return nil +} + +// Use runs `volta use node@` for the current shell. Like fnm/nvm +// Use, this is per-shell only โ€” Volta's persistent pin lives in +// the project's package.json (which `nodeup upgrade` does NOT +// touch, since it's not the user's project). +func (v *Volta) Use(ver semver.Version) error { + res, err := runShell(context.Background(), "volta", "use", "node@"+ver.String()) + if err != nil { + return fmt.Errorf("volta use node@%s: %w", ver, err) + } + _ = res + return nil +} + +// SetDefault is a no-op for Volta โ€” Volta pins versions per project +// (in package.json's `volta` field) rather than per machine. We +// intentionally return nil (not an error) so callers can invoke +// SetDefault uniformly without special-casing Volta. Volta's +// "default" after a fresh `volta install node@` is implicit: it +// becomes the version `volta` resolves to for shells that don't +// have a project pin. +func (v *Volta) SetDefault(ver semver.Version) error { + _ = ver // no-op + return nil +} + +// GlobalNpmPrefix returns the per-version global npm directory for +// the given version. Volta's on-disk layout is: +// +// $VOLTA_HOME/tools/image/node//lib/node_modules +// +// This is the directory Volta installs globally-installed npm +// packages into for the given version. Unlike fnm/nvm, Volta +// doesn't really have a "global npm prefix per version" concept in +// user-facing terms โ€” Volta's `VOLTA_HOME` is shared across versions +// โ€” but the underlying layout is per-version, so this is the path +// migration code should read. func (v *Volta) GlobalNpmPrefix(ver semver.Version) (string, error) { - return "", ErrVoltaNotImplemented + home := voltaHome() + if home == "" { + return "", errors.New("volta: cannot resolve VOLTA_HOME or ~/.volta") + } + prefix := filepath.Join(home, "tools", "image", "node", ver.String(), "lib", "node_modules") + if _, err := os.Stat(prefix); err != nil { + return "", fmt.Errorf("volta global npm prefix for %s (looked at %s): %w", ver, prefix, err) + } + return prefix, nil +} + +// Current returns the version Volta currently has active for the +// user. Source: `volta list --format=plain`. The plain format +// prints one tool per line: +// +// node@v20.18.0 (active) +// +// The first row matching "node@v" (or "node@" โ€” Volta 2.0 changed +// the prefix handling) is the active version. We strip the "v" +// prefix (and the optional "active" suffix) before parsing. +func (v *Volta) Current() (semver.Version, error) { + res, err := runShell(context.Background(), "volta", "list", "--format=plain") + if err != nil { + return semver.Version{}, fmt.Errorf("volta list: %w", err) + } + return parseVoltaCurrent(res.Stdout) +} + +// parseVoltaCurrent extracts the active node version from +// `volta list --format=plain` output. Exposed (lowercase) for direct +// unit testing. +// +// Real observed output (volta 2.0.2): +// +// node@v20.18.0 (active) +// npm@10.5.0 (active) +// pnpm@9.0.0 (default) +// +// We find the first row whose first field starts with "node@" and +// strip everything from the "@" onwards, then drop an optional "v" +// prefix and feed the remainder to semver.NewVersion. Lines that +// don't match (npm, pnpm, yarn, ...) are skipped. +func parseVoltaCurrent(stdout string) (semver.Version, error) { + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + // We only care about node. Skip other tools' rows. + const nodePrefix = "node@" + if !strings.HasPrefix(line, nodePrefix) { + continue + } + // Take the version token after "node@", stopping at the + // first whitespace (e.g., " (active)" suffix). + rest := line[len(nodePrefix):] + if sp := strings.IndexFunc(rest, func(r rune) bool { + return r == ' ' || r == '\t' + }); sp >= 0 { + rest = rest[:sp] + } + v, err := semver.NewVersion(strings.TrimPrefix(rest, "v")) + if err != nil { + return semver.Version{}, fmt.Errorf("volta current: parse %q: %w", rest, err) + } + return *v, nil + } + return semver.Version{}, errors.New("volta list did not contain an active node version") } diff --git a/internal/detector/volta_test.go b/internal/detector/volta_test.go index c216aa8..cfac270 100644 --- a/internal/detector/volta_test.go +++ b/internal/detector/volta_test.go @@ -410,30 +410,137 @@ func TestVolta_ListInstalled_ListDirError(t *testing.T) { } } -func TestVolta_MutationMethodsReturnErrVoltaNotImplemented(t *testing.T) { - // Phase 1 only implements the detection surface. Mutation methods - // must return ErrVoltaNotImplemented so callers can distinguish - // "not yet implemented" from "succeeded with nil error". +func TestVolta_MutationMethodsInvokeShell(t *testing.T) { + // Volta's mutation commands all take `node@` rather than `` + // directly. We verify each call wraps the version with the tool + // prefix so the CLI doesn't accidentally call `volta install 22.5.0`. v := NewVolta() ver, err := semver.NewVersion("22.5.0") if err != nil { t.Fatal(err) } - if err := v.Install(*ver); !errors.Is(err, ErrVoltaNotImplemented) { - t.Errorf("Install: got %v, want ErrVoltaNotImplemented", err) + type tc struct { + name string + call func() error + wantArg string + } + cases := []tc{ + {"Install", func() error { return v.Install(*ver) }, "install node@22.5.0"}, + {"Uninstall", func() error { return v.Uninstall(*ver) }, "uninstall node@22.5.0"}, + {"Use", func() error { return v.Use(*ver) }, "use node@22.5.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 := "volta " + c.wantArg + if captured != want { + t.Errorf("%s invoked %q, want %q", c.name, captured, want) + } + }) + } +} + +func TestVolta_SetDefaultIsNoOp(t *testing.T) { + // Volta pins versions per-project, not per-machine. SetDefault + // must return nil without invoking the shell โ€” callers can use + // the same code path regardless of which manager is active. + v := NewVolta() + ver, err := semver.NewVersion("22.5.0") + if err != nil { + t.Fatal(err) + } + + // Force a fail if runShell is invoked. + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + t.Fatalf("SetDefault must not invoke runShell for Volta (called %s %v)", name, a) + return nil, nil } - if err := v.Uninstall(*ver); !errors.Is(err, ErrVoltaNotImplemented) { - t.Errorf("Uninstall: got %v, want ErrVoltaNotImplemented", err) + t.Cleanup(func() { runShell = orig }) + + if err := v.SetDefault(*ver); err != nil { + t.Errorf("SetDefault = %v, want nil", err) + } +} + +func TestVolta_Uninstall_PropagatesError(t *testing.T) { + wantErr := errors.New("simulated volta failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + v := semver.MustParse("20.0.0") + err := NewVolta().Uninstall(*v) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) } - if err := v.Use(*ver); !errors.Is(err, ErrVoltaNotImplemented) { - t.Errorf("Use: got %v, want ErrVoltaNotImplemented", err) +} + +// --- parseVoltaCurrent -------------------------------------------------- + +func TestParseVoltaCurrent_ActiveNode(t *testing.T) { + // Real observed output of `volta list --format=plain`: + stdout := "node@v20.18.0 (active)\nnpm@10.5.0 (active)\npnpm@9.0.0 (default)\n" + v, err := parseVoltaCurrent(stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.String() != "20.18.0" { + t.Errorf("got %q, want %q", v.String(), "20.18.0") + } +} + +func TestParseVoltaCurrent_NoActiveNode(t *testing.T) { + // Output contains no node@ row. + _, err := parseVoltaCurrent("npm@10.5.0 (active)\n") + if err == nil { + t.Error("expected error when no active node row present") + } +} + +func TestParseVoltaCurrent_EmptyOutput(t *testing.T) { + _, err := parseVoltaCurrent("") + if err == nil { + t.Error("expected error on empty input") + } +} + +func TestVoltaCurrent_InvokesShell(t *testing.T) { + var captured string + withStubShell(t, + nil, + func(req string) (*platform.RunResult, error) { + captured = req + if req != "volta list --format=plain" { + t.Errorf("unexpected runShell call: %q", req) + } + return &platform.RunResult{Stdout: "node@v22.11.0 (active)\n"}, nil + }, + ) + + got, err := NewVolta().Current() + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if err := v.SetDefault(*ver); !errors.Is(err, ErrVoltaNotImplemented) { - t.Errorf("SetDefault: got %v, want ErrVoltaNotImplemented", err) + if got.String() != "22.11.0" { + t.Errorf("got %q, want %q", got.String(), "22.11.0") } - if _, err := v.GlobalNpmPrefix(*ver); !errors.Is(err, ErrVoltaNotImplemented) { - t.Errorf("GlobalNpmPrefix: got %v, want ErrVoltaNotImplemented", err) + if captured == "" { + t.Error("expected volta list to be invoked") } }