diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 69a09b4..6f2fa9d 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Go environment uses: actions/setup-go@v4 with: - go-version: '1.22' + go-version: '1.23' - name: Test run: go test -v ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a3a83..2de664f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* Added a `version` command to display the current version of the Parsley CLI application. The command can also check for new versions by querying the latest release information from GitHub, notifying users if an update is available and providing instructions on how to update. + ### Fixed * Fixes matching of expected arguments in `mock_verify.go` diff --git a/cmd/parsley-cli/main.go b/cmd/parsley-cli/main.go index ea86809..ba324ea 100644 --- a/cmd/parsley-cli/main.go +++ b/cmd/parsley-cli/main.go @@ -16,7 +16,8 @@ func main() { app := charmer.NewCommandLineApplication("parsley-cli", description) app.AddCommand( - commands.NewInitCommand()) + commands.NewInitCommand(), + commands.NewVersionCommand()) app.AddGroupCommand( commands.NewGenerateGroupCommand(), diff --git a/go.mod b/go.mod index 2f4cf5c..7b44f5f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/matzefriedrich/parsley go 1.23 require ( + github.com/hashicorp/go-version v1.7.0 github.com/matzefriedrich/cobra-extensions v0.2.6 github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.8.1 diff --git a/go.sum b/go.sum index 38fd8ce..75de73e 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/matzefriedrich/cobra-extensions v0.2.6 h1:tn4w3lpEGu7eMmk4mrHF+jNdTUQKT/8P6Y7q14ZLfFI= diff --git a/internal/commands/init_command.go b/internal/commands/init_command.go index 1c0a949..bc805b2 100644 --- a/internal/commands/init_command.go +++ b/internal/commands/init_command.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "github.com/matzefriedrich/parsley/internal/utils" "os" "github.com/matzefriedrich/cobra-extensions/pkg" @@ -23,9 +24,9 @@ func (g *initCommand) Execute() { return } - const minVersion = "v0.9.1" + minVersion, _ := utils.ApplicationVersion() const packageName = "github.com/matzefriedrich/parsley" - dependencyErr := p.AddDependency(packageName, minVersion) + dependencyErr := p.AddDependency(packageName, minVersion.String()) if dependencyErr != nil { fmt.Println(err) return diff --git a/internal/commands/version_command.go b/internal/commands/version_command.go new file mode 100644 index 0000000..13e92a9 --- /dev/null +++ b/internal/commands/version_command.go @@ -0,0 +1,60 @@ +package commands + +import ( + "context" + "fmt" + "github.com/matzefriedrich/cobra-extensions/pkg" + "github.com/matzefriedrich/cobra-extensions/pkg/abstractions" + "github.com/matzefriedrich/parsley/internal/utils" + "github.com/spf13/cobra" +) + +type versionCommand struct { + use abstractions.CommandName `flag:"version" short:"Show the current Parsley CLI version"` + CheckForUpdate bool `flag:"check-update" usage:"Checks for available updates and prints the update command"` +} + +func (v *versionCommand) Execute() { + + appVersion, appVersionErr := utils.ApplicationVersion() + if appVersionErr == nil { + fmt.Printf("Parsley CLI v%s\n", appVersion.String()) + } + + if v.CheckForUpdate == false { + return + } + + githubClient := utils.NewGitHubApiClient() + release, err := githubClient.QueryLatestReleaseTag(context.Background()) + if err != nil { + return + } + + releaseVersion, releaseVersionErr := release.TryParseVersionFromTag() + if appVersionErr == nil && releaseVersionErr == nil { + if appVersion.LessThan(*releaseVersion) { + + fmt.Printf("\n"+ + "Your version of Parsley CLI is out of date!\n\n"+ + "The latest version is: v%s.\n"+ + "To update run the following command: "+ + "go install github.com/matzefriedrich/parsley/cmd/parsley-cli@v%s\n\n", releaseVersion.String(), releaseVersion.String()) + + fmt.Printf("More information about the release %s is available at:\n%s\n", release.Name, release.HtmlUrl) + + } else if appVersion.Equal(*releaseVersion) { + + fmt.Printf("\n" + + "You are using the latest version of Parsley CLI.\n\n") + + } + } +} + +var _ pkg.TypedCommand = (*versionCommand)(nil) + +func NewVersionCommand() *cobra.Command { + command := &versionCommand{} + return pkg.CreateTypedCommand(command) +} diff --git a/internal/tests/core/version_test.go b/internal/tests/core/version_test.go new file mode 100644 index 0000000..ce8e996 --- /dev/null +++ b/internal/tests/core/version_test.go @@ -0,0 +1,55 @@ +package core + +import ( + "github.com/matzefriedrich/parsley/internal/utils" + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_Version_parse_version_from_github_release(t *testing.T) { + + // Arrange + const version = "1.2.3" + release := utils.GithubRelease{TagName: version} + + const expectedVersionString = "1.2.3" + + // Act + actual, err := release.TryParseVersionFromTag() + + // Assert + assert.NoError(t, err) + assert.Equal(t, expectedVersionString, actual.String()) +} + +func Test_Version_parse_prefixed_version_from_github_release(t *testing.T) { + + // Arrange + const version = "v1.2.3" + release := utils.GithubRelease{TagName: version} + + const expectedVersionString = "1.2.3" + + // Act + actual, err := release.TryParseVersionFromTag() + + // Assert + assert.NoError(t, err) + assert.Equal(t, expectedVersionString, actual.String()) +} + +func Test_Version_parse_prefixed_prerelease_version_from_github_release(t *testing.T) { + + // Arrange + const version = "v1.2.3-alpha.1" + release := utils.GithubRelease{TagName: version} + + const expectedVersionString = "1.2.3" + + // Act + actual, err := release.TryParseVersionFromTag() + + // Assert + assert.NoError(t, err) + assert.Equal(t, expectedVersionString, actual.String()) +} diff --git a/internal/utils/github_api_client.go b/internal/utils/github_api_client.go new file mode 100644 index 0000000..9ede014 --- /dev/null +++ b/internal/utils/github_api_client.go @@ -0,0 +1,85 @@ +package utils + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" +) + +type GithubRelease struct { + Id uint64 `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + HtmlUrl string `json:"html_url"` + PublishedAt time.Time `json:"published_at"` +} + +func (r GithubRelease) TryParseVersionFromTag() (*VersionInfo, error) { + version := r.TagName + return tryParseVersionInfo(version) +} + +type githubApiClient struct { + options HttpClientOptions +} + +type HttpClientOptions struct { + RequestTimeout time.Duration +} + +type HttpClientOptionsFunc func(*HttpClientOptions) + +func NewGitHubApiClient(config ...HttpClientOptionsFunc) *githubApiClient { + options := HttpClientOptions{ + RequestTimeout: 5 * time.Second, + } + for _, optionsFunc := range config { + optionsFunc(&options) + } + return &githubApiClient{ + options: options, + } +} + +// QueryLatestReleaseTag Queries the latest version from the GitHub releases endpoint and compares it against the current application version. +func (c *githubApiClient) QueryLatestReleaseTag(ctx context.Context) (*GithubRelease, error) { + + const owner = "matzefriedrich" + const repo = "parsley" + + requestCtx, cancel := context.WithTimeout(ctx, c.options.RequestTimeout) + defer cancel() + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases", owner, repo) + request, err := http.NewRequestWithContext(requestCtx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + client := &http.Client{} + response, requestErr := client.Do(request) + if requestErr != nil { + return nil, requestErr + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch latest release: %s", response.Status) + } + + var releases []GithubRelease + if unmarshalErr := json.NewDecoder(response.Body).Decode(&releases); unmarshalErr != nil { + return nil, err + } + + if len(releases) > 0 { + latestRelease := releases[0] + return &latestRelease, nil + } + + return nil, errors.New("failed to retrieve release information") +} diff --git a/internal/utils/version.go b/internal/utils/version.go new file mode 100644 index 0000000..e482873 --- /dev/null +++ b/internal/utils/version.go @@ -0,0 +1,79 @@ +package utils + +import ( + "errors" + "fmt" + "regexp" + "strconv" + + "github.com/hashicorp/go-version" +) + +const ( + VersionString string = "0.9.2" +) + +type VersionInfo struct { + Major int + Minor int + Patch int +} + +func (v VersionInfo) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +func (v VersionInfo) LessThan(other VersionInfo) bool { + a, _ := version.NewVersion(v.String()) + b, _ := version.NewVersion(other.String()) + return a.LessThan(b) +} + +func (v VersionInfo) Equal(other VersionInfo) bool { + a, _ := version.NewVersion(v.String()) + b, _ := version.NewVersion(other.String()) + return a.Equal(b) +} + +func ApplicationVersion() (*VersionInfo, error) { + version, err := tryParseVersionInfo(VersionString) + if err != nil { + return nil, errors.New("application version not set") + } + return version, nil +} + +func tryParseVersionInfo(version string) (*VersionInfo, error) { + + re := regexp.MustCompile("(?:[vV])?(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)") + match := re.FindStringSubmatch(version) + if match == nil { + return nil, errors.New("invalid version") + } + + extracted := map[string]string{} + names := re.SubexpNames() + for _, name := range names { + index := re.SubexpIndex(name) + if index != -1 && len(name) > 0 { + extracted[name] = match[index] + } + } + + readInt := func(name string) int { + value, found := extracted[name] + if found { + n, err := strconv.Atoi(value) + if err == nil { + return n + } + } + return 0 + } + + major := readInt("major") + minor := readInt("minor") + patch := readInt("patch") + + return &VersionInfo{Major: major, Minor: minor, Patch: patch}, nil +}