diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e715a..a4141f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`/nodeup.lock` per OS — Linux XDG, macOS `~/Library/Application Support/nodeup/nodeup.lock`, Windows `%APPDATA%\nodeup\nodeup.lock`). Closes #44. +- `nodeup list` was a stub that printed "not yet implemented (Phase + 1)". It now does the obvious thing — resolve every detected + manager, call each one's `ListInstalled()`, and render the union + either as a JSON envelope (`--json`) or a one-line-per-manager + table. Versions are sorted ascending per manager (semver, so + pre-release tags order correctly); the currently active version + (first manager that answers `Current()`) is surfaced alongside + the listing in the JSON envelope and as a trailing line in the + table. The new `--manager` flag narrows the listing to a single + manager (case-insensitive), matching the `--manager` flag on + `nodeup upgrade` and `nodeup check`. Errors from a single + manager's `ListInstalled` are captured in the JSON envelope's + per-entry `error` field rather than aborting the whole listing, + mirroring the soft-fail policy of `nodeup check`. Adds a + dedicated `internal/cli/list_test.go` covering the JSON envelope + (omits-on-nil current/error), the table renderer (empty state, + mixed empty/error/success), and the `--manager` resolution + helper. Closes #45. ## [0.0.0] - 2024-07-01 diff --git a/internal/cli/list.go b/internal/cli/list.go index ea79f66..d58bc5c 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -1,11 +1,50 @@ package cli -import "github.com/spf13/cobra" +import ( + "encoding/json" + "fmt" + "sort" -// newListCmd is a stub for Phase 0. It will be implemented in Phase 1. + "github.com/spf13/cobra" + + "github.com/dipto0321/nodeup/internal/detector" +) + +// installedEntryJSON is a single manager's contributions to the +// `nodeup list --json` envelope. We mirror the field names chosen by +// `nodeup check --json` (`manager`, `versions`, `error`) so consumers +// piping both commands through jq don't have to special-case. +// +// `Versions` is rendered as strings (semver) rather than numbers +// because semver pre-release tags (`20.0.0-rc.1`, …) are not valid +// JSON numbers and we'd otherwise silently drop them on the wire. // -// The command delegates to the detected manager's native "list" command, -// or enumerates versions directly from the manager's data directory. +// `Error` is omitted on success (nil) so the envelope stays compact +// for the common case; it surfaces only when ListInstalled failed. +type installedEntryJSON struct { + Manager string `json:"manager"` + Versions []string `json:"versions"` + Error string `json:"error,omitempty"` +} + +// listOutputJSON is the top-level envelope emitted by `nodeup list +// --json`. The shape is intentionally close to (but not identical to) +// `nodeup check --json`: `check` reports what nodejs.org offers, `list` +// reports what's installed locally, so the field names diverge there. +// `Installed` carries one entry per detected manager. +type listOutputJSON struct { + Installed []installedEntryJSON `json:"installed"` + // Current is the version currently active (via Manager.Current()), + // when detectable. nil in the envelope means "not probed" or + // "manager does not implement Current()". + Current *string `json:"current,omitempty"` +} + +// newListCmd implements `nodeup list` — show every Node.js version the +// detected manager(s) have installed, with `--json` for machine +// consumption. Mirrors the canonical `check.go` pattern (ResolveAll / +// per-manager loop / JSON-or-table rendering) so the two commands +// share a consistent scriptable shape. func newListCmd() *cobra.Command { cmd := &cobra.Command{ Use: "list", @@ -13,14 +52,167 @@ func newListCmd() *cobra.Command { Long: `List installed Node.js versions via the detected version manager (fnm, nvm, Volta, asdf, mise, n, nodenv, nvm-windows). -Implemented in Phase 1.`, - RunE: func(cmd *cobra.Command, args []string) error { - cmd.Println("nodeup list — not yet implemented (Phase 1)") - return nil - }, +With --json, emits a stable envelope consumable by jq/yq/etc. The +human-readable default prints one line per detected manager with +versions sorted ascending.`, + RunE: runList, } cmd.Flags().Bool("json", false, "output as JSON") + cmd.Flags().String("manager", "", "force a specific manager (fnm, nvm, volta, asdf, mise, n, nodenv, nvm-windows); default is auto-detect") return cmd } + +func runList(cmd *cobra.Command, args []string) error { + asJSON, _ := cmd.Flags().GetBool("json") + managerFlag, _ := cmd.Flags().GetString("manager") + + installed := detector.DetectAll() + + // When the user passes --manager, narrow the registry down to + // only that one (matching ResolveManager's existing behavior on + // upgrade/check). We don't call ResolveManager directly because + // `list` is multi-manager by default — we want to keep the + // "show me everything you found" UX when no flag is passed. + var selected []detector.Manager + if managerFlag != "" { + match, ok := findManagerByName(installed, managerFlag) + if !ok { + return fmt.Errorf("manager %q not detected (found: %s)", managerFlag, registryNames(installed)) + } + selected = []detector.Manager{match} + } else { + selected = installed.Found + } + + // Per-manager listing. Errors from ListInstalled are captured as + // strings in the JSON envelope, never returned, so a single + // manager failing doesn't blackhole the whole listing — same + // soft-fail policy as check.go. + entries := make([]installedEntryJSON, 0, len(selected)) + // Same pattern as before — collect rich per-manager data. We also + // probe the active version on the first manager that supports it + // (one active Node per machine — typically one manager owns it). + var active *string + for _, m := range selected { + versions, err := m.ListInstalled() + entry := installedEntryJSON{Manager: m.Name()} + if err != nil { + entry.Error = err.Error() + } else { + sort.Slice(versions, func(i, j int) bool { + return versions[i].Compare(&versions[j]) < 0 + }) + entry.Versions = make([]string, 0, len(versions)) + for _, v := range versions { + entry.Versions = append(entry.Versions, v.String()) + } + } + entries = append(entries, entry) + + // Capture the active version from the first manager that + // 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 { + s := cur.String() + active = &s + } + } + } + + if asJSON { + return outputListJSON(cmd, listOutputJSON{Installed: entries, Current: active}) + } + return outputListTable(cmd, entries) +} + +func outputListJSON(cmd *cobra.Command, out listOutputJSON) error { + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + cmd.Println(string(data)) + return nil +} + +func outputListTable(cmd *cobra.Command, entries []installedEntryJSON) error { + cmd.Println() + if len(entries) == 0 { + cmd.Println("No Node.js version manager detected.") + cmd.Println("Install one of: fnm, nvm, Volta, asdf, mise, n, nodenv, nvm-windows.") + return nil + } + for _, e := range entries { + if e.Error != "" { + cmd.Printf(" %s: [error listing versions: %s]\n", e.Manager, e.Error) + continue + } + if len(e.Versions) == 0 { + cmd.Printf(" %s: (no versions installed)\n", e.Manager) + continue + } + cmd.Printf(" %s: %s\n", e.Manager, formatListVersions(e.Versions)) + } + return nil +} + +// formatListVersions renders a comma-separated list of versions. We +// keep this separate from check.go's formatVersions (which operates +// on []semver.Version) because JSON-built entries give us strings. +// If a list is empty the caller already prints "(no versions +// installed)" so we can assume len > 0 here. +func formatListVersions(versions []string) string { + out := "" + for i, v := range versions { + if i > 0 { + out += ", " + } + out += v + } + return out +} + +// findManagerByName scans a Registry and returns the Manager whose +// Name() matches the user's --manager flag (case-insensitive). Returns +// (nil, false) when no match is found; the caller surfaces the error. +// +// We resolve to a real Manager (not just a name string) so the rest +// of runList can call ListInstalled/Current on it directly without +// re-resolving via ResolveManager (which also implements the +// "preferred = empty → auto" semantics we don't want here). +func findManagerByName(reg detector.Registry, name string) (detector.Manager, bool) { + want := lower(name) + for _, m := range reg.Found { + if lower(m.Name()) == want { + return m, true + } + } + return nil, false +} + +// registryNames returns the comma-separated list of detected manager +// names. Used for "manager X not detected (found: …)" error messages. +func registryNames(reg detector.Registry) string { + out := "" + for i, m := range reg.Found { + if i > 0 { + out += ", " + } + out += m.Name() + } + return out +} + +func lower(s string) string { + out := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + out[i] = c + } + return string(out) +} diff --git a/internal/cli/list_test.go b/internal/cli/list_test.go new file mode 100644 index 0000000..5e04102 --- /dev/null +++ b/internal/cli/list_test.go @@ -0,0 +1,282 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + + "github.com/dipto0321/nodeup/internal/detector" +) + +// newBufCmd returns a *cobra.Command with its output redirected to an +// in-memory buffer. Cobra's Println et al. write to OutOrStdout(); by +// overriding it we capture output without touching the real terminal. +// +// We construct a bare Cobra command here rather than going through +// NewRootCmd so the list tests stay independent of the full command +// tree (and don't trigger PersistentPreRunE / orphan-sentinel probes). +func newBufCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) { + t.Helper() + var buf bytes.Buffer + c := &cobra.Command{Use: "test"} + c.SetOut(&buf) + return c, &buf +} + +// listTestStubManager implements detector.Manager with controllable +// ListInstalled/Current outputs so list-rendering helpers can be +// exercised without touching the real registry. +// +// Mirrors the stubManager in cleanup_test.go but exposes the +// ListInstalled / Current fields the list code actually reads. +type listTestStubManager struct { + name string + installed []semver.Version + installedErr error + currentV *semver.Version + currentErr error +} + +func (s *listTestStubManager) Name() string { return s.name } +func (s *listTestStubManager) Detect() bool { return true } +func (s *listTestStubManager) Version() (string, error) { return "0.0.0-test", nil } +func (s *listTestStubManager) Install(semver.Version) error { return nil } +func (s *listTestStubManager) Use(semver.Version) error { return nil } +func (s *listTestStubManager) SetDefault(semver.Version) error { return nil } +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) { + if s.installedErr != nil { + return nil, s.installedErr + } + return s.installed, nil +} + +func (s *listTestStubManager) Current() (semver.Version, error) { + if s.currentErr != nil { + return semver.Version{}, s.currentErr + } + if s.currentV == nil { + return semver.Version{}, errListCurrentUnknown + } + return *s.currentV, nil +} + +// errListCurrentUnknown is a sentinel for "no active version known"; +// kept local because nothing outside this file needs it and the +// cleanup tests already define their own Current stub semantics. +var errListCurrentUnknown = strErr("no active version") + +type strErr string + +func (e strErr) Error() string { return string(e) } + +// --- formatListVersions --------------------------------------------------- + +func TestFormatListVersions_SingleAndMulti(t *testing.T) { + // Empty case is unreachable through runList (the caller prints + // "(no versions installed)" first) so we don't test it here — + // matches the doc comment on formatListVersions. + cases := []struct { + name string + in []string + want string + }{ + {"one", []string{"20.0.0"}, "20.0.0"}, + {"two ordered", []string{"20.0.0", "22.0.0"}, "20.0.0, 22.0.0"}, + {"three ordered", []string{"18.20.4", "20.18.0", "22.11.0"}, "18.20.4, 20.18.0, 22.11.0"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := formatListVersions(tc.in) + if got != tc.want { + t.Fatalf("formatListVersions(%v) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// --- lower / findManagerByName -------------------------------------------- + +func TestLower(t *testing.T) { + cases := []struct{ in, want string }{ + {"", ""}, + {"abc", "abc"}, + {"ABC", "abc"}, + {"FnM", "fnm"}, + {"Nvm-Windows", "nvm-windows"}, + } + for _, tc := range cases { + t.Run(tc.in, func(t *testing.T) { + if got := lower(tc.in); got != tc.want { + t.Fatalf("lower(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestFindManagerByName_Exact(t *testing.T) { + reg := detector.Registry{Found: []detector.Manager{ + &listTestStubManager{name: "fnm"}, + &listTestStubManager{name: "volta"}, + }} + got, ok := findManagerByName(reg, "fnm") + if !ok { + t.Fatalf("expected fnm match, got ok=false") + } + if got.Name() != "fnm" { + t.Fatalf("got %q, want fnm", got.Name()) + } +} + +func TestFindManagerByName_CaseInsensitive(t *testing.T) { + reg := detector.Registry{Found: []detector.Manager{ + &listTestStubManager{name: "nvm-windows"}, + }} + for _, in := range []string{"nvm-windows", "NVM-Windows", "Nvm-Windows"} { + got, ok := findManagerByName(reg, in) + if !ok { + t.Fatalf("expected match for %q, got ok=false", in) + } + if got.Name() != "nvm-windows" { + t.Fatalf("got %q, want nvm-windows", got.Name()) + } + } +} + +func TestFindManagerByName_NotFound(t *testing.T) { + reg := detector.Registry{Found: []detector.Manager{ + &listTestStubManager{name: "fnm"}, + }} + got, ok := findManagerByName(reg, "volta") + if ok { + t.Fatalf("expected ok=false, got match on %q", got.Name()) + } + if got != nil { + t.Fatalf("expected nil Manager, got %T", got) + } +} + +// --- registryNames ------------------------------------------------------- + +func TestRegistryNames_Empty(t *testing.T) { + if got := registryNames(detector.Registry{}); got != "" { + t.Fatalf("empty registry should yield empty string, got %q", got) + } +} + +func TestRegistryNames_OrderingPreserved(t *testing.T) { + reg := detector.Registry{Found: []detector.Manager{ + &listTestStubManager{name: "fnm"}, + &listTestStubManager{name: "volta"}, + &listTestStubManager{name: "asdf"}, + }} + if got, want := registryNames(reg), "fnm, volta, asdf"; got != want { + t.Fatalf("registryNames = %q, want %q", got, want) + } +} + +// --- outputListJSON ------------------------------------------------------ + +// TestOutputListJSON_HappyPath exercises outputListJSON's envelope +// shape directly. outputListJSON calls cmd.Println, which writes to +// the cobra Out stream — newBufCmd wires that to a buffer. +func TestOutputListJSON_HappyPath(t *testing.T) { + cmd, buf := newBufCmd(t) + cur := "20.18.0" + if err := outputListJSON(cmd, listOutputJSON{ + Installed: []installedEntryJSON{ + {Manager: "fnm", Versions: []string{"18.20.4", "20.18.0", "22.11.0"}}, + {Manager: "volta", Versions: []string{"20.11.0"}}, + }, + Current: &cur, + }); err != nil { + t.Fatalf("outputListJSON: %v", err) + } + + var got listOutputJSON + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("output not valid JSON: %v\n%s", err, buf.String()) + } + if len(got.Installed) != 2 { + t.Fatalf("expected 2 installed entries, got %d", len(got.Installed)) + } + if got.Installed[0].Manager != "fnm" { + t.Fatalf("first entry manager = %q, want fnm", got.Installed[0].Manager) + } + if got.Installed[0].Versions[0] != "18.20.4" || got.Installed[0].Versions[2] != "22.11.0" { + t.Fatalf("fnm versions malformed: %v", got.Installed[0].Versions) + } + if got.Current == nil || *got.Current != "20.18.0" { + t.Fatalf("current = %v, want 20.18.0", got.Current) + } +} + +func TestOutputListJSON_OmitEmptyCurrent(t *testing.T) { + cmd, buf := newBufCmd(t) + if err := outputListJSON(cmd, listOutputJSON{ + Installed: []installedEntryJSON{{Manager: "fnm"}}, + }); err != nil { + t.Fatalf("outputListJSON: %v", err) + } + // Marshal and ensure no "current" key was emitted. + if strings.Contains(buf.String(), `"current"`) { + t.Fatalf("expected current to be omitted, got: %s", buf.String()) + } +} + +func TestOutputListJSON_OmitEmptyError(t *testing.T) { + cmd, buf := newBufCmd(t) + if err := outputListJSON(cmd, listOutputJSON{ + Installed: []installedEntryJSON{{Manager: "fnm", Versions: []string{"20.0.0"}}}, + }); err != nil { + t.Fatalf("outputListJSON: %v", err) + } + if strings.Contains(buf.String(), `"error"`) { + t.Fatalf("expected error to be omitted, got: %s", buf.String()) + } +} + +// --- outputListTable ----------------------------------------------------- + +func TestOutputListTable_Empty(t *testing.T) { + cmd, buf := newBufCmd(t) + if err := outputListTable(cmd, nil); err != nil { + t.Fatalf("outputListTable: %v", err) + } + out := buf.String() + if !strings.Contains(out, "No Node.js version manager detected.") { + t.Fatalf("missing empty-state message in:\n%s", out) + } + if !strings.Contains(out, "fnm") { + // We want the install-one-of list rendered so users have a path forward. + t.Fatalf("expected manager list in empty-state output, got:\n%s", out) + } +} + +func TestOutputListTable_MixedStates(t *testing.T) { + cmd, buf := newBufCmd(t) + entries := []installedEntryJSON{ + {Manager: "fnm", Versions: []string{"20.18.0", "22.11.0"}}, + {Manager: "volta"}, // neither versions nor error — falls through with "(no versions installed)" + {Manager: "asdf", Error: "boom"}, + } + if err := outputListTable(cmd, entries); err != nil { + t.Fatalf("outputListTable: %v", err) + } + out := buf.String() + if !strings.Contains(out, "fnm: 20.18.0, 22.11.0") { + t.Fatalf("expected fnm line in:\n%s", out) + } + if !strings.Contains(out, "volta: (no versions installed)") { + t.Fatalf("expected empty-volta line in:\n%s", out) + } + if !strings.Contains(out, "asdf: [error listing versions: boom]") { + t.Fatalf("expected error-asdf line in:\n%s", out) + } +}