diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..08e7563 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,25 @@ +# https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 4 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab +indent_size = 4 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0a4eac7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + commit-message: + prefix: "deps" + labels: + - dependencies + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: "ci" + labels: + - ci + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2b4230..91985b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,23 @@ on: branches: [main] jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + test: name: Test strategy: @@ -16,10 +33,10 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..366252d --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,5 @@ +version: "2" + +formatters: + enable: + - gofmt diff --git a/Makefile b/Makefile index a0bdaaf..497488f 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,8 @@ # make — build the binary # make install — install to /usr/local/bin (or GOBIN) # make test — run tests +# make lint — run golangci-lint +# make cover — run tests and show coverage report # make clean — remove build artefacts BINARY := nvy @@ -13,7 +15,7 @@ LDFLAGS := -s -w -X github.com/trevorphillipscoding/nvy/cmd.Version=$ COVERAGE_THRESHOLD := 65 COVER_PKGS := ./internal/...,./plugins/... -.PHONY: all build install test clean deps cover cover-check +.PHONY: all build install test lint cover cover-check tidy clean deps help all: build @@ -29,6 +31,10 @@ install: test: go test ./... +## lint: run golangci-lint (install: https://github.com/golangci/golangci-lint) +lint: + golangci-lint run ./... + ## cover: run tests and show coverage report cover: go test -coverprofile=coverage.out -coverpkg=$(COVER_PKGS) ./... diff --git a/README.md b/README.md index 8cdafe7..1a2252a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # nvy +[![CI](https://github.com/trevorphillipscoding/nvy/actions/workflows/ci.yml/badge.svg)](https://github.com/trevorphillipscoding/nvy/actions/workflows/ci.yml) [![License](https://img.shields.io/github/license/trevorphillipscoding/nvy)](LICENSE) +[![Go Version](https://img.shields.io/github/go-mod/go-version/trevorphillipscoding/nvy)](go.mod) A minimalist, plugin-driven runtime version manager for macOS and Linux. -Install and switch between multiple versions of language runtimes — Go, Node.js, and more — with a single binary built from scratch in Go. +Install and switch between multiple versions of language runtimes — Go, Node.js, Python, and more — with a single binary built from scratch in Go. One tool to replace pyenv, goenv, nvm, fnm, and the rest. ``` $ nvy install go 1.26.0 @@ -84,6 +86,7 @@ Then restart your terminal. nvy install go 1.26.0 nvy install go@1.26.0 # same thing nvy install node 20.11.1 +nvy install python 3.12 ``` The runtime is installed to `~/.nvy/runtimes///`. @@ -134,18 +137,85 @@ node `*` = global default, `»` = local pin for the current directory. +### Uninstall a version + +```sh +nvy uninstall go 1.21.0 +``` + +If the version being removed is the active global, its shims are cleaned up automatically. + --- ## Supported runtimes -| Tool | Aliases | Platforms | Official source | -| -------- | ------------------- | ------------ | --------------- | -| `go` | `golang` | macOS, Linux | dl.google.com | -| `node` | `nodejs`, `node.js` | macOS, Linux | nodejs.org | -| `python` | `python3`, `py` | macOS, Linux | python.org | +| Tool | Aliases | Platforms | Source | +| -------- | ------------------- | ------------ | -------------------------------- | +| `go` | `golang` | macOS, Linux | dl.google.com | +| `node` | `nodejs`, `node.js` | macOS, Linux | nodejs.org | +| `python` | `python3`, `py` | macOS, Linux | python-build-standalone (GitHub) | Supported architectures: `amd64` (x86-64), `arm64` (Apple Silicon / ARM). +Adding a new runtime is straightforward — see [Contributing](#contributing). + +--- + +## How it works + +nvy uses a **shim-based execution model** with zero subprocess overhead: + +``` +~/.nvy/ +├── runtimes/// # installed runtime trees +├── shims/ # symlinks to the nvy binary +├── state/ +│ ├── global.json # active global versions +│ └── owners.json # binary → tool mapping +└── tmp/ # staging area for installs +``` + +1. `nvy global go 1.22.1` creates `~/.nvy/shims/go` → symlink to the `nvy` binary +2. When you run `go build`, your shell resolves `~/.nvy/shims/go` → the `nvy` binary +3. nvy detects it was invoked as `go` (via `os.Args[0]`), resolves the active version, and calls `syscall.Exec` to replace itself with the real Go binary +4. The real binary runs directly — no wrapper process, no signal forwarding, no overhead + +Version resolution order: **local** `.go-version` file (walking up from cwd) → **global** version → error with setup instructions. + +### Plugin architecture + +Every runtime is a self-contained plugin implementing a three-method interface: + +```go +type Plugin interface { + Name() string + Aliases() []string + Resolve(version, goos, goarch string) (*DownloadSpec, error) +} +``` + +To add a new runtime (e.g., Ruby, Rust, Deno): + +1. Create `plugins//.go` implementing the interface +2. Call `plugins.Register(New())` in the package's `init()` function +3. Add a blank import in `plugins/all/all.go` + +That's it. The core install/global/local/list/uninstall commands work automatically. + +--- + +## Security + +- **HTTPS only** — plain HTTP is rejected; redirects to HTTP are blocked +- **TLS 1.2+** — enforced on all connections +- **SHA-256 verification** — every archive is verified before extraction +- **Zip Slip protection** — archive entries that escape the destination are rejected +- **Decompression bomb protection** — 2 GB per-file limit during extraction +- **Atomic installs** — temp directory + rename ensures no partial state +- **No shell evaluation** — version files are read as plain text, never executed + +See [SECURITY.md](SECURITY.md) for the full security policy and vulnerability reporting. + --- ## Environment variables @@ -153,3 +223,21 @@ Supported architectures: `amd64` (x86-64), `arm64` (Apple Silicon / ARM). | Variable | Default | Description | | --------- | -------- | ------------------------------- | | `NVY_DIR` | `~/.nvy` | Override the nvy home directory | + +--- + +## Contributing + +Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +```sh +make test # run tests +make lint # run linter +make cover-check # verify coverage threshold +``` + +--- + +## License + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..61207d0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in nvy, please report it responsibly. + +**Do not open a public issue.** Instead, email security concerns to the maintainer or use [GitHub's private vulnerability reporting](https://github.com/trevorphillipscoding/nvy/security/advisories/new). + +Please include: + +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (if applicable) + +You should receive a response within 48 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure. + +## Security Model + +nvy is designed with security as a priority: + +- **HTTPS only** — all downloads are performed over HTTPS. Plain HTTP URLs are rejected, and redirects to HTTP are blocked. +- **TLS 1.2+** — the HTTP client enforces a minimum TLS version of 1.2. +- **SHA-256 verification** — every downloaded archive is verified against its published SHA-256 checksum before extraction. Mismatches abort the install. +- **Zip Slip protection** — archive extraction validates that no entry escapes the destination directory, preventing path-traversal attacks. +- **Decompression bomb protection** — individual extracted files are capped at 2 GB to prevent disk exhaustion. +- **Atomic installs** — installations use a temp directory + rename strategy, ensuring the runtime directory is never in a partial state. +- **Symlink validation** — absolute symlinks and symlinks that escape the destination are rejected during extraction. +- **No shell evaluation** — version files are read as plain text; their contents are never executed. + +## Supported Versions + +Security fixes are applied to the latest release only. We recommend always running the most recent version of nvy. + +| Version | Supported | +| ------- | --------- | +| latest | Yes | +| older | No | + +## Dependencies + +nvy has a single external dependency ([cobra](https://github.com/spf13/cobra)) and relies heavily on the Go standard library. We monitor dependencies for known vulnerabilities using `govulncheck` and Dependabot. diff --git a/cmd/global.go b/cmd/global.go index bde56aa..4490aa6 100644 --- a/cmd/global.go +++ b/cmd/global.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "path/filepath" "strings" "github.com/spf13/cobra" @@ -43,9 +44,14 @@ func runGlobal(_ *cobra.Command, args []string) error { } tool = p.Name() // normalise alias - runtimeBinDir := env.RuntimeBinDir(tool, version) + ver, err := resolveInstalledVersion(tool, version) + if err != nil { + return fmt.Errorf("resolving installed version for %s %s: %w", tool, version, err) + } + + runtimeBinDir := env.RuntimeBinDir(tool, ver) if _, statErr := os.Stat(runtimeBinDir); statErr != nil { - return fmt.Errorf("%s %s is not installed — run: nvy install %s %s", tool, version, tool, version) + return fmt.Errorf("%s %s is not installed — run: nvy install %s %s", tool, ver, tool, ver) } // The nvy binary itself acts as the shim. When invoked as "go" or "npm", @@ -65,11 +71,11 @@ func runGlobal(_ *cobra.Command, args []string) error { return fmt.Errorf("creating shims: %w", err) } - if err := state.SetGlobal(tool, version); err != nil { + if err := state.SetGlobal(tool, ver); err != nil { return fmt.Errorf("saving state: %w", err) } - fmt.Printf("now using %s %s\n", tool, version) + fmt.Printf("now using %s %s\n", tool, ver) if len(created) > 0 { fmt.Printf(" binaries: %s\n", strings.Join(created, ", ")) } @@ -107,7 +113,7 @@ func createShims(runtimeBinDir, nvyBinDir, nvyExe, tool string) ([]string, error continue } - dst := nvyBinDir + "/" + e.Name() + dst := filepath.Join(nvyBinDir, e.Name()) _ = os.Remove(dst) // replace any existing symlink or file if err := os.Symlink(nvyExe, dst); err != nil { return nil, fmt.Errorf("creating shim for %s: %w", e.Name(), err) diff --git a/cmd/install.go b/cmd/install.go index c440014..175218f 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -11,7 +11,6 @@ import ( "github.com/trevorphillipscoding/nvy/internal/archive" "github.com/trevorphillipscoding/nvy/internal/env" "github.com/trevorphillipscoding/nvy/internal/fetch" - "github.com/trevorphillipscoding/nvy/internal/version" "github.com/trevorphillipscoding/nvy/plugins" ) @@ -44,23 +43,21 @@ func runInstall(_ *cobra.Command, args []string) error { } tool = p.Name() // normalise alias → canonical name (e.g. "golang" → "go") - spec, err := p.Resolve(rawVer, env.OS(), env.Arch()) + ver, err := resolveInstallVersion(p, rawVer) if err != nil { - return fmt.Errorf("resolving %s %s: %w", tool, rawVer, err) + return fmt.Errorf("resolving version for %s %s: %w", tool, rawVer, err) } - // Use the plugin's resolved version if it resolved a partial input (e.g. "3.12" → "3.12.8"), - // otherwise fall back to standard normalization (e.g. "1.26" → "1.26.0"). - resolvedVer := spec.ResolvedVersion - if resolvedVer == "" { - resolvedVer = version.Normalize(rawVer) + spec, err := p.Resolve(ver, env.OS(), env.Arch()) + if err != nil { + return fmt.Errorf("resolving %s %s: %w", tool, ver, err) } - installDir := env.RuntimeDir(tool, resolvedVer) + installDir := env.RuntimeDir(tool, ver) if _, statErr := os.Stat(installDir); statErr == nil { - fmt.Printf("already installed: %s %s\n", tool, resolvedVer) + fmt.Printf("already installed: %s %s\n", tool, ver) fmt.Printf(" location: %s\n", installDir) - fmt.Printf(" to activate: nvy global %s %s\n", tool, resolvedVer) + fmt.Printf(" to activate: nvy global %s %s\n", tool, ver) return nil } @@ -75,19 +72,19 @@ func runInstall(_ *cobra.Command, args []string) error { if err != nil { return err } - defer os.RemoveAll(tmpDir) // clean up on any exit path + defer func() { _ = os.RemoveAll(tmpDir) }() archivePath := filepath.Join(tmpDir, "archive.tar.gz") // ── Step 1: Download ──────────────────────────────────────────────────── - fmt.Printf("downloading %s %s\n", tool, resolvedVer) + fmt.Printf("downloading %s %s\n", tool, ver) fmt.Printf(" from %s\n", spec.URL) if err := fetch.Download(spec.URL, archivePath); err != nil { return fmt.Errorf("download failed: %w", err) } // ── Step 2: Verify checksum ───────────────────────────────────────────── - sha256, err := resolveChecksum(spec) + sha256, err := fetch.ResolveChecksum(spec.SHA256, spec.ChecksumURL, spec.ChecksumFilename) if err != nil { return fmt.Errorf("fetching checksum: %w", err) } @@ -109,73 +106,7 @@ func runInstall(_ *cobra.Command, args []string) error { return fmt.Errorf("install failed: %w", err) } - fmt.Printf("installed %s %s\n", tool, resolvedVer) - fmt.Printf(" run: nvy global %s %s\n", tool, resolvedVer) + fmt.Printf("installed %s %s\n", tool, ver) + fmt.Printf(" run: nvy global %s %s\n", tool, ver) return nil } - -// resolveChecksum fetches or returns the expected SHA-256 hex string for a download. -// -// Resolution priority: -// 1. spec.SHA256 — pre-known hash (fastest, no network call) -// 2. spec.ChecksumURL — fetch hash from remote; if ChecksumFilename is set, -// parse as a SHASUMS256-style file; otherwise treat the body as a raw hex hash. -func resolveChecksum(spec *plugins.DownloadSpec) (string, error) { - if spec.SHA256 != "" { - return spec.SHA256, nil - } - if spec.ChecksumURL == "" { - return "", fmt.Errorf("plugin provided neither SHA256 nor ChecksumURL") - } - data, err := fetch.FetchBytes(spec.ChecksumURL) - if err != nil { - return "", fmt.Errorf("fetching checksum from %s: %w", spec.ChecksumURL, err) - } - if spec.ChecksumFilename != "" { - return parseHashFile(data, spec.ChecksumFilename) - } - // Plain format: the entire response body is the hex SHA-256. - return strings.TrimSpace(string(data)), nil -} - -// parseHashFile parses a SHASUMS256-style file and returns the hash for filename. -// -// Format (each line): -// -// -func parseHashFile(data []byte, filename string) (string, error) { - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - fields := strings.Fields(line) - if len(fields) >= 2 && fields[1] == filename { - return fields[0], nil - } - } - return "", fmt.Errorf("no checksum found for %q in checksum file", filename) -} - -// parseToolVersion accepts either: -// -// ["go", "1.22.1"] — two separate arguments -// ["go@1.22.1"] — single argument with @ separator -// -// The version is returned trimmed of whitespace and trailing dots -// (e.g. "1.26." → "1.26"). Each plugin normalizes or resolves -// versions in its own Resolve() implementation. -func parseToolVersion(args []string) (tool, ver string, err error) { - clean := func(v string) string { - return strings.TrimRight(strings.TrimSpace(v), ".") - } - if len(args) == 2 { - return strings.TrimSpace(args[0]), clean(args[1]), nil - } - // Single arg must use the tool@version form. - parts := strings.SplitN(args[0], "@", 2) - if len(parts) != 2 || parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("specify a version: nvy install or nvy install @") - } - return strings.TrimSpace(parts[0]), clean(parts[1]), nil -} diff --git a/cmd/list.go b/cmd/list.go index d1610d9..d708b2e 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -3,12 +3,14 @@ package cmd import ( "fmt" "os" + "path/filepath" "sort" "strings" "github.com/spf13/cobra" "github.com/trevorphillipscoding/nvy/internal/env" + "github.com/trevorphillipscoding/nvy/internal/semver" "github.com/trevorphillipscoding/nvy/internal/shim" "github.com/trevorphillipscoding/nvy/internal/state" "github.com/trevorphillipscoding/nvy/plugins" @@ -85,7 +87,7 @@ func runList(_ *cobra.Command, args []string) error { // * = active global // » = local pin for the current directory (from .-version) func printTool(tool string, globals map[string]string, cwd string) error { - toolDir := env.RuntimesDir() + "/" + tool + toolDir := filepath.Join(env.RuntimesDir(), tool) versions, err := listVersions(toolDir) if err != nil { fmt.Printf("%s (none installed)\n", tool) @@ -138,7 +140,7 @@ func listVersions(toolDir string) ([]string, error) { versions = append(versions, e.Name()) } } - sort.Sort(sort.Reverse(sort.StringSlice(versions))) + semver.SortStringsDesc(versions) return versions, nil } diff --git a/cmd/local.go b/cmd/local.go index a57c921..ca2382e 100644 --- a/cmd/local.go +++ b/cmd/local.go @@ -44,23 +44,28 @@ func runLocal(_ *cobra.Command, args []string) error { } tool = p.Name() + ver, err := resolveInstalledVersion(tool, version) + if err != nil { + return fmt.Errorf("resolving installed version for %s %s: %w", tool, version, err) + } + // Verify the requested version is actually installed before pinning it. - installDir := env.RuntimeDir(tool, version) + installDir := env.RuntimeDir(tool, ver) if _, statErr := os.Stat(installDir); statErr != nil { - return fmt.Errorf("%s %s is not installed — run: nvy install %s %s", tool, version, tool, version) + return fmt.Errorf("%s %s is not installed — run: nvy install %s %s", tool, ver, tool, ver) } filename := "." + tool + "-version" - if err := os.WriteFile(filename, []byte(version+"\n"), 0644); err != nil { + if err := os.WriteFile(filename, []byte(ver+"\n"), 0644); err != nil { return fmt.Errorf("writing %s: %w", filename, err) } cwd, _ := os.Getwd() - fmt.Printf("pinned %s %s in %s\n", tool, version, cwd) + fmt.Printf("pinned %s %s in %s\n", tool, ver, cwd) fmt.Printf(" written to %s\n", filename) // Show what the global version is, so the user knows what they're overriding. - if globalV, _ := shim.ResolveVersion(tool); globalV != "" && globalV != version { + if globalV, _ := shim.ResolveVersion(tool); globalV != "" && globalV != ver { fmt.Printf(" (overrides global: %s)\n", globalV) } return nil diff --git a/cmd/parse.go b/cmd/parse.go new file mode 100644 index 0000000..5a4f4cf --- /dev/null +++ b/cmd/parse.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "fmt" + "strings" +) + +// parseToolVersion accepts either: +// +// ["go", "1.22.1"] — two separate arguments +// ["go@1.22.1"] — single argument with @ separator +// +// The version is returned trimmed of whitespace and trailing dots +// (e.g. "1.26." → "1.26"). Each plugin normalizes or resolves +// versions through the shared semantic resolver. +func parseToolVersion(args []string) (tool, ver string, err error) { + clean := func(v string) string { + return strings.TrimRight(strings.TrimSpace(v), ".") + } + if len(args) == 2 { + return strings.TrimSpace(args[0]), clean(args[1]), nil + } + // Single arg must use the tool@version form. + parts := strings.SplitN(args[0], "@", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("specify a version: nvy install or nvy install @") + } + return strings.TrimSpace(parts[0]), clean(parts[1]), nil +} diff --git a/cmd/parse_test.go b/cmd/parse_test.go new file mode 100644 index 0000000..185c686 --- /dev/null +++ b/cmd/parse_test.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "testing" +) + +func TestParseToolVersion_TwoArgs(t *testing.T) { + cases := []struct { + args []string + wantTool string + wantVer string + }{ + {[]string{"go", "1.22.1"}, "go", "1.22.1"}, + {[]string{"node", "20.11.1"}, "node", "20.11.1"}, + {[]string{"python", "3.12.5"}, "python", "3.12.5"}, + {[]string{" go ", " 1.22.1 "}, "go", "1.22.1"}, // whitespace trimmed + {[]string{"go", "1.26."}, "go", "1.26"}, // trailing dot stripped + {[]string{"go", "1.26..."}, "go", "1.26"}, // multiple trailing dots + } + for _, c := range cases { + tool, ver, err := parseToolVersion(c.args) + if err != nil { + t.Errorf("parseToolVersion(%v): unexpected error: %v", c.args, err) + continue + } + if tool != c.wantTool { + t.Errorf("parseToolVersion(%v) tool = %q; want %q", c.args, tool, c.wantTool) + } + if ver != c.wantVer { + t.Errorf("parseToolVersion(%v) ver = %q; want %q", c.args, ver, c.wantVer) + } + } +} + +func TestParseToolVersion_AtSyntax(t *testing.T) { + cases := []struct { + args []string + wantTool string + wantVer string + }{ + {[]string{"go@1.22.1"}, "go", "1.22.1"}, + {[]string{"node@20.11.1"}, "node", "20.11.1"}, + {[]string{"python@3.12.5+20240814"}, "python", "3.12.5+20240814"}, + } + for _, c := range cases { + tool, ver, err := parseToolVersion(c.args) + if err != nil { + t.Errorf("parseToolVersion(%v): unexpected error: %v", c.args, err) + continue + } + if tool != c.wantTool { + t.Errorf("parseToolVersion(%v) tool = %q; want %q", c.args, tool, c.wantTool) + } + if ver != c.wantVer { + t.Errorf("parseToolVersion(%v) ver = %q; want %q", c.args, ver, c.wantVer) + } + } +} + +func TestParseToolVersion_Errors(t *testing.T) { + cases := []struct { + args []string + desc string + }{ + {[]string{"go"}, "missing version"}, + {[]string{"@1.22.1"}, "missing tool"}, + {[]string{"go@"}, "missing version after @"}, + {[]string{""}, "empty string"}, + } + for _, c := range cases { + _, _, err := parseToolVersion(c.args) + if err == nil { + t.Errorf("parseToolVersion(%v) [%s]: expected error, got nil", c.args, c.desc) + } + } +} diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 9ef48ca..b093d35 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -40,6 +40,11 @@ func runUninstall(_ *cobra.Command, args []string) error { } tool = p.Name() // normalise alias + ver, err = resolveInstalledVersion(tool, ver) + if err != nil { + return fmt.Errorf("resolving installed version for %s %s: %w", tool, ver, err) + } + installDir := env.RuntimeDir(tool, ver) if _, err := os.Stat(installDir); err != nil { return fmt.Errorf("%s %s is not installed", tool, ver) diff --git a/cmd/version_resolution.go b/cmd/version_resolution.go new file mode 100644 index 0000000..a7a1cae --- /dev/null +++ b/cmd/version_resolution.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + + "github.com/trevorphillipscoding/nvy/internal/env" + "github.com/trevorphillipscoding/nvy/internal/semver" + "github.com/trevorphillipscoding/nvy/plugins" +) + +func resolveInstallVersion(p plugins.Plugin, requested string) (string, error) { + available, err := p.AvailableVersions(env.OS(), env.Arch()) + if err != nil { + return "", err + } + v, err := semver.Resolve(requested, available) + if err != nil { + return "", err + } + return v, nil +} + +func resolveInstalledVersion(tool, requested string) (string, error) { + installed, err := env.InstalledVersions(tool) + if err != nil { + return "", fmt.Errorf("%s has no installed versions", tool) + } + v, err := semver.Resolve(requested, installed) + if err != nil { + return "", err + } + return v, nil +} diff --git a/cmd/version_resolution_test.go b/cmd/version_resolution_test.go new file mode 100644 index 0000000..8afc0eb --- /dev/null +++ b/cmd/version_resolution_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/trevorphillipscoding/nvy/plugins" +) + +type testPlugin struct { + versions []string +} + +func (p *testPlugin) Name() string { return "test" } +func (p *testPlugin) Aliases() []string { return nil } +func (p *testPlugin) AvailableVersions(_, _ string) ([]string, error) { + return p.versions, nil +} +func (p *testPlugin) Resolve(version, _, _ string) (*plugins.DownloadSpec, error) { + return &plugins.DownloadSpec{URL: "https://example.invalid/" + version}, nil +} + +func TestResolveInstallVersion_UsesSharedResolver(t *testing.T) { + p := &testPlugin{versions: []string{"1.24.4", "1.25.0", "1.25.3"}} + + got, err := resolveInstallVersion(p, "1") + if err != nil { + t.Fatalf("resolveInstallVersion: %v", err) + } + if got != "1.25.3" { + t.Fatalf("got %q, want 1.25.3", got) + } + + got, err = resolveInstallVersion(p, "1.25") + if err != nil { + t.Fatalf("resolveInstallVersion: %v", err) + } + if got != "1.25.3" { + t.Fatalf("got %q, want 1.25.3", got) + } + + got, err = resolveInstallVersion(p, "1.24.4") + if err != nil { + t.Fatalf("resolveInstallVersion: %v", err) + } + if got != "1.24.4" { + t.Fatalf("got %q, want 1.24.4", got) + } +} + +func TestResolveInstalledVersion_UsesSharedResolver(t *testing.T) { + base := t.TempDir() + t.Setenv("NVY_DIR", base) + + for _, v := range []string{"1.24.4", "1.25.0", "1.25.3"} { + if err := os.MkdirAll(filepath.Join(base, "runtimes", "go", v), 0755); err != nil { + t.Fatal(err) + } + } + + got, err := resolveInstalledVersion("go", "1") + if err != nil { + t.Fatalf("resolveInstalledVersion: %v", err) + } + if got != "1.25.3" { + t.Fatalf("got %q, want 1.25.3", got) + } + + if _, err := resolveInstalledVersion("go", "1.24.5"); err == nil { + t.Fatal("expected error for missing exact version") + } +} diff --git a/internal/archive/archive.go b/internal/archive/archive.go index aaf9b76..88de389 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -31,13 +31,13 @@ func ExtractTarGz(src, dest string, stripComponents int) error { if err != nil { return fmt.Errorf("opening archive: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() gz, err := gzip.NewReader(f) if err != nil { return fmt.Errorf("reading gzip stream: %w", err) } - defer gz.Close() + defer func() { _ = gz.Close() }() tr := tar.NewReader(gz) @@ -138,7 +138,7 @@ func extractFile(r io.Reader, dest string, mode os.FileMode) error { if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() limited := &io.LimitedReader{R: r, N: maxFileSize + 1} if _, err := io.Copy(f, limited); err != nil { diff --git a/internal/archive/archive_test.go b/internal/archive/archive_test.go index 3b46486..f41c8b2 100644 --- a/internal/archive/archive_test.go +++ b/internal/archive/archive_test.go @@ -29,8 +29,8 @@ func buildTarGz(t *testing.T, files map[string]string) []byte { t.Fatal(err) } } - tw.Close() - gz.Close() + _ = tw.Close() + _ = gz.Close() return buf.Bytes() } @@ -65,18 +65,24 @@ func TestExtractTarGz_ZipSlipRejected(t *testing.T) { var buf bytes.Buffer gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) - tw.WriteHeader(&tar.Header{ //nolint:errcheck + if err := tw.WriteHeader(&tar.Header{ Name: "prefix/../../../etc/evil", Mode: 0644, Size: 5, Typeflag: tar.TypeReg, - }) - tw.Write([]byte("evil")) //nolint:errcheck - tw.Close() - gz.Close() + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte("evil")); err != nil { + t.Fatal(err) + } + _ = tw.Close() + _ = gz.Close() src := filepath.Join(t.TempDir(), "evil.tar.gz") - os.WriteFile(src, buf.Bytes(), 0600) //nolint:errcheck + if err := os.WriteFile(src, buf.Bytes(), 0600); err != nil { + t.Fatal(err) + } dest := t.TempDir() err := ExtractTarGz(src, dest, 1) diff --git a/internal/env/env.go b/internal/env/env.go index 72a544b..5593431 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -119,11 +119,31 @@ func AtomicInstall(src, dst string) error { // Clean up the old backup asynchronously — not critical. if oldBackup != "" { - go os.RemoveAll(oldBackup) //nolint:errcheck + go func() { _ = os.RemoveAll(oldBackup) }() } return nil } +// InstalledVersions returns installed runtime directory names for a tool. +// These are expected to be exact semantic versions (major.minor.patch). +func InstalledVersions(tool string) ([]string, error) { + dir := filepath.Join(RuntimesDir(), tool) + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + versions := make([]string, 0, len(entries)) + + for _, e := range entries { + if !e.IsDir() { + continue + } + versions = append(versions, e.Name()) + } + return versions, nil +} + func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { diff --git a/internal/env/env_test.go b/internal/env/env_test.go index 5d1b636..c457de9 100644 --- a/internal/env/env_test.go +++ b/internal/env/env_test.go @@ -66,7 +66,7 @@ func TestMkTempDir(t *testing.T) { if err != nil { t.Fatalf("MkTempDir: %v", err) } - defer os.RemoveAll(d1) + defer func() { _ = os.RemoveAll(d1) }() if _, err := os.Stat(d1); err != nil { t.Errorf("temp dir not created: %v", err) @@ -76,7 +76,7 @@ func TestMkTempDir(t *testing.T) { if err != nil { t.Fatalf("MkTempDir second call: %v", err) } - defer os.RemoveAll(d2) + defer func() { _ = os.RemoveAll(d2) }() if d1 == d2 { t.Error("MkTempDir returned same directory twice") @@ -117,13 +117,17 @@ func TestAtomicInstall_Replace(t *testing.T) { if err := os.MkdirAll(dst, 0755); err != nil { t.Fatal(err) } - os.WriteFile(filepath.Join(dst, "old"), []byte("old"), 0644) //nolint:errcheck + if err := os.WriteFile(filepath.Join(dst, "old"), []byte("old"), 0644); err != nil { + t.Fatal(err) + } src := filepath.Join(base, "src") if err := os.MkdirAll(src, 0755); err != nil { t.Fatal(err) } - os.WriteFile(filepath.Join(src, "new"), []byte("new"), 0644) //nolint:errcheck + if err := os.WriteFile(filepath.Join(src, "new"), []byte("new"), 0644); err != nil { + t.Fatal(err) + } if err := env.AtomicInstall(src, dst); err != nil { t.Fatalf("AtomicInstall replace: %v", err) diff --git a/internal/fetch/checksum.go b/internal/fetch/checksum.go new file mode 100644 index 0000000..4438e5b --- /dev/null +++ b/internal/fetch/checksum.go @@ -0,0 +1,51 @@ +package fetch + +import ( + "fmt" + "strings" +) + +// ResolveChecksum returns the expected SHA-256 hex hash for a download. +// +// Resolution priority: +// 1. sha256 — pre-known hash (fastest, no network call) +// 2. checksumURL — fetch hash from remote; if checksumFilename is set, +// parse as a SHASUMS256-style file; otherwise treat the body as a raw hex hash. +func ResolveChecksum(sha256, checksumURL, checksumFilename string) (string, error) { + if sha256 != "" { + return sha256, nil + } + if checksumURL == "" { + return "", fmt.Errorf("neither SHA256 hash nor checksum URL provided") + } + data, err := Bytes(checksumURL) + if err != nil { + return "", fmt.Errorf("fetching checksum from %s: %w", checksumURL, err) + } + if checksumFilename != "" { + return ParseHashFile(data, checksumFilename) + } + // Plain format: the entire response body is the hex SHA-256. + return strings.TrimSpace(string(data)), nil +} + +// ParseHashFile parses a SHASUMS256-style file and returns the hash for filename. +// +// Expected format (each line): +// +// +// +// Lines starting with # and blank lines are skipped. +func ParseHashFile(data []byte, filename string) (string, error) { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == filename { + return fields[0], nil + } + } + return "", fmt.Errorf("no checksum found for %q in checksum file", filename) +} diff --git a/internal/fetch/checksum_test.go b/internal/fetch/checksum_test.go new file mode 100644 index 0000000..11d51ee --- /dev/null +++ b/internal/fetch/checksum_test.go @@ -0,0 +1,101 @@ +package fetch + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestParseHashFile_Found(t *testing.T) { + data := []byte(`# SHA-256 checksums +abc123def456 node-v20.11.1-linux-x64.tar.gz +789abcdef012 node-v20.11.1-darwin-arm64.tar.gz +`) + hash, err := ParseHashFile(data, "node-v20.11.1-linux-x64.tar.gz") + if err != nil { + t.Fatalf("ParseHashFile: %v", err) + } + if hash != "abc123def456" { + t.Errorf("hash = %q; want abc123def456", hash) + } +} + +func TestParseHashFile_NotFound(t *testing.T) { + data := []byte("abc123 file-a.tar.gz\ndef456 file-b.tar.gz\n") + _, err := ParseHashFile(data, "file-c.tar.gz") + if err == nil { + t.Error("expected error for missing filename, got nil") + } +} + +func TestParseHashFile_SkipsBlankAndComments(t *testing.T) { + data := []byte("\n# comment\n\nabc123 target.tar.gz\n") + hash, err := ParseHashFile(data, "target.tar.gz") + if err != nil { + t.Fatalf("ParseHashFile: %v", err) + } + if hash != "abc123" { + t.Errorf("hash = %q; want abc123", hash) + } +} + +func TestResolveChecksum_PreKnownHash(t *testing.T) { + hash, err := ResolveChecksum("deadbeef", "", "") + if err != nil { + t.Fatalf("ResolveChecksum: %v", err) + } + if hash != "deadbeef" { + t.Errorf("hash = %q; want deadbeef", hash) + } +} + +func TestResolveChecksum_NoHashOrURL(t *testing.T) { + _, err := ResolveChecksum("", "", "") + if err == nil { + t.Error("expected error when neither hash nor URL provided, got nil") + } +} + +func TestResolveChecksum_RawHexURL(t *testing.T) { + withTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(" abc123def456 \n")) + }), func(url string) { + hash, err := ResolveChecksum("", url, "") + if err != nil { + t.Fatalf("ResolveChecksum: %v", err) + } + if hash != "abc123def456" { + t.Errorf("hash = %q; want abc123def456", hash) + } + }) +} + +func TestResolveChecksum_SHASUMSFile(t *testing.T) { + withTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("abc123 file-a.tar.gz\ndef456 file-b.tar.gz\n")) + }), func(url string) { + hash, err := ResolveChecksum("", url, "file-b.tar.gz") + if err != nil { + t.Fatalf("ResolveChecksum: %v", err) + } + if hash != "def456" { + t.Errorf("hash = %q; want def456", hash) + } + }) +} + +func TestResolveChecksum_FetchError(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + })) + defer srv.Close() + + orig := httpClient + httpClient = srv.Client() + defer func() { httpClient = orig }() + + _, err := ResolveChecksum("", srv.URL, "") + if err == nil { + t.Error("expected error for 404, got nil") + } +} diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index dd8644a..28e6122 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -49,11 +49,11 @@ func Download(url, destPath string) error { return fmt.Errorf("refusing to download from non-HTTPS URL: %s", url) } - resp, err := httpClient.Get(url) //nolint:noctx + resp, err := httpClient.Get(url) if err != nil { return fmt.Errorf("GET %s: %w", url, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("GET %s: unexpected status %s", url, resp.Status) @@ -63,7 +63,7 @@ func Download(url, destPath string) error { if err != nil { return fmt.Errorf("creating %s: %w", destPath, err) } - defer f.Close() + defer func() { _ = f.Close() }() pw := &progressWriter{total: resp.ContentLength} if _, err := io.Copy(f, io.TeeReader(resp.Body, pw)); err != nil { @@ -73,18 +73,18 @@ func Download(url, destPath string) error { return nil } -// FetchBytes performs a GET request and returns the response body as bytes. +// Bytes performs a GET request and returns the response body as bytes. // Only HTTPS URLs are accepted. -func FetchBytes(url string) ([]byte, error) { +func Bytes(url string) ([]byte, error) { if !strings.HasPrefix(url, "https://") { return nil, fmt.Errorf("refusing non-HTTPS URL: %s", url) } - resp, err := httpClient.Get(url) //nolint:noctx + resp, err := httpClient.Get(url) if err != nil { return nil, fmt.Errorf("GET %s: %w", url, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: unexpected status %s", url, resp.Status) @@ -106,7 +106,7 @@ func VerifySHA256(path, expected string) error { if err != nil { return fmt.Errorf("opening %s for verification: %w", path, err) } - defer f.Close() + defer func() { _ = f.Close() }() h := sha256.New() if _, err := io.Copy(h, f); err != nil { @@ -115,7 +115,7 @@ func VerifySHA256(path, expected string) error { got := hex.EncodeToString(h.Sum(nil)) if got != expected { - return fmt.Errorf("SHA-256 mismatch:\n expected: %s\n got: %s\n\nThe downloaded file may be corrupt or tampered with. Aborting.", expected, got) + return fmt.Errorf("SHA-256 mismatch:\n expected: %s\n got: %s\n\nthe downloaded file may be corrupt or tampered with", expected, got) } return nil } diff --git a/internal/fetch/fetch_test.go b/internal/fetch/fetch_test.go index 5fe19cb..fa17926 100644 --- a/internal/fetch/fetch_test.go +++ b/internal/fetch/fetch_test.go @@ -64,15 +64,15 @@ func TestDownload_RejectsHTTP(t *testing.T) { } } -func TestFetchBytes_RejectsHTTP(t *testing.T) { - if _, err := FetchBytes("http://example.com/checksums"); err == nil { +func TestBytes_RejectsHTTP(t *testing.T) { + if _, err := Bytes("http://example.com/checksums"); err == nil { t.Error("expected error for HTTP URL, got nil") } } func TestDownload_Success(t *testing.T) { - withTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("file content")) //nolint:errcheck + withTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("file content")) }), func(url string) { tmp := filepath.Join(t.TempDir(), "out") if err := Download(url, tmp); err != nil { @@ -96,13 +96,13 @@ func TestDownload_NotFound(t *testing.T) { }) } -func TestFetchBytes_Success(t *testing.T) { - withTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("checksum data")) //nolint:errcheck +func TestBytes_Success(t *testing.T) { + withTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("checksum data")) }), func(url string) { - data, err := FetchBytes(url) + data, err := Bytes(url) if err != nil { - t.Fatalf("FetchBytes: %v", err) + t.Fatalf("Bytes: %v", err) } if string(data) != "checksum data" { t.Errorf("got %q; want %q", data, "checksum data") @@ -110,11 +110,11 @@ func TestFetchBytes_Success(t *testing.T) { }) } -func TestFetchBytes_NotFound(t *testing.T) { +func TestBytes_NotFound(t *testing.T) { withTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }), func(url string) { - if _, err := FetchBytes(url); err == nil { + if _, err := Bytes(url); err == nil { t.Error("expected error for 404, got nil") } }) diff --git a/internal/semver/resolver.go b/internal/semver/resolver.go new file mode 100644 index 0000000..3faab74 --- /dev/null +++ b/internal/semver/resolver.go @@ -0,0 +1,66 @@ +package semver + +import "fmt" + +// Resolve resolves a user version reference against available exact versions. +// Rules: +// - "1" resolves to latest 1.x.x +// - "1.25" resolves to latest 1.25.x +// - "1.25.4" resolves to exact 1.25.4 +func Resolve(request string, available []string) (string, error) { + ref, err := ParseReference(request) + if err != nil { + return "", err + } + + parsed := make([]Version, 0, len(available)) + for _, raw := range available { + v, parseErr := ParseVersion(raw) + if parseErr != nil { + continue + } + parsed = append(parsed, v) + } + + if len(parsed) == 0 { + return "", fmt.Errorf("no versions available") + } + + var best Version + found := false + for _, v := range parsed { + if !matches(ref, v) { + continue + } + if !found || Compare(v, best) > 0 { + best = v + found = true + } + } + + if !found { + switch ref.Parts { + case 1: + return "", fmt.Errorf("no version found for %d.x.x", ref.Major) + case 2: + return "", fmt.Errorf("no version found for %d.%d.x", ref.Major, ref.Minor) + default: + return "", fmt.Errorf("version %d.%d.%d not found", ref.Major, ref.Minor, ref.Patch) + } + } + + return best.String(), nil +} + +func matches(ref Reference, v Version) bool { + if v.Major != ref.Major { + return false + } + if ref.Parts >= 2 && v.Minor != ref.Minor { + return false + } + if ref.Parts == 3 && v.Patch != ref.Patch { + return false + } + return true +} diff --git a/internal/semver/semver.go b/internal/semver/semver.go new file mode 100644 index 0000000..5f92b68 --- /dev/null +++ b/internal/semver/semver.go @@ -0,0 +1,116 @@ +// Package semver provides nvy's single global version semantics. +// +// Supported references: +// - major (e.g. "1") +// - major.minor (e.g. "1.25") +// - major.minor.patch (e.g. "1.25.4") +// +// Build metadata, prerelease tags, and language-specific variants are not +// supported by design to keep version behavior consistent across all runtimes. +package semver + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +// Reference is a requested version with 1 to 3 numeric components. +type Reference struct { + Major int + Minor int + Patch int + Parts int +} + +// Version is a strict three-part semantic version. +type Version struct { + Major int + Minor int + Patch int +} + +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +// ParseReference parses version references in nvy's global format. +func ParseReference(input string) (Reference, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return Reference{}, fmt.Errorf("version is required") + } + parts := strings.Split(trimmed, ".") + if len(parts) < 1 || len(parts) > 3 { + return Reference{}, fmt.Errorf("invalid version %q: expected major, major.minor, or major.minor.patch", input) + } + + vals := [3]int{} + for i, p := range parts { + if p == "" { + return Reference{}, fmt.Errorf("invalid version %q: empty component", input) + } + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return Reference{}, fmt.Errorf("invalid version %q: non-numeric component %q", input, p) + } + vals[i] = n + } + + return Reference{Major: vals[0], Minor: vals[1], Patch: vals[2], Parts: len(parts)}, nil +} + +// ParseVersion parses an exact semantic version (major.minor.patch). +func ParseVersion(input string) (Version, error) { + ref, err := ParseReference(input) + if err != nil { + return Version{}, err + } + if ref.Parts != 3 { + return Version{}, fmt.Errorf("invalid exact version %q: expected major.minor.patch", input) + } + return Version{Major: ref.Major, Minor: ref.Minor, Patch: ref.Patch}, nil +} + +// Compare returns -1 if a < b, 0 if a == b, and 1 if a > b. +func Compare(a, b Version) int { + if a.Major != b.Major { + if a.Major < b.Major { + return -1 + } + return 1 + } + if a.Minor != b.Minor { + if a.Minor < b.Minor { + return -1 + } + return 1 + } + if a.Patch != b.Patch { + if a.Patch < b.Patch { + return -1 + } + return 1 + } + return 0 +} + +// SortStringsDesc sorts exact semantic versions in descending order. +// Invalid versions are kept but always sorted after valid versions. +func SortStringsDesc(versions []string) { + sort.Slice(versions, func(i, j int) bool { + vi, ei := ParseVersion(versions[i]) + vj, ej := ParseVersion(versions[j]) + switch { + case ei == nil && ej == nil: + return Compare(vi, vj) > 0 + case ei == nil: + return true + case ej == nil: + return false + default: + return versions[i] > versions[j] + } + }) +} diff --git a/internal/semver/semver_test.go b/internal/semver/semver_test.go new file mode 100644 index 0000000..5b2f314 --- /dev/null +++ b/internal/semver/semver_test.go @@ -0,0 +1,74 @@ +package semver_test + +import ( + "reflect" + "testing" + + "github.com/trevorphillipscoding/nvy/internal/semver" +) + +func TestResolve(t *testing.T) { + available := []string{"1.24.3", "1.25.0", "1.25.2", "2.0.1"} + + t.Run("major resolves latest patch", func(t *testing.T) { + got, err := semver.Resolve("1", available) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "1.25.2" { + t.Fatalf("got %q, want 1.25.2", got) + } + }) + + t.Run("minor resolves latest patch", func(t *testing.T) { + got, err := semver.Resolve("1.25", available) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "1.25.2" { + t.Fatalf("got %q, want 1.25.2", got) + } + }) + + t.Run("exact resolves exact", func(t *testing.T) { + got, err := semver.Resolve("1.24.3", available) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if got != "1.24.3" { + t.Fatalf("got %q, want 1.24.3", got) + } + }) + + t.Run("nonexistent exact", func(t *testing.T) { + _, err := semver.Resolve("1.24.4", available) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("nonexistent partial", func(t *testing.T) { + _, err := semver.Resolve("3.1", available) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestResolve_InvalidInput(t *testing.T) { + cases := []string{"", "1.", "1..2", "v1.2.3", "1.2.3.4", "1.2.x", "1.2.3+meta"} + for _, c := range cases { + if _, err := semver.Resolve(c, []string{"1.2.3"}); err == nil { + t.Fatalf("Resolve(%q): expected error", c) + } + } +} + +func TestSortStringsDesc(t *testing.T) { + input := []string{"1.2.9", "1.11.0", "2.0.0", "1.2.10"} + semver.SortStringsDesc(input) + want := []string{"2.0.0", "1.11.0", "1.2.10", "1.2.9"} + if !reflect.DeepEqual(input, want) { + t.Fatalf("sorted = %v, want %v", input, want) + } +} diff --git a/internal/shim/shim.go b/internal/shim/shim.go index e248a0d..d1ceedb 100644 --- a/internal/shim/shim.go +++ b/internal/shim/shim.go @@ -25,8 +25,8 @@ import ( "syscall" "github.com/trevorphillipscoding/nvy/internal/env" + "github.com/trevorphillipscoding/nvy/internal/semver" "github.com/trevorphillipscoding/nvy/internal/state" - "github.com/trevorphillipscoding/nvy/internal/version" ) // Run resolves the active version for binary's owning tool, then replaces the @@ -73,13 +73,13 @@ func ResolveVersion(tool string) (string, error) { // 1. Local version file (.-version), walking up from cwd. if cwd, err := os.Getwd(); err == nil { if v := findLocalVersion(tool, cwd); v != "" { - return version.Normalize(v), nil + return resolveToInstalled(tool, v) } } // 2. Global version. if v, ok := state.GetGlobal(tool); ok { - return version.Normalize(v), nil + return resolveToInstalled(tool, v) } return "", fmt.Errorf( @@ -90,6 +90,20 @@ func ResolveVersion(tool string) (string, error) { ) } +// resolveToInstalled resolves local/global configured versions against installed +// versions using the same semantic rules used everywhere else in nvy. +func resolveToInstalled(tool, requested string) (string, error) { + installed, err := env.InstalledVersions(tool) + if err != nil { + return "", fmt.Errorf("%s has no installed versions", tool) + } + v, err := semver.Resolve(strings.TrimSpace(requested), installed) + if err != nil { + return "", fmt.Errorf("resolving installed version for %s %s: %w", tool, requested, err) + } + return v, nil +} + // FindLocalVersion walks up from dir looking for a .-version file. // Returns "" if none is found. Exported for use in cmd/list.go. func FindLocalVersion(tool, dir string) string { diff --git a/internal/shim/shim_test.go b/internal/shim/shim_test.go index df681e5..7bc17d9 100644 --- a/internal/shim/shim_test.go +++ b/internal/shim/shim_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/trevorphillipscoding/nvy/internal/env" "github.com/trevorphillipscoding/nvy/internal/shim" "github.com/trevorphillipscoding/nvy/internal/state" ) @@ -61,6 +62,9 @@ func TestResolveVersion_GlobalVersion(t *testing.T) { t.Setenv("NVY_DIR", t.TempDir()) // Chdir to an isolated temp dir so local version file doesn't interfere. t.Chdir(t.TempDir()) + if err := os.MkdirAll(env.RuntimeDir("go", "1.22.1"), 0755); err != nil { + t.Fatal(err) + } if err := state.SetGlobal("go", "1.22.1"); err != nil { t.Fatal(err) @@ -78,6 +82,9 @@ func TestResolveVersion_GlobalVersion(t *testing.T) { func TestResolveVersion_LocalFile(t *testing.T) { tmp := t.TempDir() t.Setenv("NVY_DIR", tmp) + if err := os.MkdirAll(env.RuntimeDir("go", "1.21.0"), 0755); err != nil { + t.Fatal(err) + } if err := os.WriteFile(filepath.Join(tmp, ".go-version"), []byte("1.21.0"), 0644); err != nil { t.Fatal(err) @@ -97,6 +104,12 @@ func TestResolveVersion_LocalFile(t *testing.T) { func TestResolveVersion_LocalOverridesGlobal(t *testing.T) { tmp := t.TempDir() t.Setenv("NVY_DIR", tmp) + if err := os.MkdirAll(env.RuntimeDir("go", "1.22.1"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(env.RuntimeDir("go", "1.21.0"), 0755); err != nil { + t.Fatal(err) + } // Set global to a different version. if err := state.SetGlobal("go", "1.22.1"); err != nil { @@ -119,3 +132,27 @@ func TestResolveVersion_LocalOverridesGlobal(t *testing.T) { t.Errorf("ResolveVersion = %q; want 1.21.0 (local should override global)", got) } } + +func TestResolveVersion_PartialUsesSameResolver(t *testing.T) { + tmp := t.TempDir() + t.Setenv("NVY_DIR", tmp) + t.Chdir(tmp) + + for _, v := range []string{"1.25.1", "1.25.3", "1.24.9"} { + if err := os.MkdirAll(env.RuntimeDir("go", v), 0755); err != nil { + t.Fatal(err) + } + } + + if err := state.SetGlobal("go", "1.25"); err != nil { + t.Fatal(err) + } + + got, err := shim.ResolveVersion("go") + if err != nil { + t.Fatalf("ResolveVersion: %v", err) + } + if got != "1.25.3" { + t.Fatalf("ResolveVersion = %q; want 1.25.3", got) + } +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 66feb10..f352772 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -39,8 +39,12 @@ func TestSetAndGetGlobal(t *testing.T) { func TestAllGlobals(t *testing.T) { t.Setenv("NVY_DIR", t.TempDir()) - SetGlobal("go", "1.22.1") //nolint:errcheck - SetGlobal("node", "20.0.0") //nolint:errcheck + if err := SetGlobal("go", "1.22.1"); err != nil { + t.Fatal(err) + } + if err := SetGlobal("node", "20.0.0"); err != nil { + t.Fatal(err) + } all, err := AllGlobals() if err != nil { @@ -70,7 +74,9 @@ func TestLoadMissingFile(t *testing.T) { func TestAtomicWrite(t *testing.T) { t.Setenv("NVY_DIR", t.TempDir()) - SetGlobal("go", "1.22.1") //nolint:errcheck + if err := SetGlobal("go", "1.22.1"); err != nil { + t.Fatal(err) + } // Verify the file was written. if _, err := os.Stat(env.GlobalStatePath()); err != nil { @@ -90,8 +96,12 @@ func TestAtomicWrite(t *testing.T) { func TestDeleteGlobal(t *testing.T) { t.Setenv("NVY_DIR", t.TempDir()) - SetGlobal("go", "1.22.1") //nolint:errcheck - SetGlobal("node", "20.0.0") //nolint:errcheck + if err := SetGlobal("go", "1.22.1"); err != nil { + t.Fatal(err) + } + if err := SetGlobal("node", "20.0.0"); err != nil { + t.Fatal(err) + } if err := DeleteGlobal("go"); err != nil { t.Fatalf("DeleteGlobal: %v", err) @@ -144,8 +154,12 @@ func TestRegisterAndLookupShims(t *testing.T) { func TestUnregisterShims(t *testing.T) { t.Setenv("NVY_DIR", t.TempDir()) - RegisterShims("go", []string{"go", "gofmt"}) //nolint:errcheck - RegisterShims("node", []string{"node", "npm"}) //nolint:errcheck + if err := RegisterShims("go", []string{"go", "gofmt"}); err != nil { + t.Fatal(err) + } + if err := RegisterShims("node", []string{"node", "npm"}); err != nil { + t.Fatal(err) + } removed, err := UnregisterShims("go") if err != nil { diff --git a/internal/version/version.go b/internal/version/version.go deleted file mode 100644 index 432d15d..0000000 --- a/internal/version/version.go +++ /dev/null @@ -1,24 +0,0 @@ -// Package version provides shared version-string utilities. -package version - -import "strings" - -// Normalize expands short versions to full major.minor.patch form: -// -// "20" → "20.0.0" -// "1.26" → "1.26.0" -// "3.12.5" → "3.12.5" -// "3.12+20240814" → "3.12.0+20240814" (+tag preserved) -func Normalize(v string) string { - base, tag, hasTag := strings.Cut(v, "+") - switch strings.Count(base, ".") { - case 0: - base += ".0.0" - case 1: - base += ".0" - } - if hasTag { - return base + "+" + tag - } - return base -} diff --git a/internal/version/version_test.go b/internal/version/version_test.go deleted file mode 100644 index a6c49ae..0000000 --- a/internal/version/version_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package version_test - -import ( - "testing" - - "github.com/trevorphillipscoding/nvy/internal/version" -) - -func TestNormalize(t *testing.T) { - cases := []struct { - input string - want string - }{ - {"20", "20.0.0"}, - {"1.26", "1.26.0"}, - {"3.12.5", "3.12.5"}, - {"3.12+20240814", "3.12.0+20240814"}, - {"1.22.1", "1.22.1"}, - {"20.11.1", "20.11.1"}, - {"3", "3.0.0"}, - {"1.0+tag", "1.0.0+tag"}, - } - for _, c := range cases { - got := version.Normalize(c.input) - if got != c.want { - t.Errorf("Normalize(%q) = %q; want %q", c.input, got, c.want) - } - } -} diff --git a/main.go b/main.go index 9f5f4c4..dc09043 100644 --- a/main.go +++ b/main.go @@ -1,3 +1,5 @@ +// nvy is a universal runtime version manager that replaces pyenv, goenv, +// nvm, and similar tools with a single, fast binary. package main import ( diff --git a/plugins/all/all.go b/plugins/all/all.go index cb86ad3..d6ba0b1 100644 --- a/plugins/all/all.go +++ b/plugins/all/all.go @@ -8,7 +8,7 @@ package all import ( - _ "github.com/trevorphillipscoding/nvy/plugins/golang" - _ "github.com/trevorphillipscoding/nvy/plugins/node" - _ "github.com/trevorphillipscoding/nvy/plugins/python" + _ "github.com/trevorphillipscoding/nvy/plugins/golang" // register Go plugin + _ "github.com/trevorphillipscoding/nvy/plugins/node" // register Node.js plugin + _ "github.com/trevorphillipscoding/nvy/plugins/python" // register Python plugin ) diff --git a/plugins/golang/golang.go b/plugins/golang/golang.go index dc24da3..18300b5 100644 --- a/plugins/golang/golang.go +++ b/plugins/golang/golang.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/trevorphillipscoding/nvy/internal/version" + "github.com/trevorphillipscoding/nvy/internal/semver" "github.com/trevorphillipscoding/nvy/plugins" ) @@ -33,6 +33,11 @@ func (g *goPlugin) Name() string { return "go" } func (g *goPlugin) Aliases() []string { return []string{"golang"} } +// AvailableVersions returns stable Go versions as exact semantic versions. +func (g *goPlugin) AvailableVersions(_, _ string) ([]string, error) { + return fetchStableGoVersions() +} + // Resolve builds the download spec for a Go release tarball. // // Official naming convention: @@ -41,12 +46,6 @@ func (g *goPlugin) Aliases() []string { return []string{"golang"} } // go.-.tar.gz.sha256 ← single-line hex SHA-256 // // Example: go1.22.1.linux-amd64.tar.gz -// -// Partial versions (fewer than two dots, no +tag) resolve to the latest -// matching stable release: -// -// "1" → latest 1.x.y -// "1.26" → latest 1.26.x func (g *goPlugin) Resolve(ver, goos, goarch string) (*plugins.DownloadSpec, error) { os, err := normalizeOS(goos) if err != nil { @@ -57,48 +56,35 @@ func (g *goPlugin) Resolve(ver, goos, goarch string) (*plugins.DownloadSpec, err return nil, err } - var resolvedVersion string - base := strings.SplitN(ver, "+", 2)[0] - if strings.Count(base, ".") < 2 { - latest, err := findLatestGoVersion(base) - if err != nil { - return nil, err - } - resolvedVersion = latest - ver = latest - } else { - ver = version.Normalize(ver) + v, err := semver.ParseVersion(ver) + if err != nil { + return nil, fmt.Errorf("go plugin: %w", err) } - + ver = v.String() filename := fmt.Sprintf("go%s.%s-%s.tar.gz", ver, os, arch) url := fmt.Sprintf("%s/%s", downloadBase, filename) return &plugins.DownloadSpec{ - URL: url, - // The .sha256 file contains a single hex-encoded SHA-256 hash, no filename prefix. + URL: url, ChecksumURL: url + ".sha256", ChecksumFilename: "", // plain mode: response body is the raw hex hash StripComponents: 1, // archive has a top-level "go/" directory to strip - ResolvedVersion: resolvedVersion, }, nil } -// findLatestGoVersion returns the latest stable Go release whose version -// starts with prefix (e.g. "1" or "1.26"). The go.dev/dl API returns releases -// newest-first, so the first match is the latest. -func findLatestGoVersion(prefix string) (string, error) { +func fetchStableGoVersions() ([]string, error) { client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(releasesAPI) if err != nil { - return "", fmt.Errorf("go plugin: fetching releases: %w", err) + return nil, fmt.Errorf("go plugin: fetching releases: %w", err) } body, err := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if err != nil { - return "", fmt.Errorf("go plugin: reading releases response: %w", err) + return nil, fmt.Errorf("go plugin: reading releases response: %w", err) } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("go plugin: releases API returned %s", resp.Status) + return nil, fmt.Errorf("go plugin: releases API returned %s", resp.Status) } var releases []struct { @@ -106,18 +92,23 @@ func findLatestGoVersion(prefix string) (string, error) { Stable bool `json:"stable"` } if err := json.Unmarshal(body, &releases); err != nil { - return "", fmt.Errorf("go plugin: parsing releases JSON: %w", err) + return nil, fmt.Errorf("go plugin: parsing releases JSON: %w", err) } - // Versions in the API are "go1.24.1"; add a dot after prefix to avoid - // "go1.2" matching "go1.20.x". - wantPrefix := "go" + prefix + "." + versions := make([]string, 0, len(releases)) for _, r := range releases { - if r.Stable && strings.HasPrefix(r.Version, wantPrefix) { - return strings.TrimPrefix(r.Version, "go"), nil + if !r.Stable { + continue } + v := strings.TrimPrefix(r.Version, "go") + if _, parseErr := semver.ParseVersion(v); parseErr == nil { + versions = append(versions, v) + } + } + if len(versions) == 0 { + return nil, fmt.Errorf("go plugin: no stable semantic versions found") } - return "", fmt.Errorf("go plugin: no stable release found for Go %s.*", prefix) + return versions, nil } func normalizeOS(goos string) (string, error) { diff --git a/plugins/golang/golang_test.go b/plugins/golang/golang_test.go index 13d2a02..33ad88b 100644 --- a/plugins/golang/golang_test.go +++ b/plugins/golang/golang_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/trevorphillipscoding/nvy/internal/semver" ) func TestNormalizeOS(t *testing.T) { @@ -66,7 +68,7 @@ func TestNormalizeArch(t *testing.T) { } } -func TestFindLatestGoVersion(t *testing.T) { +func TestFetchStableGoVersions(t *testing.T) { releases := []struct { Version string `json:"version"` Stable bool `json:"stable"` @@ -79,9 +81,9 @@ func TestFindLatestGoVersion(t *testing.T) { } body, _ := json.Marshal(releases) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write(body) + _, _ = w.Write(body) })) defer srv.Close() @@ -89,38 +91,24 @@ func TestFindLatestGoVersion(t *testing.T) { releasesAPI = srv.URL defer func() { releasesAPI = orig }() - cases := []struct { - prefix string - want string - wantErr bool - }{ - {"1.24", "1.24.1", false}, - {"1.23", "1.23.5", false}, - {"1.22", "1.22.12", false}, - {"1.25", "", true}, + versions, err := fetchStableGoVersions() + if err != nil { + t.Fatalf("fetchStableGoVersions: %v", err) } - for _, c := range cases { - got, err := findLatestGoVersion(c.prefix) - if c.wantErr { - if err == nil { - t.Errorf("findLatestGoVersion(%q): expected error, got %q", c.prefix, got) - } - continue - } - if err != nil { - t.Errorf("findLatestGoVersion(%q): unexpected error: %v", c.prefix, err) - continue - } - if got != c.want { - t.Errorf("findLatestGoVersion(%q) = %q; want %q", c.prefix, got, c.want) - } + + resolved, err := semver.Resolve("1.24", versions) + if err != nil { + t.Fatalf("Resolve 1.24: %v", err) + } + if resolved != "1.24.1" { + t.Errorf("Resolve(1.24) = %q; want 1.24.1", resolved) } } -func TestFindLatestGoVersion_ServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func TestFetchStableGoVersions_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("internal error")) + _, _ = w.Write([]byte("internal error")) })) defer srv.Close() @@ -128,15 +116,15 @@ func TestFindLatestGoVersion_ServerError(t *testing.T) { releasesAPI = srv.URL defer func() { releasesAPI = orig }() - _, err := findLatestGoVersion("1.22") + _, err := fetchStableGoVersions() if err == nil { t.Error("expected error for server 500, got nil") } } -func TestFindLatestGoVersion_BadJSON(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("not json")) +func TestFetchStableGoVersions_BadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) })) defer srv.Close() @@ -144,13 +132,13 @@ func TestFindLatestGoVersion_BadJSON(t *testing.T) { releasesAPI = srv.URL defer func() { releasesAPI = orig }() - _, err := findLatestGoVersion("1.22") + _, err := fetchStableGoVersions() if err == nil { t.Error("expected error for bad JSON, got nil") } } -func TestResolve_PartialVersion(t *testing.T) { +func TestAvailableVersions(t *testing.T) { releases := []struct { Version string `json:"version"` Stable bool `json:"stable"` @@ -159,8 +147,8 @@ func TestResolve_PartialVersion(t *testing.T) { } body, _ := json.Marshal(releases) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write(body) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) })) defer srv.Close() @@ -169,15 +157,27 @@ func TestResolve_PartialVersion(t *testing.T) { defer func() { releasesAPI = orig }() p := New() - spec, err := p.Resolve("1.22", "linux", "amd64") + versions, err := p.AvailableVersions("linux", "amd64") if err != nil { - t.Fatalf("Resolve partial version: %v", err) + t.Fatalf("AvailableVersions: %v", err) } - if spec.ResolvedVersion != "1.22.3" { - t.Errorf("ResolvedVersion = %q; want 1.22.3", spec.ResolvedVersion) + ver, err := semver.Resolve("1.22", versions) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if ver != "1.22.3" { + t.Errorf("resolved version = %q; want 1.22.3", ver) + } +} + +func TestResolve_FullVersion(t *testing.T) { + p := New() + spec, err := p.Resolve("1.22.3", "linux", "amd64") + if err != nil { + t.Fatalf("Resolve: %v", err) } if !strings.Contains(spec.URL, "1.22.3") { - t.Errorf("URL should contain resolved version, got %q", spec.URL) + t.Errorf("URL should contain version, got %q", spec.URL) } } diff --git a/plugins/node/node.go b/plugins/node/node.go index 5ef7812..6fe922a 100644 --- a/plugins/node/node.go +++ b/plugins/node/node.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/trevorphillipscoding/nvy/internal/version" + "github.com/trevorphillipscoding/nvy/internal/semver" "github.com/trevorphillipscoding/nvy/plugins" ) @@ -33,6 +33,11 @@ func (n *nodePlugin) Name() string { return "node" } func (n *nodePlugin) Aliases() []string { return []string{"nodejs", "node.js"} } +// AvailableVersions returns Node.js releases as exact semantic versions. +func (n *nodePlugin) AvailableVersions(_, _ string) ([]string, error) { + return fetchNodeVersions() +} + // Resolve builds the download spec for a Node.js release tarball. // // Official naming convention: @@ -41,11 +46,6 @@ func (n *nodePlugin) Aliases() []string { return []string{"nodejs", "node.js"} } // SHASUMS256.txt ← multi-entry file; we look up our filename inside it // // Example: node-v20.11.1-linux-x64.tar.gz -// -// Partial versions (fewer than two dots) resolve to the latest matching release: -// -// "22" → latest 22.x.y -// "22.11" → latest 22.11.x func (n *nodePlugin) Resolve(ver, goos, goarch string) (*plugins.DownloadSpec, error) { os, err := normalizeOS(goos) if err != nil { @@ -56,18 +56,11 @@ func (n *nodePlugin) Resolve(ver, goos, goarch string) (*plugins.DownloadSpec, e return nil, err } - var resolvedVersion string - if strings.Count(ver, ".") < 2 { - latest, err := findLatestNodeVersion(ver) - if err != nil { - return nil, err - } - resolvedVersion = latest - ver = latest - } else { - ver = version.Normalize(ver) + v, err := semver.ParseVersion(ver) + if err != nil { + return nil, fmt.Errorf("node plugin: %w", err) } - + ver = v.String() // Node uses "v" prefix in both the URL path and the archive filename. filename := fmt.Sprintf("node-v%s-%s-%s.tar.gz", ver, os, arch) url := fmt.Sprintf("%s/v%s/%s", distBase, ver, filename) @@ -78,43 +71,42 @@ func (n *nodePlugin) Resolve(ver, goos, goarch string) (*plugins.DownloadSpec, e ChecksumURL: checksumURL, ChecksumFilename: filename, // SHASUMS256 mode: look up this filename in the file StripComponents: 1, // archive has a top-level "node-v--/" directory - ResolvedVersion: resolvedVersion, }, nil } -// findLatestNodeVersion returns the latest Node.js release whose version -// starts with prefix (e.g. "22" or "22.11"). The dist/index.json is -// ordered newest-first, so the first match is the latest. -func findLatestNodeVersion(prefix string) (string, error) { +func fetchNodeVersions() ([]string, error) { client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(releasesAPI) if err != nil { - return "", fmt.Errorf("node plugin: fetching releases: %w", err) + return nil, fmt.Errorf("node plugin: fetching releases: %w", err) } body, err := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if err != nil { - return "", fmt.Errorf("node plugin: reading releases response: %w", err) + return nil, fmt.Errorf("node plugin: reading releases response: %w", err) } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("node plugin: releases API returned %s", resp.Status) + return nil, fmt.Errorf("node plugin: releases API returned %s", resp.Status) } var releases []struct { Version string `json:"version"` } if err := json.Unmarshal(body, &releases); err != nil { - return "", fmt.Errorf("node plugin: parsing releases JSON: %w", err) + return nil, fmt.Errorf("node plugin: parsing releases JSON: %w", err) } - // Versions are "v22.13.1"; add a dot after prefix to avoid "v2" matching "v20.x". - wantPrefix := "v" + prefix + "." + versions := make([]string, 0, len(releases)) for _, r := range releases { - if strings.HasPrefix(r.Version, wantPrefix) { - return strings.TrimPrefix(r.Version, "v"), nil + v := strings.TrimPrefix(r.Version, "v") + if _, parseErr := semver.ParseVersion(v); parseErr == nil { + versions = append(versions, v) } } - return "", fmt.Errorf("node plugin: no release found for Node.js %s.*", prefix) + if len(versions) == 0 { + return nil, fmt.Errorf("node plugin: no semantic versions found") + } + return versions, nil } func normalizeOS(goos string) (string, error) { diff --git a/plugins/node/node_test.go b/plugins/node/node_test.go index c33cc7f..83f64a3 100644 --- a/plugins/node/node_test.go +++ b/plugins/node/node_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/trevorphillipscoding/nvy/internal/semver" ) func TestNormalizeOS(t *testing.T) { @@ -66,7 +68,7 @@ func TestNormalizeArch(t *testing.T) { } } -func TestFindLatestNodeVersion(t *testing.T) { +func TestFetchNodeVersions(t *testing.T) { releases := []struct { Version string `json:"version"` }{ @@ -77,8 +79,8 @@ func TestFindLatestNodeVersion(t *testing.T) { } body, _ := json.Marshal(releases) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write(body) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) })) defer srv.Close() @@ -86,35 +88,22 @@ func TestFindLatestNodeVersion(t *testing.T) { releasesAPI = srv.URL defer func() { releasesAPI = orig }() - cases := []struct { - prefix string - want string - wantErr bool - }{ - {"22", "22.13.1", false}, - {"20", "20.18.2", false}, - {"18", "", true}, + versions, err := fetchNodeVersions() + if err != nil { + t.Fatalf("fetchNodeVersions: %v", err) } - for _, c := range cases { - got, err := findLatestNodeVersion(c.prefix) - if c.wantErr { - if err == nil { - t.Errorf("findLatestNodeVersion(%q): expected error, got %q", c.prefix, got) - } - continue - } - if err != nil { - t.Errorf("findLatestNodeVersion(%q): unexpected error: %v", c.prefix, err) - continue - } - if got != c.want { - t.Errorf("findLatestNodeVersion(%q) = %q; want %q", c.prefix, got, c.want) - } + + resolved, err := semver.Resolve("22", versions) + if err != nil { + t.Fatalf("Resolve 22: %v", err) + } + if resolved != "22.13.1" { + t.Errorf("Resolve(22) = %q; want 22.13.1", resolved) } } -func TestFindLatestNodeVersion_ServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func TestFetchNodeVersions_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() @@ -123,15 +112,15 @@ func TestFindLatestNodeVersion_ServerError(t *testing.T) { releasesAPI = srv.URL defer func() { releasesAPI = orig }() - _, err := findLatestNodeVersion("22") + _, err := fetchNodeVersions() if err == nil { t.Error("expected error for server 500, got nil") } } -func TestFindLatestNodeVersion_BadJSON(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("not json")) +func TestFetchNodeVersions_BadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) })) defer srv.Close() @@ -139,13 +128,13 @@ func TestFindLatestNodeVersion_BadJSON(t *testing.T) { releasesAPI = srv.URL defer func() { releasesAPI = orig }() - _, err := findLatestNodeVersion("22") + _, err := fetchNodeVersions() if err == nil { t.Error("expected error for bad JSON, got nil") } } -func TestResolve_PartialVersion(t *testing.T) { +func TestAvailableVersions(t *testing.T) { releases := []struct { Version string `json:"version"` }{ @@ -153,8 +142,8 @@ func TestResolve_PartialVersion(t *testing.T) { } body, _ := json.Marshal(releases) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write(body) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) })) defer srv.Close() @@ -163,15 +152,27 @@ func TestResolve_PartialVersion(t *testing.T) { defer func() { releasesAPI = orig }() p := New() - spec, err := p.Resolve("22", "linux", "amd64") + versions, err := p.AvailableVersions("linux", "amd64") if err != nil { - t.Fatalf("Resolve partial version: %v", err) + t.Fatalf("AvailableVersions: %v", err) } - if spec.ResolvedVersion != "22.13.1" { - t.Errorf("ResolvedVersion = %q; want 22.13.1", spec.ResolvedVersion) + ver, err := semver.Resolve("22", versions) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if ver != "22.13.1" { + t.Errorf("resolved version = %q; want 22.13.1", ver) + } +} + +func TestResolve_FullVersion(t *testing.T) { + p := New() + spec, err := p.Resolve("22.13.1", "linux", "amd64") + if err != nil { + t.Fatalf("Resolve: %v", err) } if !strings.Contains(spec.URL, "22.13.1") { - t.Errorf("URL should contain resolved version, got %q", spec.URL) + t.Errorf("URL should contain version, got %q", spec.URL) } } diff --git a/plugins/plugin.go b/plugins/plugin.go index 0b5a255..2c7b31a 100644 --- a/plugins/plugin.go +++ b/plugins/plugin.go @@ -31,12 +31,6 @@ type DownloadSpec struct { // go1.22.1.linux-amd64.tar.gz → go/bin/go (strip 1) // node-v20.11.1-linux-x64.tar.gz → node-v.../bin/node (strip 1) StripComponents int - - // ResolvedVersion is the canonical version that was resolved from the input. - // Plugins set this when the caller passed a partial version (e.g. "3.12") and - // the plugin resolved it to a specific release (e.g. "3.12.8"). - // If empty, install uses version.Normalize(rawInput) for the install directory. - ResolvedVersion string } // Plugin is the interface every language runtime installer must satisfy. @@ -49,8 +43,15 @@ type Plugin interface { // Aliases are case-sensitive and must not conflict with other plugin names. Aliases() []string - // Resolve returns a DownloadSpec for the given version on the given platform. + // AvailableVersions returns all available exact versions (major.minor.patch) + // for this runtime on the given platform. + // + // Version interpretation (exact vs partial, latest matching version, sorting) + // is handled centrally by internal/semver. Plugins only provide candidates. + AvailableVersions(goos, goarch string) ([]string, error) + + // Resolve returns a DownloadSpec for the given full version on the given platform. + // version is always a complete version string (e.g. "22.13.1", not "22"). // goos/goarch mirror runtime.GOOS / runtime.GOARCH values ("linux"/"darwin", "amd64"/"arm64"). - // Resolve should return a descriptive error for unsupported platforms or malformed versions. Resolve(version, goos, goarch string) (*DownloadSpec, error) } diff --git a/plugins/plugin_test.go b/plugins/plugin_test.go index 95ec563..73ad4ed 100644 --- a/plugins/plugin_test.go +++ b/plugins/plugin_test.go @@ -174,14 +174,14 @@ func TestPythonPlugin_UnsupportedPlatform(t *testing.T) { func TestPythonPlugin_ExplicitTagNoResolvedVersion(t *testing.T) { p := python.New() - // When a full version+tag is provided, ResolvedVersion should be empty - // (no resolution was needed; the install dir uses the input version as-is). + // When a full version+tag is provided, no resolution was needed; + // the install dir uses the input version as-is. spec, err := p.Resolve("3.12.5+20240814", "linux", "amd64") if err != nil { t.Fatalf("Resolve: %v", err) } - if spec.ResolvedVersion != "" { - t.Errorf("ResolvedVersion should be empty for explicit version+tag, got %q", spec.ResolvedVersion) + if spec.URL == "" { + t.Error("URL must not be empty for explicit version+tag") } } diff --git a/plugins/python/python.go b/plugins/python/python.go index 08bddab..00c9739 100644 --- a/plugins/python/python.go +++ b/plugins/python/python.go @@ -10,10 +10,10 @@ import ( "io" "net/http" "regexp" - "strconv" "strings" "time" + "github.com/trevorphillipscoding/nvy/internal/semver" "github.com/trevorphillipscoding/nvy/plugins" ) @@ -43,15 +43,17 @@ func (p *pythonPlugin) Name() string { return "python" } func (p *pythonPlugin) Aliases() []string { return []string{"python3", "py"} } +// AvailableVersions returns available exact CPython semantic versions for the platform. +func (p *pythonPlugin) AvailableVersions(goos, goarch string) ([]string, error) { + triple, err := normalizeTriple(goos, goarch) + if err != nil { + return nil, err + } + return listAvailableVersions(triple) +} + // Resolve builds the download spec for a CPython release from python-build-standalone. // -// Version formats accepted: -// -// 3 — discovers the latest 3.x.y release via the GitHub API -// 3.12 — discovers the latest 3.12.x release via the GitHub API -// 3.12.5 — discovers the latest release tag via the GitHub Atom feed -// 3.12.5+20240814 — uses the given build tag directly (no network call) -// // Official naming convention: // // cpython-+--install_only.tar.gz @@ -64,25 +66,14 @@ func (p *pythonPlugin) Resolve(version, goos, goarch string) (*plugins.DownloadS return nil, err } - pyVersion, tag, err := parseVersion(version) + pyVersion, tag, err := parseResolvedVersion(version) if err != nil { - return nil, err + return nil, fmt.Errorf("python plugin: %w", err) } - - var resolvedVersion string if tag == "" { - if strings.Count(pyVersion, ".") < 2 { - // Partial version (major or major.minor): find the latest available release. - pyVersion, tag, err = findLatest(pyVersion, triple) - if err != nil { - return nil, err - } - resolvedVersion = pyVersion - } else { - tag, err = findReleaseTag(pyVersion, triple) - if err != nil { - return nil, err - } + tag, err = findReleaseTag(pyVersion, triple) + if err != nil { + return nil, err } } @@ -95,26 +86,23 @@ func (p *pythonPlugin) Resolve(version, goos, goarch string) (*plugins.DownloadS ChecksumURL: checksumURL, ChecksumFilename: filename, // SHASUMS256 mode: look up this filename in SHA256SUMS StripComponents: 1, // archive top-level is "python/" - ResolvedVersion: resolvedVersion, }, nil } -// parseVersion splits an optional +tag suffix from the version string. -// -// "3.12.5" → ("3.12.5", "", nil) -// "3.12.5+20240814" → ("3.12.5", "20240814", nil) -// "3.12" → ("3.12", "", nil) -// "3" → ("3", "", nil) -func parseVersion(version string) (pyVersion, tag string, err error) { - parts := strings.SplitN(version, "+", 2) - pyVersion = strings.TrimSpace(parts[0]) - if pyVersion == "" { - return "", "", fmt.Errorf("python plugin: empty version string") +func parseResolvedVersion(input string) (version string, tag string, err error) { + base, build, hasBuild := strings.Cut(strings.TrimSpace(input), "+") + v, err := semver.ParseVersion(base) + if err != nil { + return "", "", err } - if len(parts) == 2 { - tag = strings.TrimSpace(parts[1]) + if !hasBuild { + return v.String(), "", nil } - return pyVersion, tag, nil + build = strings.TrimSpace(build) + if build == "" { + return "", "", fmt.Errorf("invalid build tag in %q", input) + } + return v.String(), build, nil } // normalizeTriple maps GOOS/GOARCH to the target triple used in python-build-standalone filenames. @@ -133,33 +121,26 @@ func normalizeTriple(goos, goarch string) (string, error) { } } -// findLatest fetches the GitHub releases API and returns the highest available -// CPython version whose version string starts with prefix (e.g. "3" or "3.12") -// and matches the given platform triple. -// -// python-build-standalone releases contain multiple Python versions (3.12.x, -// 3.13.x, …) per tag, so we compare full version tuples to find the true -// latest, not just the highest patch number. -func findLatest(prefix, triple string) (pyVersion, tag string, err error) { +func listAvailableVersions(triple string) ([]string, error) { client := &http.Client{Timeout: 30 * time.Second} req, err := http.NewRequest(http.MethodGet, releasesAPI, nil) if err != nil { - return "", "", fmt.Errorf("python plugin: building releases request: %w", err) + return nil, fmt.Errorf("python plugin: building releases request: %w", err) } req.Header.Set("Accept", "application/vnd.github+json") resp, err := client.Do(req) if err != nil { - return "", "", fmt.Errorf("python plugin: fetching releases: %w", err) + return nil, fmt.Errorf("python plugin: fetching releases: %w", err) } body, err := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if err != nil { - return "", "", fmt.Errorf("python plugin: reading releases response: %w", err) + return nil, fmt.Errorf("python plugin: reading releases response: %w", err) } if resp.StatusCode != http.StatusOK { - return "", "", fmt.Errorf("python plugin: releases API returned %s", resp.Status) + return nil, fmt.Errorf("python plugin: releases API returned %s", resp.Status) } var releases []struct { @@ -168,39 +149,32 @@ func findLatest(prefix, triple string) (pyVersion, tag string, err error) { } `json:"assets"` } if err := json.Unmarshal(body, &releases); err != nil { - return "", "", fmt.Errorf("python plugin: parsing releases JSON: %w", err) + return nil, fmt.Errorf("python plugin: parsing releases JSON: %w", err) } - // Add a dot after prefix so "3" matches "3.12.x" but not "30.x.y". - assetPrefix := prefix + "." - var bestVer [3]int - bestVersionStr := "" - bestTag := "" + seen := map[string]bool{} + versions := make([]string, 0) for _, release := range releases { for _, asset := range release.Assets { - m := assetPattern.FindStringSubmatch(asset.Name) - if m == nil || !strings.HasPrefix(m[1], assetPrefix) || m[4] != triple { + m := assetPattern.FindStringSubmatch(strings.TrimSpace(asset.Name)) + if m == nil || m[4] != triple { continue } - v := parseVersionTuple(m[1]) - if bestVersionStr == "" || cmpVersionTuple(v, bestVer) > 0 { - bestVer = v - bestVersionStr = m[1] - bestTag = m[3] + if _, err := semver.ParseVersion(m[1]); err != nil { + continue + } + if !seen[m[1]] { + seen[m[1]] = true + versions = append(versions, m[1]) } - } - // Releases are newest-first; stop after the first one that has any match. - // All relevant latest versions are in the most recent release. - if bestVersionStr != "" { - break } } - if bestVersionStr == "" { - return "", "", fmt.Errorf("python plugin: no release found for Python %s.* on %s in recent releases", prefix, triple) + if len(versions) == 0 { + return nil, fmt.Errorf("python plugin: no semantic versions found for %s in recent releases", triple) } - return bestVersionStr, bestTag, nil + return versions, nil } // findReleaseTag fetches the project's Atom release feed (no auth required, not @@ -214,7 +188,7 @@ func findReleaseTag(pyVersion, triple string) (string, error) { return "", fmt.Errorf("python plugin: fetching release feed: %w", err) } body, err := io.ReadAll(resp.Body) - resp.Body.Close() + _ = resp.Body.Close() if err != nil { return "", fmt.Errorf("python plugin: reading release feed: %w", err) } @@ -242,7 +216,7 @@ func findReleaseTag(pyVersion, triple string) (string, error) { url := fmt.Sprintf("%s/%s/%s", downloadBase, tag, filename) r, err := client.Head(url) if err == nil { - r.Body.Close() + _ = r.Body.Close() if r.StatusCode == http.StatusOK { return tag, nil } @@ -251,30 +225,3 @@ func findReleaseTag(pyVersion, triple string) (string, error) { return "", fmt.Errorf("python plugin: no release found for Python %s on %s in the latest %d releases; specify a build tag to install older versions (e.g. %s+20240814)", pyVersion, triple, len(tags), pyVersion) } - -// parseVersionTuple parses "major.minor.patch" into an integer triple. -func parseVersionTuple(v string) [3]int { - var result [3]int - parts := strings.SplitN(v, ".", 3) - for i, p := range parts { - if i >= 3 { - break - } - n, _ := strconv.Atoi(p) - result[i] = n - } - return result -} - -// cmpVersionTuple returns -1, 0, or 1 for a < b, a == b, a > b. -func cmpVersionTuple(a, b [3]int) int { - for i := range a { - if a[i] < b[i] { - return -1 - } - if a[i] > b[i] { - return 1 - } - } - return 0 -} diff --git a/plugins/python/python_test.go b/plugins/python/python_test.go index a9ac710..4364439 100644 --- a/plugins/python/python_test.go +++ b/plugins/python/python_test.go @@ -5,44 +5,10 @@ import ( "fmt" "net/http" "net/http/httptest" - "strings" "testing" -) -func TestParseVersion(t *testing.T) { - cases := []struct { - input string - wantVer string - wantTag string - wantError bool - }{ - {"3.12.5", "3.12.5", "", false}, - {"3.12.5+20240814", "3.12.5", "20240814", false}, - {"3.12", "3.12", "", false}, - {"3", "3", "", false}, - {"", "", "", true}, - {" ", "", "", true}, - } - for _, c := range cases { - ver, tag, err := parseVersion(c.input) - if c.wantError { - if err == nil { - t.Errorf("parseVersion(%q): expected error, got nil", c.input) - } - continue - } - if err != nil { - t.Errorf("parseVersion(%q): unexpected error: %v", c.input, err) - continue - } - if ver != c.wantVer { - t.Errorf("parseVersion(%q) ver = %q; want %q", c.input, ver, c.wantVer) - } - if tag != c.wantTag { - t.Errorf("parseVersion(%q) tag = %q; want %q", c.input, tag, c.wantTag) - } - } -} + "github.com/trevorphillipscoding/nvy/internal/semver" +) func TestParseVersionTuple(t *testing.T) { cases := []struct { @@ -57,9 +23,13 @@ func TestParseVersionTuple(t *testing.T) { {"3", [3]int{3, 0, 0}}, } for _, c := range cases { - got := parseVersionTuple(c.input) + v, err := semver.ParseReference(c.input) + if err != nil { + t.Fatalf("ParseReference(%q): %v", c.input, err) + } + got := [3]int{v.Major, v.Minor, v.Patch} if got != c.want { - t.Errorf("parseVersionTuple(%q) = %v; want %v", c.input, got, c.want) + t.Errorf("ParseReference(%q) = %v; want %v", c.input, got, c.want) } } } @@ -78,9 +48,12 @@ func TestCmpVersionTuple(t *testing.T) { {[3]int{0, 0, 0}, [3]int{0, 0, 0}, 0}, } for _, c := range cases { - got := cmpVersionTuple(c.a, c.b) + got := semver.Compare( + semver.Version{Major: c.a[0], Minor: c.a[1], Patch: c.a[2]}, + semver.Version{Major: c.b[0], Minor: c.b[1], Patch: c.b[2]}, + ) if got != c.want { - t.Errorf("cmpVersionTuple(%v, %v) = %d; want %d", c.a, c.b, got, c.want) + t.Errorf("Compare(%v, %v) = %d; want %d", c.a, c.b, got, c.want) } } } @@ -118,7 +91,7 @@ func TestNormalizeTriple(t *testing.T) { } } -func makeFindLatestServer(t *testing.T, triple string) *httptest.Server { +func makeAvailableVersionsServer(t *testing.T, triple string) *httptest.Server { t.Helper() assets := []struct { Name string `json:"name"` @@ -133,72 +106,77 @@ func makeFindLatestServer(t *testing.T, triple string) *httptest.Server { Assets interface{} `json:"assets"` }{{Assets: assets}} body, _ := json.Marshal(releases) - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - w.Write(body) + _, _ = w.Write(body) })) } -func TestFindLatest(t *testing.T) { +func TestListAvailableVersions(t *testing.T) { triple := "x86_64-unknown-linux-gnu" - srv := makeFindLatestServer(t, triple) + srv := makeAvailableVersionsServer(t, triple) defer srv.Close() orig := releasesAPI releasesAPI = srv.URL defer func() { releasesAPI = orig }() - pyVersion, tag, err := findLatest("3", triple) + versions, err := listAvailableVersions(triple) + if err != nil { + t.Fatalf("listAvailableVersions: %v", err) + } + pyVersion, err := semver.Resolve("3", versions) if err != nil { - t.Fatalf("findLatest: %v", err) + t.Fatalf("Resolve(3): %v", err) } - // Should pick the highest version: 3.13.1 > 3.12.8 > 3.12.5 if pyVersion != "3.13.1" { t.Errorf("pyVersion = %q; want 3.13.1", pyVersion) } - if tag != "20240814" { - t.Errorf("tag = %q; want 20240814", tag) - } } -func TestFindLatest_Minor(t *testing.T) { +func TestListAvailableVersions_MinorResolution(t *testing.T) { triple := "x86_64-unknown-linux-gnu" - srv := makeFindLatestServer(t, triple) + srv := makeAvailableVersionsServer(t, triple) defer srv.Close() orig := releasesAPI releasesAPI = srv.URL defer func() { releasesAPI = orig }() - pyVersion, tag, err := findLatest("3.12", triple) + versions, err := listAvailableVersions(triple) + if err != nil { + t.Fatalf("listAvailableVersions: %v", err) + } + pyVersion, err := semver.Resolve("3.12", versions) if err != nil { - t.Fatalf("findLatest 3.12: %v", err) + t.Fatalf("Resolve 3.12: %v", err) } if pyVersion != "3.12.8" { t.Errorf("pyVersion = %q; want 3.12.8", pyVersion) } - if tag != "20240814" { - t.Errorf("tag = %q; want 20240814", tag) - } } -func TestFindLatest_NotFound(t *testing.T) { +func TestListAvailableVersions_NotFound(t *testing.T) { triple := "x86_64-unknown-linux-gnu" - srv := makeFindLatestServer(t, triple) + srv := makeAvailableVersionsServer(t, triple) defer srv.Close() orig := releasesAPI releasesAPI = srv.URL defer func() { releasesAPI = orig }() - _, _, err := findLatest("4", triple) + versions, err := listAvailableVersions(triple) + if err != nil { + t.Fatalf("listAvailableVersions: %v", err) + } + _, err = semver.Resolve("4", versions) if err == nil { t.Error("expected error for version 4.*, got nil") } } -func TestFindLatest_ServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func TestListAvailableVersions_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() @@ -207,15 +185,15 @@ func TestFindLatest_ServerError(t *testing.T) { releasesAPI = srv.URL defer func() { releasesAPI = orig }() - _, _, err := findLatest("3", "x86_64-unknown-linux-gnu") + _, err := listAvailableVersions("x86_64-unknown-linux-gnu") if err == nil { t.Error("expected error for server 500, got nil") } } -func TestFindLatest_BadJSON(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("not json")) +func TestListAvailableVersions_BadJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("not json")) })) defer srv.Close() @@ -223,14 +201,14 @@ func TestFindLatest_BadJSON(t *testing.T) { releasesAPI = srv.URL defer func() { releasesAPI = orig }() - _, _, err := findLatest("3", "x86_64-unknown-linux-gnu") + _, err := listAvailableVersions("x86_64-unknown-linux-gnu") if err == nil { t.Error("expected error for bad JSON, got nil") } } func TestFindReleaseTag_AtomServerError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() @@ -246,8 +224,8 @@ func TestFindReleaseTag_AtomServerError(t *testing.T) { } func TestFindReleaseTag_NoTags(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("no tags here")) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("no tags here")) })) defer srv.Close() @@ -261,7 +239,7 @@ func TestFindReleaseTag_NoTags(t *testing.T) { } } -func TestResolve_PartialVersion(t *testing.T) { +func TestAvailableVersions(t *testing.T) { triple := "x86_64-unknown-linux-gnu" assets := []struct { Name string `json:"name"` @@ -273,8 +251,8 @@ func TestResolve_PartialVersion(t *testing.T) { }{{Assets: assets}} body, _ := json.Marshal(releases) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Write(body) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(body) })) defer srv.Close() @@ -283,14 +261,15 @@ func TestResolve_PartialVersion(t *testing.T) { defer func() { releasesAPI = orig }() p := New() - spec, err := p.Resolve("3.12", "linux", "amd64") + versions, err := p.AvailableVersions("linux", "amd64") if err != nil { - t.Fatalf("Resolve partial version: %v", err) + t.Fatalf("AvailableVersions: %v", err) } - if spec.ResolvedVersion != "3.12.8" { - t.Errorf("ResolvedVersion = %q; want 3.12.8", spec.ResolvedVersion) + ver, err := semver.Resolve("3.12", versions) + if err != nil { + t.Fatalf("Resolve: %v", err) } - if !strings.Contains(spec.URL, "3.12.8") { - t.Errorf("URL should contain resolved version, got %q", spec.URL) + if ver != "3.12.8" { + t.Errorf("resolved version = %q; want 3.12.8", ver) } } diff --git a/plugins/registry.go b/plugins/registry.go index 0a178f4..27505c1 100644 --- a/plugins/registry.go +++ b/plugins/registry.go @@ -44,7 +44,23 @@ func Get(name string) (Plugin, error) { func All() []Plugin { mu.RLock() defer mu.RUnlock() + return uniqueSorted() +} +// availableNames returns a sorted, comma-separated list of canonical plugin names. +// Must be called with mu held (e.g. from Get). +func availableNames() string { + all := uniqueSorted() + names := make([]string, len(all)) + for i, p := range all { + names[i] = p.Name() + } + return strings.Join(names, ", ") +} + +// uniqueSorted returns one entry per unique plugin sorted by canonical name. +// Caller must hold mu (read or write). +func uniqueSorted() []Plugin { seen := map[string]bool{} var out []Plugin for _, p := range registry { @@ -56,17 +72,3 @@ func All() []Plugin { sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() }) return out } - -// availableNames returns a sorted, comma-separated list of canonical plugin names. -func availableNames() string { - seen := map[string]bool{} - var names []string - for _, p := range registry { - if !seen[p.Name()] { - seen[p.Name()] = true - names = append(names, p.Name()) - } - } - sort.Strings(names) - return strings.Join(names, ", ") -}