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) }