From 987aac6c68c3c482318715b5c6247ac4e571c3a6 Mon Sep 17 00:00:00 2001 From: dipto0321 Date: Fri, 3 Jul 2026 13:27:23 +0600 Subject: [PATCH] refactor(detector): add context.Context to Manager.ListInstalled and Current Mechanical refactor that adds context.Context as the first parameter to two Manager interface methods. The other interface methods (Install, Uninstall, Use, SetDefault, GlobalNpmPrefix, Version) are intentionally out of scope per the issue title. The omission became user-visible when PR #56 / issue #41 added Current() across all 8 manager implementations: a Ctrl-C mid-`nodeup upgrade` would still wait for the underlying `manager list` / `fnm list` / `nvm current` subprocess to finish before returning, because cancellation had no path into the read paths. Interface + 8 implementations (fnm, nvm, volta, asdf, mise, n, nodenv, nvm-windows) plumb ctx into existing runShell/runScript calls (both already ctx-aware via internal/platform/shell.go). 6 production call-sites in internal/cli/{check,list,upgrade,packages}.go pass cmd.Context(); getCurrentVersion(m) becomes getCurrentVersion(ctx, m). 3 mock/stub Manager types (fakeManager, listTestStubManager, stubManager) take a discarded _ context.Context. 39 test call-sites in the per-manager *_test.go files now pass t.Context() (Go 1.24). contextcheck linter is satisfied: production code uses cmd.Context(), tests use t.Context() (no context.Background() anywhere a ctx is in scope). Closes #53 --- CHANGELOG.md | 32 +++++++++++++++++++++++++++ internal/cli/check.go | 4 ++-- internal/cli/cleanup_test.go | 19 ++++++++-------- internal/cli/list.go | 4 ++-- internal/cli/list_test.go | 5 +++-- internal/cli/packages.go | 7 +++--- internal/cli/upgrade.go | 4 ++-- internal/detector/asdf.go | 8 +++---- internal/detector/asdf_test.go | 8 +++---- internal/detector/detector.go | 13 +++++++---- internal/detector/fnm.go | 8 +++---- internal/detector/fnm_test.go | 6 ++--- internal/detector/mise.go | 8 +++---- internal/detector/mise_test.go | 12 +++++----- internal/detector/n.go | 8 +++---- internal/detector/n_test.go | 14 ++++++------ internal/detector/nodenv.go | 8 +++---- internal/detector/nodenv_test.go | 8 +++---- internal/detector/nvm.go | 6 ++--- internal/detector/nvm_test.go | 14 ++++++------ internal/detector/nvm_windows.go | 6 ++--- internal/detector/nvm_windows_test.go | 8 +++---- internal/detector/system_node_test.go | 20 ++++++++--------- internal/detector/volta.go | 6 ++--- internal/detector/volta_test.go | 10 ++++----- 25 files changed, 143 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11df0a8..20a699f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -745,6 +745,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 action tag would silently pull unreviewed code into `release.yml` (which runs with `contents: write` and handles publish secrets). Closes #67. +- `internal/detector` `Manager` interface now takes a + `context.Context` as the first parameter on `ListInstalled` + and `Current`. Both methods were defined without `ctx` + when the interface was first laid out, and the omission + became user-visible when the post-upgrade cleanup feature + (PR #56 / issue #41) added `Current()` and shipped across + all 8 manager implementations: a Ctrl-C mid-`nodeup + upgrade` would still wait for the underlying `manager list` + / `fnm list` / `nvm current` subprocess to finish before + returning, because cancellation had no path into the read + paths. The 8 concrete implementations (`fnm`, `nvm`, + `volta`, `asdf`, `mise`, `n`, `nodenv`, `nvm-windows`) + plumb the new `ctx` into their existing `runShell` / + `runScript` calls (both already ctx-aware via + `internal/platform/shell.go`). The 6 production call-sites + in `internal/cli/{check,list,upgrade,packages}.go` now + pass `cmd.Context()` (or, for the new + `getCurrentVersion(ctx, m)` helper, the caller's ctx) so + the `contextcheck` linter is satisfied. The 3 mock/stub + Manager types (`fakeManager`, `listTestStubManager`, + `stubManager`) take a discarded `_ context.Context` + parameter; their bodies are unchanged since they don't do + real I/O. The 39 test call-sites across the per-manager + `*_test.go` files now pass `t.Context()` (Go 1.24 added + this; the project's `go.mod` is on `go 1.24`). Out of scope + (per the issue title): the remaining `Manager` methods + (`Install`, `Uninstall`, `Use`, `SetDefault`, + `GlobalNpmPrefix`, `Version`) keep their no-ctx signatures + for now — most have a pre-existing cancellation story (the + platform lock + interrupted-upgrade sentinel from #46) + and a follow-up can plumb `ctx` through them when the + call-sites warrant it. Closes #53. ## [0.0.0] - 2024-07-01 diff --git a/internal/cli/check.go b/internal/cli/check.go index b5e3599..297312d 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -118,7 +118,7 @@ func outputCheckJSON(cmd *cobra.Command, lts, current *node.ManifestVersion, ins installedVersions := make([]string, 0) for _, m := range installed.Found { - versions, err := m.ListInstalled() + versions, err := m.ListInstalled(cmd.Context()) if err != nil { continue } @@ -153,7 +153,7 @@ func outputCheckTable(cmd *cobra.Command, lts, current *node.ManifestVersion, in } else { cmd.Println("Installed versions:") for _, m := range installed.Found { - versions, err := m.ListInstalled() + versions, err := m.ListInstalled(cmd.Context()) if err != nil { cmd.Printf(" - %s: [error listing versions]\n", m.Name()) continue diff --git a/internal/cli/cleanup_test.go b/internal/cli/cleanup_test.go index 5bdbed9..7fb5e0d 100644 --- a/internal/cli/cleanup_test.go +++ b/internal/cli/cleanup_test.go @@ -3,6 +3,7 @@ package cli import ( "bufio" "bytes" + "context" "errors" "strings" "testing" @@ -24,15 +25,15 @@ type stubManager struct { 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) { +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(context.Context) ([]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(context.Context) (semver.Version, error) { if s.currentErr != nil { return semver.Version{}, s.currentErr } diff --git a/internal/cli/list.go b/internal/cli/list.go index d58bc5c..dc484b9 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -96,7 +96,7 @@ func runList(cmd *cobra.Command, args []string) error { // (one active Node per machine — typically one manager owns it). var active *string for _, m := range selected { - versions, err := m.ListInstalled() + versions, err := m.ListInstalled(cmd.Context()) entry := installedEntryJSON{Manager: m.Name()} if err != nil { entry.Error = err.Error() @@ -115,7 +115,7 @@ func runList(cmd *cobra.Command, args []string) error { // answers. This is best-effort: managers that don't implement // Current() return an error, which we silently drop. if active == nil { - if cur, cerr := m.Current(); cerr == nil { + if cur, cerr := m.Current(cmd.Context()); cerr == nil { s := cur.String() active = &s } diff --git a/internal/cli/list_test.go b/internal/cli/list_test.go index 5e04102..6d24b86 100644 --- a/internal/cli/list_test.go +++ b/internal/cli/list_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "encoding/json" "strings" "testing" @@ -50,14 +51,14 @@ func (s *listTestStubManager) SetDefault(semver.Version) error { func (s *listTestStubManager) GlobalNpmPrefix(semver.Version) (string, error) { return "", nil } func (s *listTestStubManager) Uninstall(semver.Version) error { return nil } -func (s *listTestStubManager) ListInstalled() ([]semver.Version, error) { +func (s *listTestStubManager) ListInstalled(_ context.Context) ([]semver.Version, error) { if s.installedErr != nil { return nil, s.installedErr } return s.installed, nil } -func (s *listTestStubManager) Current() (semver.Version, error) { +func (s *listTestStubManager) Current(_ context.Context) (semver.Version, error) { if s.currentErr != nil { return semver.Version{}, s.currentErr } diff --git a/internal/cli/packages.go b/internal/cli/packages.go index 93d7e52..1a625f6 100644 --- a/internal/cli/packages.go +++ b/internal/cli/packages.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "strings" @@ -47,7 +48,7 @@ func runSnapshot(cmd *cobra.Command, args []string) error { } m := installed.Found[0] - version, err := getCurrentVersion(m) + version, err := getCurrentVersion(cmd.Context(), m) if err != nil { return fmt.Errorf("get current version: %w", err) } @@ -60,8 +61,8 @@ func runSnapshot(cmd *cobra.Command, args []string) error { return nil } -func getCurrentVersion(m detector.Manager) (semver.Version, error) { - versions, err := m.ListInstalled() +func getCurrentVersion(ctx context.Context, m detector.Manager) (semver.Version, error) { + versions, err := m.ListInstalled(ctx) if err != nil { return semver.Version{}, err } diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 9aa052a..babbfaa 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -166,7 +166,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error { } // Get installed versions - installedVersions, err := m.ListInstalled() + installedVersions, err := m.ListInstalled(cmd.Context()) if err != nil { return fmt.Errorf("list installed versions: %w", err) } @@ -331,7 +331,7 @@ func runUpgrade(cmd *cobra.Command, args []string) error { // why the prompt is now per-version. See #58. var active semver.Version var currentErr error - if cur, cerr := m.Current(); cerr == nil { + if cur, cerr := m.Current(cmd.Context()); cerr == nil { active = cur } else { currentErr = cerr diff --git a/internal/detector/asdf.go b/internal/detector/asdf.go index 79fb516..fffed1f 100644 --- a/internal/detector/asdf.go +++ b/internal/detector/asdf.go @@ -168,8 +168,8 @@ func parseASDFVersion(stdout string) (string, error) { // // ASDF does NOT have an nvm-style "system" sentinel — we don't filter // for one. -func (a *ASDF) ListInstalled() ([]semver.Version, error) { - res, err := runShell(context.Background(), "asdf", "list", "nodejs") +func (a *ASDF) ListInstalled(ctx context.Context) ([]semver.Version, error) { + res, err := runShell(ctx, "asdf", "list", "nodejs") if err != nil { return nil, fmt.Errorf("asdf list nodejs: %w", err) } @@ -333,8 +333,8 @@ func (a *ASDF) GlobalNpmPrefix(ver semver.Version) (string, error) { // 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") +func (a *ASDF) Current(ctx context.Context) (semver.Version, error) { + res, err := runShell(ctx, "asdf", "current", "nodejs") if err != nil { return semver.Version{}, fmt.Errorf("asdf current nodejs: %w", err) } diff --git a/internal/detector/asdf_test.go b/internal/detector/asdf_test.go index 3094a94..4170d9e 100644 --- a/internal/detector/asdf_test.go +++ b/internal/detector/asdf_test.go @@ -264,7 +264,7 @@ func TestASDF_ListInstalled_Success(t *testing.T) { }, ) - got, err := NewASDF().ListInstalled() + got, err := NewASDF().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -295,7 +295,7 @@ func TestASDF_ListInstalled_EmptyStdout(t *testing.T) { return &platform.RunResult{Stdout: ""}, nil }) - got, err := NewASDF().ListInstalled() + got, err := NewASDF().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -313,7 +313,7 @@ func TestASDF_ListInstalled_RunShellError(t *testing.T) { return nil, wantErr }) - _, err := NewASDF().ListInstalled() + _, err := NewASDF().ListInstalled(t.Context()) if err == nil { t.Fatal("expected error, got nil") } @@ -519,7 +519,7 @@ func TestASDFCurrent_InvokesShell(t *testing.T) { }, ) - got, err := NewASDF().Current() + got, err := NewASDF().Current(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 596010d..04f09a6 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -19,6 +19,7 @@ package detector import ( + "context" "errors" "fmt" "sort" @@ -47,8 +48,11 @@ type Manager interface { Version() (string, error) // ListInstalled returns every Node.js version the manager currently - // has installed, sorted ascending. - ListInstalled() ([]semver.Version, error) + // has installed, sorted ascending. Implementations should respect + // ctx cancellation so a Ctrl-C during `nodeup upgrade` propagates + // into the underlying `manager list` subprocess. The CLI layer + // passes cmd.Context(). + ListInstalled(ctx context.Context) ([]semver.Version, error) // Install downloads and installs the given Node.js version. Install(v semver.Version) error @@ -73,8 +77,9 @@ type Manager interface { // 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) + // and proceed without excluding it. Implementations should respect + // ctx cancellation; the CLI layer passes cmd.Context(). + Current(ctx context.Context) (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 831681b..c4ef6bc 100644 --- a/internal/detector/fnm.go +++ b/internal/detector/fnm.go @@ -101,8 +101,8 @@ func parseFNMVersion(stdout string) (string, error) { // it. The literal "system" line (which represents the system Node, not // an fnm-managed install) is excluded — nodeup only cares about versions // fnm actually manages. -func (f *FNM) ListInstalled() ([]semver.Version, error) { - res, err := runShell(context.Background(), "fnm", "list") +func (f *FNM) ListInstalled(ctx context.Context) ([]semver.Version, error) { + res, err := runShell(ctx, "fnm", "list") if err != nil { return nil, fmt.Errorf("fnm list: %w", err) } @@ -304,8 +304,8 @@ func fnmDir() string { // 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") +func (f *FNM) Current(ctx context.Context) (semver.Version, error) { + res, err := runShell(ctx, "fnm", "current") if err != nil { return semver.Version{}, fmt.Errorf("fnm current: %w", err) } diff --git a/internal/detector/fnm_test.go b/internal/detector/fnm_test.go index 287ad3c..3018d22 100644 --- a/internal/detector/fnm_test.go +++ b/internal/detector/fnm_test.go @@ -217,7 +217,7 @@ func TestFNM_ListInstalled_Success(t *testing.T) { }, ) - got, err := NewFNM().ListInstalled() + got, err := NewFNM().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -238,7 +238,7 @@ func TestFNM_ListInstalled_RunShellError(t *testing.T) { return nil, wantErr }) - _, err := NewFNM().ListInstalled() + _, err := NewFNM().ListInstalled(t.Context()) if err == nil { t.Fatal("expected error, got nil") } @@ -354,7 +354,7 @@ func TestFNMCurrent_InvokesShell(t *testing.T) { }, ) - got, err := NewFNM().Current() + got, err := NewFNM().Current(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/detector/mise.go b/internal/detector/mise.go index c34192b..bc9f860 100644 --- a/internal/detector/mise.go +++ b/internal/detector/mise.go @@ -197,8 +197,8 @@ func parseMiseVersion(stdout string) (string, error) { // // Note: mise does not have an nvm-style "system" sentinel — we // don't filter for one. -func (m *Mise) ListInstalled() ([]semver.Version, error) { - res, err := runShell(context.Background(), "mise", "ls", "--installed", "--json", "node") +func (m *Mise) ListInstalled(ctx context.Context) ([]semver.Version, error) { + res, err := runShell(ctx, "mise", "ls", "--installed", "--json", "node") if err != nil { return nil, fmt.Errorf("mise ls --installed --json node: %w", err) } @@ -370,8 +370,8 @@ func miseDataDir() string { // 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") +func (m *Mise) Current(ctx context.Context) (semver.Version, error) { + res, err := runShell(ctx, "mise", "current", "node") if err != nil { return semver.Version{}, fmt.Errorf("mise current node: %w", err) } diff --git a/internal/detector/mise_test.go b/internal/detector/mise_test.go index 9de2bbe..c4bc6db 100644 --- a/internal/detector/mise_test.go +++ b/internal/detector/mise_test.go @@ -368,7 +368,7 @@ func TestMise_ListInstalled_Success(t *testing.T) { }, ) - got, err := NewMise().ListInstalled() + got, err := NewMise().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -399,7 +399,7 @@ func TestMise_ListInstalled_EmptyArray(t *testing.T) { return &platform.RunResult{Stdout: "[]"}, nil }) - got, err := NewMise().ListInstalled() + got, err := NewMise().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -418,7 +418,7 @@ func TestMise_ListInstalled_EmptyStdout(t *testing.T) { return &platform.RunResult{Stdout: ""}, nil }) - got, err := NewMise().ListInstalled() + got, err := NewMise().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -436,7 +436,7 @@ func TestMise_ListInstalled_RunShellError(t *testing.T) { return nil, wantErr }) - _, err := NewMise().ListInstalled() + _, err := NewMise().ListInstalled(t.Context()) if err == nil { t.Fatal("expected error, got nil") } @@ -451,7 +451,7 @@ func TestMise_ListInstalled_JSONParseError(t *testing.T) { return &platform.RunResult{Stdout: "{not json"}, nil }) - _, err := NewMise().ListInstalled() + _, err := NewMise().ListInstalled(t.Context()) if err == nil { t.Error("expected JSON parse error, got nil") } @@ -629,7 +629,7 @@ func TestMiseCurrent_InvokesShell(t *testing.T) { }, ) - got, err := NewMise().Current() + got, err := NewMise().Current(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/detector/n.go b/internal/detector/n.go index c359c29..95f29d0 100644 --- a/internal/detector/n.go +++ b/internal/detector/n.go @@ -168,8 +168,8 @@ func parseNVersion(stdout string) (string, error) { // // Note: n does not have a "system" sentinel — we don't filter // for one. -func (n *N) ListInstalled() ([]semver.Version, error) { - res, err := runShell(context.Background(), "n", "ls") +func (n *N) ListInstalled(ctx context.Context) ([]semver.Version, error) { + res, err := runShell(ctx, "n", "ls") if err != nil { return nil, fmt.Errorf("n ls: %w", err) } @@ -365,7 +365,7 @@ func nPrefix() string { // fails, we return an error — the caller treats that as // "active version unknown, don't exclude it" (the safe-by- // convention default per the Manager interface doc). -func (n *N) Current() (semver.Version, error) { +func (n *N) Current(ctx context.Context) (semver.Version, error) { prefix := nPrefix() if prefix == "" { return semver.Version{}, errors.New("n: cannot resolve N_PREFIX or /usr/local") @@ -376,7 +376,7 @@ func (n *N) Current() (semver.Version, error) { // (the runShell error message will already name the missing // path), rather than paying for a stat that we'd race // against a concurrent uninstall. - res, err := runShell(context.Background(), nodeBin, "--version") + res, err := runShell(ctx, nodeBin, "--version") if err != nil { return semver.Version{}, fmt.Errorf("n current: %s --version: %w", nodeBin, err) } diff --git a/internal/detector/n_test.go b/internal/detector/n_test.go index f20eeab..76ebea7 100644 --- a/internal/detector/n_test.go +++ b/internal/detector/n_test.go @@ -310,7 +310,7 @@ func TestN_ListInstalled_Success(t *testing.T) { }, ) - got, err := NewN().ListInstalled() + got, err := NewN().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -341,7 +341,7 @@ func TestN_ListInstalled_EmptyStdout(t *testing.T) { return &platform.RunResult{Stdout: ""}, nil }) - got, err := NewN().ListInstalled() + got, err := NewN().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -359,7 +359,7 @@ func TestN_ListInstalled_RunShellError(t *testing.T) { return nil, wantErr }) - _, err := NewN().ListInstalled() + _, err := NewN().ListInstalled(t.Context()) if err == nil { t.Fatal("expected error, got nil") } @@ -375,7 +375,7 @@ func TestN_ListInstalled_SkipsUnparseableLines(t *testing.T) { return &platform.RunResult{Stdout: "node/20.11.1\nnode/latest\nnode/22.5.0\n"}, nil }) - got, err := NewN().ListInstalled() + got, err := NewN().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -615,7 +615,7 @@ func TestN_Current_InvokesNodeVersionNotNCurrent(t *testing.T) { }, ) - got, err := NewN().Current() + got, err := NewN().Current(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -643,7 +643,7 @@ func TestN_Current_PropagatesRunShellError(t *testing.T) { return nil, wantErr }) - _, err := NewN().Current() + _, err := NewN().Current(t.Context()) if err == nil { t.Fatal("expected error, got nil") } @@ -659,7 +659,7 @@ func TestN_Current_PropagatesParseError(t *testing.T) { return &platform.RunResult{Stdout: ""}, nil }) - _, err := NewN().Current() + _, err := NewN().Current(t.Context()) if err == nil { t.Error("expected parsing error from blank --version output, got nil") } diff --git a/internal/detector/nodenv.go b/internal/detector/nodenv.go index eea4487..7eec9d8 100644 --- a/internal/detector/nodenv.go +++ b/internal/detector/nodenv.go @@ -217,8 +217,8 @@ func parseNodenvVersion(stdout string) (string, error) { // a non-zero exit becomes an error from ListInstalled rather than a // silent empty slice. (This matches asdf's behavior with an empty // install.) -func (nd *Nodenv) ListInstalled() ([]semver.Version, error) { - res, err := runShell(context.Background(), "nodenv", "versions") +func (nd *Nodenv) ListInstalled(ctx context.Context) ([]semver.Version, error) { + res, err := runShell(ctx, "nodenv", "versions") if err != nil { return nil, fmt.Errorf("nodenv versions: %w", err) } @@ -384,8 +384,8 @@ func (nd *Nodenv) GlobalNpmPrefix(ver semver.Version) (string, error) { // "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") +func (nd *Nodenv) Current(ctx context.Context) (semver.Version, error) { + res, err := runShell(ctx, "nodenv", "version") if err != nil { return semver.Version{}, fmt.Errorf("nodenv version: %w", err) } diff --git a/internal/detector/nodenv_test.go b/internal/detector/nodenv_test.go index 46bc8e1..c754bdd 100644 --- a/internal/detector/nodenv_test.go +++ b/internal/detector/nodenv_test.go @@ -308,7 +308,7 @@ func TestNodenv_ListInstalled_Success(t *testing.T) { }, ) - got, err := NewNodenv().ListInstalled() + got, err := NewNodenv().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -340,7 +340,7 @@ func TestNodenv_ListInstalled_EmptyStdout(t *testing.T) { return &platform.RunResult{Stdout: ""}, nil }, ) - got, err := NewNodenv().ListInstalled() + got, err := NewNodenv().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -365,7 +365,7 @@ func TestNodenv_ListInstalled_RunShellError(t *testing.T) { }, ) - _, err := NewNodenv().ListInstalled() + _, err := NewNodenv().ListInstalled(t.Context()) if err == nil { t.Fatal("expected error from runShell failure, got nil") } @@ -611,7 +611,7 @@ func TestNodenvCurrent_InvokesShell(t *testing.T) { }, ) - got, err := NewNodenv().Current() + got, err := NewNodenv().Current(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/detector/nvm.go b/internal/detector/nvm.go index 9f1b7ba..ea4fbda 100644 --- a/internal/detector/nvm.go +++ b/internal/detector/nvm.go @@ -175,7 +175,7 @@ func parseNVMVersion(stdout string) (string, error) { // Non-directory entries (e.g. the "lts" symlink that some nvm versions // drop here) are skipped. The literal name "system" is a sentinel for // the system Node and not a managed install, so we skip it too. -func (n *NVM) ListInstalled() ([]semver.Version, error) { +func (n *NVM) ListInstalled(ctx context.Context) ([]semver.Version, error) { dir := nvmDir() if dir == "" { return nil, errors.New("nvm not detected: cannot resolve NVM_DIR or ~/.nvm") @@ -356,12 +356,12 @@ func (n *NVM) GlobalNpmPrefix(v semver.Version) (string, error) { // 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) { +func (n *NVM) Current(ctx context.Context) (semver.Version, error) { script, err := nvmScriptInvocation("current") if err != nil { return semver.Version{}, err } - res, err := runScript(context.Background(), script) + res, err := runScript(ctx, script) if err != nil { return semver.Version{}, fmt.Errorf("nvm current: %w", err) } diff --git a/internal/detector/nvm_test.go b/internal/detector/nvm_test.go index 69514c7..c7d1ffa 100644 --- a/internal/detector/nvm_test.go +++ b/internal/detector/nvm_test.go @@ -422,7 +422,7 @@ func TestNVM_ListInstalled_HappyPath(t *testing.T) { fakeEntry{name: "v19.6.0", isDir: true}, }, nil) - got, err := NewNVM().ListInstalled() + got, err := NewNVM().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -443,7 +443,7 @@ func TestNVM_ListInstalled_NotFoundReturnsEmpty(t *testing.T) { // os.ErrNotExist from the stub. withStubListDir(t, nil, os.ErrNotExist) - got, err := NewNVM().ListInstalled() + got, err := NewNVM().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -459,7 +459,7 @@ func TestNVM_ListInstalled_PropagatesOtherError(t *testing.T) { withStubNVMScript(t) withStubListDir(t, nil, errSentinelForTest) - _, err := NewNVM().ListInstalled() + _, err := NewNVM().ListInstalled(t.Context()) if err == nil { t.Fatal("expected error when listDir fails with non-NotExist") } @@ -474,7 +474,7 @@ func TestNVM_ListInstalled_NoNVMDir(t *testing.T) { } else { t.Setenv("HOME", "") } - _, err := NewNVM().ListInstalled() + _, err := NewNVM().ListInstalled(t.Context()) if err == nil { t.Error("expected error when NVM_DIR and HOME both empty") } @@ -602,7 +602,7 @@ func TestNVMCurrent_InvokesShell(t *testing.T) { withStubNVMScript(t) rec := withStubScript(t, "v22.11.0\n", nil) - got, err := NewNVM().Current() + got, err := NewNVM().Current(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -626,7 +626,7 @@ func TestNVMCurrent_PropagatesShellError(t *testing.T) { withStubNVMScript(t) withStubScript(t, "", errSentinelForTest) - _, err := NewNVM().Current() + _, err := NewNVM().Current(t.Context()) if err == nil { t.Fatal("expected error, got nil") } @@ -645,7 +645,7 @@ func TestNVMCurrent_SystemIsError(t *testing.T) { withStubNVMScript(t) withStubScript(t, "system\n", nil) - _, err := NewNVM().Current() + _, err := NewNVM().Current(t.Context()) if err == nil { t.Error("expected error when `nvm current` returns 'system'") } diff --git a/internal/detector/nvm_windows.go b/internal/detector/nvm_windows.go index 9c3289d..37420f5 100644 --- a/internal/detector/nvm_windows.go +++ b/internal/detector/nvm_windows.go @@ -207,8 +207,8 @@ func parseNVMWindowsVersion(stdout string) (string, error) { // // There is no "system" sentinel line (unlike nodenv) — nvm-windows // is exclusively a managed install. -func (n *NVMWindows) ListInstalled() ([]semver.Version, error) { - res, err := runShell(context.Background(), "nvm", "list") +func (n *NVMWindows) ListInstalled(ctx context.Context) ([]semver.Version, error) { + res, err := runShell(ctx, "nvm", "list") if err != nil { return nil, fmt.Errorf("nvm list: %w", err) } @@ -327,6 +327,6 @@ func (n *NVMWindows) GlobalNpmPrefix(ver semver.Version) (string, error) { // 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) { +func (n *NVMWindows) Current(_ context.Context) (semver.Version, error) { return semver.Version{}, ErrNVMWindowsNotImplemented } diff --git a/internal/detector/nvm_windows_test.go b/internal/detector/nvm_windows_test.go index 4772186..05dd420 100644 --- a/internal/detector/nvm_windows_test.go +++ b/internal/detector/nvm_windows_test.go @@ -393,7 +393,7 @@ func TestNVMWindows_ListInstalled_Success(t *testing.T) { }, ) - got, err := NewNVMWindows().ListInstalled() + got, err := NewNVMWindows().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -423,7 +423,7 @@ func TestNVMWindows_ListInstalled_EmptyStdout(t *testing.T) { return &platform.RunResult{Stdout: ""}, nil }, ) - got, err := NewNVMWindows().ListInstalled() + got, err := NewNVMWindows().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -446,7 +446,7 @@ func TestNVMWindows_ListInstalled_RunShellError(t *testing.T) { }, ) - _, err := NewNVMWindows().ListInstalled() + _, err := NewNVMWindows().ListInstalled(t.Context()) if err == nil { t.Fatal("expected error from runShell failure, got nil") } @@ -651,7 +651,7 @@ func TestNVMWindows_CurrentReturnsSentinel(t *testing.T) { // 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() + _, err := NewNVMWindows().Current(t.Context()) if !errors.Is(err, ErrNVMWindowsNotImplemented) { t.Errorf("Current() err = %v, want ErrNVMWindowsNotImplemented", err) } diff --git a/internal/detector/system_node_test.go b/internal/detector/system_node_test.go index 25e61aa..fcd9d25 100644 --- a/internal/detector/system_node_test.go +++ b/internal/detector/system_node_test.go @@ -599,16 +599,16 @@ func TestWarnSystemNode_WritesToWriter(t *testing.T) { // return zero values. type fakeManager struct{ name string } -func (f fakeManager) Name() string { return f.name } -func (f fakeManager) Detect() bool { return true } -func (f fakeManager) Version() (string, error) { return "0.0.0-test", nil } -func (f fakeManager) ListInstalled() ([]semver.Version, error) { return nil, nil } -func (f fakeManager) Install(semver.Version) error { return nil } -func (f fakeManager) Uninstall(semver.Version) error { return nil } -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 (f fakeManager) Name() string { return f.name } +func (f fakeManager) Detect() bool { return true } +func (f fakeManager) Version() (string, error) { return "0.0.0-test", nil } +func (f fakeManager) ListInstalled(context.Context) ([]semver.Version, error) { return nil, nil } +func (f fakeManager) Install(semver.Version) error { return nil } +func (f fakeManager) Uninstall(semver.Version) error { return nil } +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(context.Context) (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 c04ca2a..179902e 100644 --- a/internal/detector/volta.go +++ b/internal/detector/volta.go @@ -195,7 +195,7 @@ func parseVoltaVersion(stdout string) (string, error) { // Non-directory entries (which Volta doesn't currently emit, but we // guard against) are skipped. Volta does NOT have an nvm-style // "system" sentinel, so no special-case is needed. -func (v *Volta) ListInstalled() ([]semver.Version, error) { +func (v *Volta) ListInstalled(ctx context.Context) ([]semver.Version, error) { home := voltaHome() if home == "" { return nil, errors.New("volta not detected: cannot resolve VOLTA_HOME or ~/.volta") @@ -352,8 +352,8 @@ func (v *Volta) GlobalNpmPrefix(ver semver.Version) (string, error) { // 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") +func (v *Volta) Current(ctx context.Context) (semver.Version, error) { + res, err := runShell(ctx, "volta", "list", "--format=plain") if err != nil { return semver.Version{}, fmt.Errorf("volta list: %w", err) } diff --git a/internal/detector/volta_test.go b/internal/detector/volta_test.go index cfac270..7b5c6ef 100644 --- a/internal/detector/volta_test.go +++ b/internal/detector/volta_test.go @@ -330,7 +330,7 @@ func TestVolta_ListInstalled_Success(t *testing.T) { } withStubListDir(t, entries, nil) - got, err := NewVolta().ListInstalled() + got, err := NewVolta().ListInstalled(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -355,7 +355,7 @@ func TestVolta_ListInstalled_NoImageDir(t *testing.T) { // Stub listDir to simulate ENOENT (real path doesn't exist). withStubListDir(t, nil, os.ErrNotExist) - got, err := NewVolta().ListInstalled() + got, err := NewVolta().ListInstalled(t.Context()) if err != nil { t.Fatalf("expected nil error for missing image dir, got %v", err) } @@ -383,7 +383,7 @@ func TestVolta_ListInstalled_ReadsExpectedPath(t *testing.T) { } t.Cleanup(func() { listDir = orig }) - if _, err := NewVolta().ListInstalled(); err != nil { + if _, err := NewVolta().ListInstalled(t.Context()); err != nil { t.Fatalf("unexpected error: %v", err) } want := filepath.Join(home, "tools", "image", "node") @@ -401,7 +401,7 @@ func TestVolta_ListInstalled_ListDirError(t *testing.T) { wantErr := errors.New("simulated readdir failure") withStubListDir(t, nil, wantErr) - _, err := NewVolta().ListInstalled() + _, err := NewVolta().ListInstalled(t.Context()) if err == nil { t.Fatal("expected error from listDir failure, got nil") } @@ -532,7 +532,7 @@ func TestVoltaCurrent_InvokesShell(t *testing.T) { }, ) - got, err := NewVolta().Current() + got, err := NewVolta().Current(t.Context()) if err != nil { t.Fatalf("unexpected error: %v", err) }