diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 584eb1a..981e7e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,11 @@ on: permissions: contents: read + # commitlint-github-action@v5 reads the PR's commit list via the + # GitHub API; `pull-requests: read` is required for that. Without it + # the action crashes with "Resource not accessible by integration" + # (covered by PR #1's lint failure). + pull-requests: read # Cancel in-progress runs for the same branch / PR when a new commit # is pushed. Saves CI minutes. diff --git a/internal/detector/fnm.go b/internal/detector/fnm.go index 9fe9400..c71d6fd 100644 --- a/internal/detector/fnm.go +++ b/internal/detector/fnm.go @@ -1,17 +1,28 @@ package detector -import "github.com/Masterminds/semver/v3" +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) // FNM is the Fast Node Manager implementation. See nodeup.md §5 for the // detection strategy and the supported command surface. // -// Detection: -// - binary on PATH (`fnm`) -// - FNM_DIR env var -// - ~/.local/share/fnm (Linux), ~/Library/Application Support/fnm (macOS), -// %AppData%\fnm (Windows) +// Phase 1 implements the detection surface only: +// - Detect : cheap PATH probe via exec.LookPath +// - Version : `fnm --version`, parsed to drop the leading "fnm " +// - ListInstalled: `fnm list`, parsed into sorted []semver.Version // -// Implementation status: stub. Real implementation lands in Phase 1. +// Mutation methods (Install, Uninstall, Use, SetDefault, GlobalNpmPrefix) +// return an explicit "not implemented" error so callers can detect them +// at runtime instead of getting a silent zero-value result. type FNM struct{} // NewFNM constructs a fresh fnm detector. Returned by value so each @@ -20,14 +31,139 @@ func NewFNM() *FNM { return &FNM{} } func (f *FNM) Name() string { return "fnm" } -// Stub methods. Phase 1 fills these in. -func (f *FNM) Detect() bool { return false } -func (f *FNM) Version() (string, error) { return "", nil } -func (f *FNM) ListInstalled() ([]semver.Version, error) { return nil, nil } -func (f *FNM) Install(v semver.Version) error { return nil } -func (f *FNM) Uninstall(v semver.Version) error { return nil } -func (f *FNM) Use(v semver.Version) error { return nil } -func (f *FNM) SetDefault(v semver.Version) error { return nil } +// runShell is the package-level seam used by FNM to invoke fnm. Tests +// overwrite it to capture arguments and return canned output without +// spawning a subprocess. Production code never reassigns it. +// +// Signature matches platform.RunShell so a direct assignment works. +var runShell = platform.RunShell + +// ErrFNMNotImplemented is returned by FNM mutation methods that have not +// yet been implemented in Phase 1 (Install, Uninstall, Use, SetDefault, +// GlobalNpmPrefix). Returning this error instead of a zero value lets +// callers distinguish "I haven't done it yet" from "user passed a bad +// version" via errors.Is. +var ErrFNMNotImplemented = errors.New("fnm mutation commands not yet implemented") + +// Detect returns true when an fnm executable can be located on PATH. +// Per the Manager contract, it MUST be cheap — exec.LookPath does a +// directory walk but no subprocess spawn. +func (f *FNM) Detect() bool { + return platform.LookupManagerBinary("fnm") != "" +} + +// Version returns fnm's own version string, e.g. "1.39.0". The binary +// emits "fnm 1.39.0\n" so we drop the leading "fnm " prefix and trim +// surrounding whitespace. +func (f *FNM) Version() (string, error) { + res, err := runShell(context.Background(), "fnm", "--version") + if err != nil { + return "", fmt.Errorf("fnm --version: %w", err) + } + return parseFNMVersion(res.Stdout) +} + +// parseFNMVersion extracts the version token from `fnm --version` output. +// Real observed output: +// +// fnm 1.39.0\n +// +// We accept either "fnm X.Y.Z" or bare "X.Y.Z" (some forks omit the +// program name). +func parseFNMVersion(stdout string) (string, error) { + out := strings.TrimSpace(stdout) + if out == "" { + return "", errors.New("fnm --version returned empty output") + } + fields := strings.Fields(out) + if len(fields) == 0 { + return "", errors.New("fnm --version returned no tokens") + } + // If the first token is literally "fnm" take the next one. + if fields[0] == "fnm" && len(fields) >= 2 { + return strings.TrimSpace(fields[1]), nil + } + // Otherwise assume the whole string is already a version. + return strings.TrimSpace(fields[0]), nil +} + +// ListInstalled returns every Node.js version fnm has installed, sorted +// ascending. Source: `fnm list` which prints lines like: +// +// - v24.15.0 default +// - v25.9.0 +// - system +// +// The leading "* " is a default-marker (not per-line current); we strip +// 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") + if err != nil { + return nil, fmt.Errorf("fnm list: %w", err) + } + return parseFNMInstalled(res.Stdout) +} + +// parseFNMInstalled turns raw `fnm list` output into a sorted-ascending +// []semver.Version. Exported (lowercase) for direct unit testing. +// +// Returns a non-nil empty slice (never nil) when no parseable versions +// are present — callers rely on this for "no versions, but no error" +// semantics (e.g., "fnm installed, nothing managed yet"). +func parseFNMInstalled(stdout string) ([]semver.Version, error) { + versions := make([]semver.Version, 0) + for _, raw := range strings.Split(stdout, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + // Strip the leading "* " default marker if present. + line = strings.TrimPrefix(line, "* ") + line = strings.TrimPrefix(line, "*") + line = strings.TrimSpace(line) + + // The first whitespace-separated token is the version or the + // literal "system". Anything beyond it (e.g. "default", an alias + // name) is metadata we don't need. + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + first := fields[0] + if first == "system" { + continue + } + // fnm emits "v22.11.0" (with v prefix); semver.NewVersion handles + // that, but we normalize to be safe across forks. + v, err := semver.NewVersion(strings.TrimPrefix(first, "v")) + if err != nil { + // Skip unparseable lines rather than aborting the whole list. + continue + } + versions = append(versions, *v) + } + // semver.Collection in v3.5.0 is []*Version (pointers), so a value + // slice doesn't satisfy it. Use sort.Slice with semver.Compare. + sort.Slice(versions, func(i, j int) bool { + return versions[i].Compare(&versions[j]) < 0 + }) + return versions, nil +} + +// --- Mutation stubs ----------------------------------------------------- +// +// Install, Uninstall, Use, SetDefault, and GlobalNpmPrefix return +// 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". + +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 } func (f *FNM) GlobalNpmPrefix(v semver.Version) (string, error) { - return "", nil + return "", ErrFNMNotImplemented } diff --git a/internal/detector/fnm_test.go b/internal/detector/fnm_test.go new file mode 100644 index 0000000..5ab05b3 --- /dev/null +++ b/internal/detector/fnm_test.go @@ -0,0 +1,301 @@ +package detector + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/Masterminds/semver/v3" + + "github.com/dipto0321/nodeup/internal/platform" +) + +// withStubShell temporarily swaps the package-level runShell var so a +// test can intercept fnm invocations without spawning a real subprocess. +// All detector tests use this helper so the swap/restore ceremony is +// in one place. +// +// The args callback receives the (name, args...) tuple fnm.go passes to +// runShell, letting the test assert what command was issued. The reply +// callback returns the canned RunResult. +func withStubShell(t *testing.T, args func(name string, a []string), reply func(req string) (*platform.RunResult, error)) { + t.Helper() + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + if args != nil { + args(name, a) + } + return reply(strings.Join(append([]string{name}, a...), " ")) + } + t.Cleanup(func() { runShell = orig }) +} + +// --- parseFNMVersion ---------------------------------------------------- + +func TestParseFNMVersion_StandardOutput(t *testing.T) { + // Observed on fnm 1.39.0: + // $ fnm --version + // fnm 1.39.0 + got, err := parseFNMVersion("fnm 1.39.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.39.0" { + t.Errorf("got %q, want %q", got, "1.39.0") + } +} + +func TestParseFNMVersion_BareVersion(t *testing.T) { + // Some forks (or very old fnm) drop the "fnm " prefix. + got, err := parseFNMVersion("1.39.0\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.39.0" { + t.Errorf("got %q, want %q", got, "1.39.0") + } +} + +func TestParseFNMVersion_TrailingWhitespace(t *testing.T) { + got, err := parseFNMVersion(" fnm 1.40.2 \n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.40.2" { + t.Errorf("got %q, want %q", got, "1.40.2") + } +} + +func TestParseFNMVersion_Empty(t *testing.T) { + _, err := parseFNMVersion("") + if err == nil { + t.Error("expected error for empty input") + } +} + +// --- parseFNMInstalled -------------------------------------------------- + +func TestParseFNMInstalled_HappyPath(t *testing.T) { + // Observed from a real `fnm list` (note: no header row, * marks + // default, "system" represents the system Node). + input := "* v22.11.0 default\n* v24.15.0\n* system\n" + + got, err := parseFNMInstalled(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := []string{"22.11.0", "24.15.0"} // sorted asc, "system" excluded + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseFNMInstalled_HandlesUnparseableLines(t *testing.T) { + // fnm occasionally emits banner/empty/garbage lines. We must skip + // them silently rather than aborting the entire list. + input := "* v20.0.0\n\nbogus-not-a-version\n* v18.19.0\n \n* system\n" + + got, err := parseFNMInstalled(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"18.19.0", "20.0.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestParseFNMInstalled_Empty(t *testing.T) { + got, err := parseFNMInstalled("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty slice, got %v", got) + } +} + +func TestParseFNMInstalled_OnlySystem(t *testing.T) { + // If fnm is installed but no versions are managed, the only line is + // "* system". Result should be an empty (not nil) slice. + got, err := parseFNMInstalled("* system\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Error("expected non-nil slice, got nil") + } + if len(got) != 0 { + t.Errorf("expected empty slice, got %v", got) + } +} + +// --- FNM method tests --------------------------------------------------- + +func TestFNM_Name(t *testing.T) { + if got := NewFNM().Name(); got != "fnm" { + t.Errorf("Name() = %q, want %q", got, "fnm") + } +} + +func TestFNM_Version_Success(t *testing.T) { + var captured []string + withStubShell(t, + func(name string, a []string) { captured = append(captured, name); captured = append(captured, a...) }, + func(req string) (*platform.RunResult, error) { + if req != "fnm --version" { + t.Errorf("unexpected runShell call: %q", req) + } + return &platform.RunResult{Stdout: "fnm 1.39.0\n"}, nil + }, + ) + + got, err := NewFNM().Version() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.39.0" { + t.Errorf("got %q, want %q", got, "1.39.0") + } + if len(captured) == 0 || captured[0] != "fnm" { + t.Errorf("expected fnm to be invoked, got %v", captured) + } +} + +func TestFNM_Version_RunShellError(t *testing.T) { + wantErr := errors.New("simulated subprocess failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewFNM().Version() + if err == nil { + t.Fatal("expected error, got nil") + } + // Must wrap the underlying error (not replace it) so callers can + // use errors.Is(err, platform.ErrNotFound). + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +func TestFNM_Version_ParsingError(t *testing.T) { + // runShell succeeded but the body is unparseable. + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: "\n \n"}, nil + }) + + _, err := NewFNM().Version() + if err == nil { + t.Error("expected parsing error from blank output, got nil") + } +} + +func TestFNM_ListInstalled_Success(t *testing.T) { + input := "* v20.18.0\n* v22.11.0\n* v24.15.0\n* system\n" + + withStubShell(t, + func(name string, a []string) { + if name != "fnm" || len(a) != 1 || a[0] != "list" { + t.Errorf("expected fnm list, got %s %v", name, a) + } + }, + func(req string) (*platform.RunResult, error) { + return &platform.RunResult{Stdout: input}, nil + }, + ) + + got, err := NewFNM().ListInstalled() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"20.18.0", "22.11.0", "24.15.0"} + if len(got) != len(want) { + t.Fatalf("got %d versions, want %d (%v)", len(got), len(want), got) + } + for i, w := range want { + if got[i].String() != w { + t.Errorf("got[%d] = %s, want %s", i, got[i], w) + } + } +} + +func TestFNM_ListInstalled_RunShellError(t *testing.T) { + wantErr := errors.New("simulated fnm failure") + withStubShell(t, nil, func(req string) (*platform.RunResult, error) { + return nil, wantErr + }) + + _, err := NewFNM().ListInstalled() + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, wantErr) { + t.Errorf("error %v should wrap %v", err, wantErr) + } +} + +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". + 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) + } + if err := f.Uninstall(*v); !errors.Is(err, ErrFNMNotImplemented) { + t.Errorf("Uninstall: got %v, want ErrFNMNotImplemented", err) + } + if err := f.Use(*v); !errors.Is(err, ErrFNMNotImplemented) { + t.Errorf("Use: got %v, want ErrFNMNotImplemented", err) + } + if err := f.SetDefault(*v); !errors.Is(err, ErrFNMNotImplemented) { + t.Errorf("SetDefault: got %v, want ErrFNMNotImplemented", err) + } + if _, err := f.GlobalNpmPrefix(*v); !errors.Is(err, ErrFNMNotImplemented) { + t.Errorf("GlobalNpmPrefix: got %v, want ErrFNMNotImplemented", err) + } +} + +func TestFNM_DetectUsesSoftLookup(t *testing.T) { + // Detect() must NOT call runShell — it's the cheap probe documented + // in the Manager interface contract. We verify this by leaving + // runShell in its real (subprocess-invoking) state, replacing it + // only with a panic. If Detect() never touches runShell, the panic + // never fires. + // + // We can't replace it with a panic-storing helper because the test + // runs in CI where fnm may or may not be installed — so we just + // sanity-check that calling Detect doesn't blow up. The "no + // subprocess" guarantee is verified by the absence of any + // runShell interaction in the implementation. + t.Run("does not panic", func(t *testing.T) { + // Save and force runShell to panic if invoked. + orig := runShell + runShell = func(ctx context.Context, name string, a ...string) (*platform.RunResult, error) { + t.Fatalf("Detect() must not invoke runShell (was called with %s %v)", name, a) + return nil, nil + } + defer func() { runShell = orig }() + + // Should return without calling runShell. + _ = NewFNM().Detect() + }) +}