Skip to content

Commit 60de918

Browse files
authored
Merge pull request #5 from stepbeta/feat/support-install-latest
feat: add support for installing "latest" version
2 parents 9a83c40 + 70c9b0b commit 60de918

File tree

6 files changed

+189
-8
lines changed

6 files changed

+189
-8
lines changed

.vscode/settings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"go.coverOnSave": true,
3+
"go.coverageDecorator": {
4+
"type": "gutter",
5+
"coveredGutterStyle": "blockgreen",
6+
"uncoveredGutterStyle": "blockred"
7+
}
8+
}

AGENTS.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copilot Instructions
2+
3+
You are an expert Go developer working on this CLI application built with Cobra.
4+
5+
## Project Overview
6+
7+
**vrsr** (Version Selector Runner) is a CLI tool for managing multiple versions of various command-line tools. It allows users to easily install, list, and switch between different versions of tools like `kubectl`, `helm`, `kind`, `talosctl`, `cilium`, and `hubble`. The tool downloads binaries from GitHub releases and manages them in a configurable storage path.
8+
9+
## Project Structure
10+
11+
- `cmd/` - Cobra commands (root.go, version.go, docs.go)
12+
- `internal/cli/` - Command implementations organized by subcommand
13+
- `internal/cli/tools/` - Tool-specific subcommands (kubectl, helm, kind, etc.)
14+
- `internal/cli/common/` - Shared command utilities (install, list, use, etc.)
15+
- `internal/utils/` - Utility functions (cache, binary management)
16+
- `internal/github/` - GitHub API helpers
17+
18+
## Code Style
19+
20+
- Use `cobra.Command` for CLI commands
21+
- Use `viper` for configuration management
22+
- Use environment variable prefix `VRSR_` (e.g., `VRSR_BIN_PATH`)
23+
- Follow standard Go conventions: `camelCase` for variables, `PascalCase` for exported functions/types
24+
- Use meaningful error messages and wrap errors with `fmt.Errorf("context: %w", err)`
25+
- Use the `command` pattern from `internal/cli/common/command.go` for consistent subcommand structure
26+
- Always format and lint code after a modification.
27+
28+
## Testing
29+
30+
- Write unit tests with the `_test.go` suffix in the same package
31+
- Use table-driven tests where appropriate
32+
33+
## Commands
34+
35+
- Run the CLI: `go run main.go`
36+
- Run tests: `go test ./...`
37+
- Build: `go build -o vrsr`
38+
- Format code: `go fmt -w -s .`
39+
- Lint code: `golangci-lint run`
40+
- Add a new tool subcommand: Follow the pattern in `internal/cli/tools/`
41+
42+
## Constraints
43+
44+
- Never execute destructive commands (rm -rf, dd, mkfs, etc.) unless the user asks explicitly
45+
- If the user asks for a destructive command, always ask for confirmation before executing
46+
- Never modify or delete files outside the project directory without user consent
47+
- Always validate potentially destructive operations with the user before proceeding
48+
- Never run commands that could affect the host system (systemd operations, disk operations, etc.)
49+
- When in doubt, ask the user to confirm before taking any action that modifies system state
50+
- Do not interact with Git or GitHub repositories without explicit user instructions
51+
- Always prioritize user safety and data integrity in all operations

internal/cli/common/install.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package common
22

33
import (
44
"fmt"
5+
"strings"
56

67
"github.com/spf13/cobra"
78
"github.com/spf13/viper"
@@ -43,6 +44,16 @@ func newInstallCommand(tool string, repoConf github.RepoConfDef, installType Ins
4344

4445
// install downloads and installs the specified version of the tool from GitHub releases
4546
func install(cmd *cobra.Command, vrs, tool string, repoConf github.RepoConfDef, installType InstallCmdType, skipMsg bool) error {
47+
if strings.ToLower(vrs) == "latest" {
48+
ghc := github.New(nil)
49+
latestVrs, err := getLatestVersion(tool, repoConf, ghc)
50+
if err != nil {
51+
return fmt.Errorf("failed to get latest version: %w", err)
52+
}
53+
vrs = latestVrs
54+
cmd.Printf("Resolved 'latest' to version %s\n", vrs)
55+
}
56+
4657
if utils.IsToolInUse(tool, vrs) {
4758
cmd.Printf("%s version %s is already installed and in use. Nothing to do\n", tool, vrs)
4859
return nil
@@ -111,3 +122,21 @@ func useOnInstallFn(cmd *cobra.Command, vrs, tool string) error {
111122
}
112123
return nil
113124
}
125+
126+
func getLatestVersion(tool string, repoConf github.RepoConfDef, ghc github.GithubHelper) (string, error) {
127+
releasesData, err := ghc.FetchAllReleases(tool, github.FetchOptions{
128+
IncludeDevel: false,
129+
Limit: 0,
130+
Force: true,
131+
RepoConf: repoConf,
132+
})
133+
if err != nil {
134+
return "", err
135+
}
136+
versions := utils.SemverFromReleases(releasesData.Releases, false)
137+
if len(versions) == 0 {
138+
return "", fmt.Errorf("no versions found for %s", tool)
139+
}
140+
latest := versions[len(versions)-1]
141+
return latest.Original(), nil
142+
}

internal/cli/common/install_test.go

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,106 @@
11
package common
22

33
import (
4+
"context"
5+
"io"
6+
"net/http"
47
"os"
58
"path/filepath"
69
"testing"
710

11+
"github.com/google/go-github/v78/github"
812
"github.com/spf13/cobra"
913
"github.com/spf13/viper"
10-
"github.com/stepbeta/vrsr/internal/github"
14+
vrsrgithub "github.com/stepbeta/vrsr/internal/github"
1115
)
1216

17+
type mockReposService struct {
18+
releases []*github.RepositoryRelease
19+
}
20+
21+
func (m *mockReposService) ListReleases(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.RepositoryRelease, *github.Response, error) {
22+
return m.releases, &github.Response{}, nil
23+
}
24+
25+
func (m *mockReposService) GetReleaseByTag(ctx context.Context, owner, repo, tag string) (*github.RepositoryRelease, *github.Response, error) {
26+
return nil, &github.Response{}, nil
27+
}
28+
29+
func (m *mockReposService) DownloadReleaseAsset(ctx context.Context, owner, repo string, assetID int64, httpClient *http.Client) (io.ReadCloser, string, error) {
30+
return nil, "", nil
31+
}
32+
33+
func TestGetLatestVersion(t *testing.T) {
34+
tests := []struct {
35+
name string
36+
releases []*github.RepositoryRelease
37+
expectedLatest string
38+
expectedError bool
39+
}{
40+
{
41+
name: "returns highest semver",
42+
releases: []*github.RepositoryRelease{
43+
{TagName: github.Ptr("v1.0.0")},
44+
{TagName: github.Ptr("v1.2.3")},
45+
{TagName: github.Ptr("v1.1.0")},
46+
},
47+
expectedLatest: "v1.2.3",
48+
expectedError: false,
49+
},
50+
{
51+
name: "handles complex semver versions",
52+
releases: []*github.RepositoryRelease{
53+
{TagName: github.Ptr("v1.0.0")},
54+
{TagName: github.Ptr("v2.0.0-alpha")},
55+
{TagName: github.Ptr("v2.0.0-beta")},
56+
{TagName: github.Ptr("v2.0.0-rc.1")},
57+
{TagName: github.Ptr("v2.0.0")},
58+
},
59+
expectedLatest: "v2.0.0",
60+
expectedError: false,
61+
},
62+
{
63+
name: "returns error when no releases",
64+
releases: []*github.RepositoryRelease{},
65+
expectedLatest: "",
66+
expectedError: true,
67+
},
68+
{
69+
name: "handles non-semver tags gracefully",
70+
releases: []*github.RepositoryRelease{
71+
{TagName: github.Ptr("not-a-version")},
72+
{TagName: github.Ptr("v1.0.0")},
73+
},
74+
expectedLatest: "v1.0.0",
75+
expectedError: false,
76+
},
77+
}
78+
79+
for _, tt := range tests {
80+
t.Run(tt.name, func(t *testing.T) {
81+
mockRepos := &mockReposService{releases: tt.releases}
82+
mockGh := vrsrgithub.GithubHelper{
83+
Repos: mockRepos,
84+
}
85+
86+
result, err := getLatestVersion("testtool", vrsrgithub.RepoConfDef{}, mockGh)
87+
88+
if tt.expectedError {
89+
if err == nil {
90+
t.Errorf("expected error, got nil")
91+
}
92+
} else {
93+
if err != nil {
94+
t.Errorf("unexpected error: %v", err)
95+
}
96+
if result != tt.expectedLatest {
97+
t.Errorf("expected %s, got %s", tt.expectedLatest, result)
98+
}
99+
}
100+
})
101+
}
102+
}
103+
13104
func TestInstallCommands_EarlyReturnWhenInUseOrInstalled(t *testing.T) {
14105
// setup temp dirs
15106
td := t.TempDir()
@@ -42,7 +133,7 @@ func TestInstallCommands_EarlyReturnWhenInUseOrInstalled(t *testing.T) {
42133
viper.Set("bin-path", binPath)
43134

44135
// calling install Github should return early (tool already in use)
45-
if err := install(&cobra.Command{}, "1.2.3", "mytool", github.RepoConfDef{}, InstallGitHubCmd, false); err != nil {
136+
if err := install(&cobra.Command{}, "1.2.3", "mytool", vrsrgithub.RepoConfDef{}, InstallGitHubCmd, false); err != nil {
46137
t.Fatalf("install Github expected nil error when tool in use, got: %v", err)
47138
}
48139

@@ -52,7 +143,7 @@ func TestInstallCommands_EarlyReturnWhenInUseOrInstalled(t *testing.T) {
52143
}
53144

54145
// calling install Download should return early (tool already installed)
55-
if err := install(&cobra.Command{}, "1.2.3", "mytool", github.RepoConfDef{}, InstallDownloadCmd, false); err != nil {
146+
if err := install(&cobra.Command{}, "1.2.3", "mytool", vrsrgithub.RepoConfDef{}, InstallDownloadCmd, false); err != nil {
56147
t.Fatalf("install Download expected nil error when tool installed, got: %v", err)
57148
}
58149
}

internal/cli/common/list.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ import (
1111
// newGithubListCommand creates a new 'list' command for the specified tool
1212
func newListCommand(tool string) *cobra.Command {
1313
return &cobra.Command{
14-
Use: "list",
15-
Short: fmt.Sprintf("List all installed %s versions", tool),
16-
Long: fmt.Sprintf(`Lists all the %s versions that are currently installed on the system.`, tool),
14+
Use: "list",
15+
Aliases: []string{"ls"},
16+
Short: fmt.Sprintf("List all installed %s versions", tool),
17+
Long: fmt.Sprintf(`Lists all the %s versions that are currently installed on the system.`, tool),
1718
RunE: func(cmd *cobra.Command, args []string) error {
1819
return list(cmd, tool)
1920
},

internal/cli/common/listRemote.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ var (
2121
// newGithubListRemoteCommand creates a new 'list-remote' command for the specified tool
2222
func newGithubListRemoteCommand(tool string, repoConf github.RepoConfDef) *cobra.Command {
2323
listRemoteCmd := &cobra.Command{
24-
Use: "list-remote",
25-
Short: fmt.Sprintf("List all remote %s versions from GitHub (sorted by semver)", tool),
24+
Use: "list-remote",
25+
Aliases: []string{"ls-remote"},
26+
Short: fmt.Sprintf("List all remote %s versions from GitHub (sorted by semver)", tool),
2627
Long: fmt.Sprintf("Lists all the remote %s versions available as GitHub releases (sorted by semver).\n\n"+
2728
"In the list the versions currently installed are marked with a '+' symbol, while the version currently in use is marked with a '*' symbol.\n\n"+
2829
"Note: By default pre-release versions (alpha, beta, rc) are hidden. Use '--devel' to include them", tool),

0 commit comments

Comments
 (0)