diff --git a/README.md b/README.md index 66f85ce..b10c455 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ - [Use the dropdown to select a version](#use-the-dropdown-to-select-a-version) - [See all versions including release candidates (rc)](#see-all-versions-including-release-candidates-rc) - [Install latest version](#install-latest-version) + - [Install specific version](#install-specific-version) + - [Delete unused versions](#delete-unused-versions) - [Refresh version list](#refresh-version-list) - [Help](#help) - [Contributions](#contributions) @@ -81,7 +83,7 @@ $ brew install VassilisPallas/tap/gvs Installation for other linux operation systems. ```sh -$ curl -L https://raw.githubusercontent.com/VassilisPallas/gvs/main/install.sh | bash +$ curl -L https://raw.githubusercontent.com/VassilisPallas/gvs/HEAD/install.sh | bash ``` ### Install from source @@ -151,6 +153,26 @@ Installing version... 1.21.3 version is installed! ``` +### Install specific version + +In order to install a specific version without using the dropdown, use the `--install-version=value`. + +```sh +$ gvs --install-version=1.21.3 +Downloading... +Compare Checksums... +Unzipping... +Installing version... +1.21.3 version is installed! +``` + +If the `Minor` version is not specified (`--install-version=1`), the latest `Minor` version is selected from the given `Major` version. + +If the `Patch` version is not specified (`--install-version=1.21`), the latest `Patch` version is selected from the given version. + +You can also pass Release Candidates, like `1.21rc2`. + + ### Delete unused versions Every time you install a new version, gvs keeps the previous installed versions, so you can easily change between them. If you want to delete all the unused versions and keep only the current one, use the `--delete-unused` flag. diff --git a/cmd/gvs/main.go b/cmd/gvs/main.go index 7501367..8989a67 100644 --- a/cmd/gvs/main.go +++ b/cmd/gvs/main.go @@ -1,19 +1,16 @@ package main import ( - "flag" - "fmt" "net/http" "os" "runtime" "time" - terminalColors "github.com/fatih/color" - "github.com/VassilisPallas/gvs/api_client" "github.com/VassilisPallas/gvs/clock" cf "github.com/VassilisPallas/gvs/config" "github.com/VassilisPallas/gvs/files" + "github.com/VassilisPallas/gvs/flags" "github.com/VassilisPallas/gvs/install" "github.com/VassilisPallas/gvs/logger" "github.com/VassilisPallas/gvs/pkg/unzip" @@ -26,48 +23,18 @@ var ( installLatest = false deleteUnused = false showAllVersions = false + specificVersion = "" ) -func getBold() *terminalColors.Color { - return terminalColors.New().Add(terminalColors.Bold) -} - func parseFlags() { - flag.BoolVar(&showAllVersions, "show-all", false, "Show both stable and unstable versions.") - flag.BoolVar(&installLatest, "install-latest", false, "Install latest stable version.") - flag.BoolVar(&deleteUnused, "delete-unused", false, "Delete all unused versions that were installed before.") - flag.BoolVar(&refreshVersions, "refresh-versions", false, "Fetch again go versions in case the cached ones are stale.") - - flag.Usage = func() { - bold := getBold() - gvsMessage := bold.Sprint("gvs") - - flagSet := flag.CommandLine - - fmt.Println() - bold.Println("NAME") - fmt.Printf(" gvs\n\n") - - bold.Println("DESCRIPTION") - fmt.Printf(" the %s CLI is a command line tool to manage multiple active Go versions.\n\n", gvsMessage) - - bold.Println("SYNOPSIS") - fmt.Printf(" gvs\n [--show-all]\n [--install-latest]\n [--delete-unused]\n [--refresh-versions]\n\n") - - bold.Println("FLAGS") - flags := []string{"show-all", "install-latest", "delete-unused", "refresh-versions"} - for _, name := range flags { - flag := flagSet.Lookup(name) - fmt.Printf(" --%s\n\t%s\n", flag.Name, flag.Usage) - } - fmt.Println() - - fmt.Printf("Before start using the %s CLI, make sure to delete all the existing go versions\n", gvsMessage) - fmt.Printf("and append to your profile file the export: %q.\n", "export PATH=$PATH:$HOME/bin") - fmt.Printf("The profile file could be one of: (%s)\n", "~/.bash_profile, ~/.zshrc, ~/.profile, or ~/.bashrc") - } - - flag.Parse() + set := flags.FlagSet{} + set.FlagBool(&showAllVersions, "show-all", false, "Show both stable and unstable versions.") + set.FlagBool(&installLatest, "install-latest", false, "Install latest stable version.") + set.FlagBool(&deleteUnused, "delete-unused", false, "Delete all unused versions that were installed before.") + set.FlagBool(&refreshVersions, "refresh-versions", false, "Fetch again go versions in case the cached ones are stale.") + set.FlagStr(&specificVersion, "install-version", "", "Pass the version you want to install instead of selecting from the dropdown. If you do not specify the minor or the patch version, the latest one will be selected.") + + set.Parse() } func main() { @@ -110,6 +77,29 @@ func main() { } switch { + case specificVersion != "": + semver := &version.Semver{} + err := version.ParseSemver(specificVersion, semver) + if err != nil { + log.PrintError(err.Error()) + os.Exit(1) + return + } + + selectedVersion := versioner.FindVersionBasedOnSemverName(versions, semver) + if selectedVersion == nil { + log.PrintError("%s is not a valid version.", semver.GetVersion()) + os.Exit(1) + return + } + + err = versioner.Install(selectedVersion, runtime.GOOS, runtime.GOARCH) + + if err != nil { + log.PrintError(err.Error()) + os.Exit(1) + return + } case deleteUnused: log.Info("deleteUnused option selected") diff --git a/flags/flags.go b/flags/flags.go new file mode 100644 index 0000000..5124aec --- /dev/null +++ b/flags/flags.go @@ -0,0 +1,107 @@ +package flags + +import ( + "flag" + "fmt" + + terminalColors "github.com/fatih/color" +) + +// Flag contains the information for the flag. +type Flag struct { + // The name of the flag + name string + + // A boolean that represents if the flag expects a value to be passed. + // + // e.g. --install-version 21.3 + acceptsVale bool +} + +// getHelpName returns the name for the flag and an indicator +// if the flag expects a value of not. +// +// For example, when a flag does not expect a value the result will be like: --some-flag. +// If the flag expects a value, the result will be like: --some-flag=value +func (f Flag) getHelpName() string { + flagName := fmt.Sprintf("--%s", f.name) + if f.acceptsVale { + flagName += "=value" + } + + return flagName +} + +// FlagSet is the struct that will be used to set up the flags for the CLI. +type FlagSet struct { + // An array of the flags that are available. + // flags is used as a store to easy iterate on the flags. + flags []Flag +} + +// FlagBool defines a bool flag with specified name, default value, and usage string. +// The argument p points to a bool variable in which to store the value of the flag. +// FlagBool also appends the flag to the FlagSet array. +func (s *FlagSet) FlagBool(p *bool, name string, value bool, usage string) { + s.flags = append(s.flags, Flag{name: name, acceptsVale: false}) + flag.BoolVar(p, name, value, usage) +} + +// StringVar defines a string flag with specified name, default value, and usage string. +// The argument p points to a string variable in which to store the value of the flag. +// FlagBool also appends the flag to the FlagSet array. +func (s *FlagSet) FlagStr(p *string, name string, value string, usage string) { + s.flags = append(s.flags, Flag{name: name, acceptsVale: true}) + flag.StringVar(p, name, value, usage) +} + +// printSynopsis returns back all the available flags without any description. +// All the flags are iterated from FlagSet array that contains the flags. +func (s *FlagSet) printSynopsis() { + msg := " gvs\n" + for _, flag := range s.flags { + msg += fmt.Sprintf(" [%s]\n", flag.getHelpName()) + } + + fmt.Printf("%s\n", msg) +} + +// printFlags returns back all the available flags wiath a description. +// All the flags are iterated from FlagSet array that contains the flags. +func (s *FlagSet) printFlags() { + flagSet := flag.CommandLine + + for _, flag := range s.flags { + flagInfo := flagSet.Lookup(flag.name) + fmt.Printf(" %s\n\t%s\n", flag.getHelpName(), flagInfo.Usage) + } +} + +// Parse is preparing the help command and parses the flags. +func (s *FlagSet) Parse() { + flag.Usage = func() { + bold := terminalColors.New().Add(terminalColors.Bold) + + gvsMessage := bold.Sprint("gvs") + + fmt.Println() + bold.Println("NAME") + fmt.Printf(" gvs - go version manager\n\n") + + bold.Println("DESCRIPTION") + fmt.Printf(" the %s CLI is a command line tool to manage multiple active Go versions.\n\n", gvsMessage) + + bold.Println("SYNOPSIS") + s.printSynopsis() + + bold.Println("FLAGS") + s.printFlags() + + fmt.Println() + fmt.Printf("Before start using the %s CLI, make sure to delete all the existing go versions\n", gvsMessage) + fmt.Printf("and append to your profile file the export: %q.\n", "export PATH=$PATH:$HOME/bin") + fmt.Printf("The profile file could be one of: (%s)\n", "~/.bash_profile, ~/.zshrc, ~/.profile, or ~/.bashrc") + } + + flag.Parse() +} diff --git a/install/install.go b/install/install.go index 34dd76d..fd5d1f1 100644 --- a/install/install.go +++ b/install/install.go @@ -4,7 +4,6 @@ package install import ( "context" - "fmt" "io" "github.com/VassilisPallas/gvs/api_client" @@ -112,7 +111,6 @@ func (i Install) newVersionHandler(checksum string, goVersionName string) func(c i.log.PrintMessage("Unzipping...\n") if err = i.fileHelpers.UnzipTarFile(); err != nil { - fmt.Println(err) return err } diff --git a/version/semver.go b/version/semver.go new file mode 100644 index 0000000..9d5d305 --- /dev/null +++ b/version/semver.go @@ -0,0 +1,98 @@ +// Package version provides an interface to make handle +// the CLI logic for the versions. +package version + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" +) + +// Semver contains the version split into semantic version structure. +// Semver can have either a patch or a release candidate value, not both at the same time. +type Semver struct { + // Contains the major version of the semantic structure. + Major *uint64 + + // Contains the minor version of the semantic structure. + Minor *uint64 + + // Contains the patch version of the semantic structure. + Patch *uint64 + + // Contains the release candidate number of the semantic structure. + ReleaseCandidate *uint64 +} + +// GetVersion returns back the stringified representation of the semantic version structure. +func (s Semver) GetVersion() string { + semver := "" + + if s.Major == nil { + return "" + } + + semver += fmt.Sprint(*s.Major) + + if s.Minor != nil { + semver += fmt.Sprintf(".%d", *s.Minor) + } + + if s.Patch != nil { + semver += fmt.Sprintf(".%d", *s.Patch) + } else if s.ReleaseCandidate != nil { + semver += fmt.Sprintf("rc%d", *s.ReleaseCandidate) + } + + return semver +} + +// parseNumber converts a string into *uint64. +// In case of an error while converting the value, parseNumber return nil. +func parseNumber(str string) *uint64 { + num, err := strconv.ParseUint(str, 10, 64) + + if err != nil { + return nil + } + + return &num +} + +// ParseSemver parses the given stringified version into a semantic version structure. +// +// If the parse is successful it will store it +// in the value pointed to by semver. +// +// If the parse fails, ParseSemver will return an error. +func ParseSemver(version string, semver *Semver) error { + var major *uint64 + var minor *uint64 + var patch *uint64 + var rc *uint64 + + r := regexp.MustCompile(`(\d{1,2})(\.?\d{1,2})?(\.?\d{1,2}|rc\d{1,2})?`) + + if !r.MatchString(version) { + return errors.New("invalid Go version") + } + + groups := r.FindStringSubmatch(version)[1:] + major = parseNumber(groups[0]) + minor = parseNumber(strings.TrimPrefix(groups[1], ".")) + + if strings.HasPrefix(groups[2], "rc") { + rc = parseNumber(strings.TrimPrefix(groups[2], "rc")) + } else { + patch = parseNumber(strings.TrimPrefix(groups[2], ".")) + } + + semver.Major = major + semver.Minor = minor + semver.Patch = patch + semver.ReleaseCandidate = rc + + return nil +} diff --git a/version/semver_test.go b/version/semver_test.go new file mode 100644 index 0000000..3007f40 --- /dev/null +++ b/version/semver_test.go @@ -0,0 +1,119 @@ +package version_test + +import ( + "errors" + "testing" + + "github.com/VassilisPallas/gvs/version" + "github.com/google/go-cmp/cmp" +) + +func intToUnsigned(num int64) *uint64 { + res := uint64(num) + return &res +} + +func TestParseSemver(t *testing.T) { + testCases := []struct { + testTitle string + version string + expectedSemver *version.Semver + expectedError error + }{ + { + testTitle: "should return an error when regex does not compile the passed version", + version: "some-version", + expectedSemver: &version.Semver{}, + expectedError: errors.New("invalid Go version"), + }, + { + testTitle: "should parse only the major version", + version: "1", + expectedSemver: &version.Semver{Major: intToUnsigned(1)}, + expectedError: nil, + }, + { + testTitle: "should parse minor version", + version: "1.23", + expectedSemver: &version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(23)}, + expectedError: nil, + }, + { + testTitle: "should parse patch version", + version: "1.23.3", + expectedSemver: &version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(23), Patch: intToUnsigned(3)}, + expectedError: nil, + }, + { + testTitle: "should parse rc version", + version: "1.23rc2", + expectedSemver: &version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(23), ReleaseCandidate: intToUnsigned(2)}, + expectedError: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.testTitle, func(t *testing.T) { + semver := &version.Semver{} + err := version.ParseSemver(tc.version, semver) + + if tc.expectedError == nil && err != nil { + t.Errorf("error should be nil, instead got %q", err.Error()) + return + } + + if tc.expectedError != nil && err.Error() != tc.expectedError.Error() { + t.Errorf("error should be %q, instead got %q", tc.expectedError.Error(), err.Error()) + return + } + + if !cmp.Equal(tc.expectedSemver, semver) { + t.Errorf("Wrong object received, got=%s", cmp.Diff(tc.expectedSemver, semver)) + } + }) + } +} + +func TestGetVersion(t *testing.T) { + testCases := []struct { + testTitle string + semver *version.Semver + expectedVersion string + }{ + { + testTitle: "should return an empty string when major is not specified", + semver: &version.Semver{}, + expectedVersion: "", + }, + { + testTitle: "should return the major version", + semver: &version.Semver{Major: intToUnsigned(1)}, + expectedVersion: "1", + }, + { + testTitle: "should return the major and the minor version", + semver: &version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(23)}, + expectedVersion: "1.23", + }, + { + testTitle: "should return the major, minor and the patch version", + semver: &version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(23), Patch: intToUnsigned(3)}, + expectedVersion: "1.23.3", + }, + { + testTitle: "should return the major, minor and the rc version", + semver: &version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(23), ReleaseCandidate: intToUnsigned(2)}, + expectedVersion: "1.23rc2", + }, + } + + for _, tc := range testCases { + t.Run(tc.testTitle, func(t *testing.T) { + res := tc.semver.GetVersion() + + if res != tc.expectedVersion { + t.Errorf("version should be %q, instead got %q", tc.expectedVersion, res) + } + }) + } +} diff --git a/version/version.go b/version/version.go index e0d6789..8344620 100644 --- a/version/version.go +++ b/version/version.go @@ -45,6 +45,12 @@ type Versioner interface { // FilterAlreadyDownloadedVersions returns the versions that are already installed. // FilterAlreadyDownloadedVersions must return a slice of string. FilterAlreadyDownloadedVersions(evs []*ExtendedVersion) []string + + // FindVersionBasedOnSemverName returns the version that is described in the semver. + // In case one of minor or patch version number is missing from the semver, + // FindVersionBasedOnSemverName should return the latest value of them. + // If the version is not found, FindVersionBasedOnSemverName should return nil. + FindVersionBasedOnSemverName(evs []*ExtendedVersion, version *Semver) *ExtendedVersion } // Version is the struct that implements the Versioner interface. @@ -289,6 +295,23 @@ func (v Version) GetPromptVersions(evs []*ExtendedVersion, showAllVersions bool) return filteredVersions } +// FindVersionBasedOnSemverName returns the version that is described in the semver. +// It compares the stringified semver version as a prefix for each one of the versions. +// FindVersionBasedOnSemverName returns back the first occurrence of that version, which ensures the +// latest version will be returned when minor or patch versions are not assigned to the semver. +// If the version is not found, FindVersionBasedOnSemverName return back nil. +func (v Version) FindVersionBasedOnSemverName(evs []*ExtendedVersion, version *Semver) *ExtendedVersion { + expectedVersion := version.GetVersion() + + for _, ev := range evs { + if strings.HasPrefix(ev.getCleanVersionName(), expectedVersion) { + return ev + } + } + + return nil +} + // New returns a Version instance that implements the Versioner interface. // Each call to New returns a distinct Version instance even if the parameters are identical. func New(fileHelpers files.FileHelpers, clientAPI api_client.GoClientAPI, installer install.Installer, logger *logger.Log) Version { diff --git a/version/version_test.go b/version/version_test.go index 28f07f3..6158184 100644 --- a/version/version_test.go +++ b/version/version_test.go @@ -1146,3 +1146,111 @@ func TestExtendedVersionGetPromptNameShowStable(t *testing.T) { }) } } + +func TestFindVersionBasedOnSemverName(t *testing.T) { + versions := []*version.ExtendedVersion{ + { + UsedVersion: false, + AlreadyInstalled: true, + VersionInfo: api_client.VersionInfo{ + Version: "go1.21.3", + IsStable: true, + Files: []api_client.FileInformation{}, + }, + }, + { + UsedVersion: false, + AlreadyInstalled: true, + VersionInfo: api_client.VersionInfo{ + Version: "go1.21.0", + IsStable: true, + Files: []api_client.FileInformation{}, + }, + }, + { + UsedVersion: true, + AlreadyInstalled: true, + VersionInfo: api_client.VersionInfo{ + Version: "go1.20.8", + IsStable: true, + Files: []api_client.FileInformation{}, + }, + }, + { + UsedVersion: false, + AlreadyInstalled: true, + VersionInfo: api_client.VersionInfo{ + Version: "go1.20.7", + IsStable: true, + Files: []api_client.FileInformation{}, + }, + }, + { + UsedVersion: false, + AlreadyInstalled: false, + VersionInfo: api_client.VersionInfo{ + Version: "go1.20.6", + IsStable: true, + Files: []api_client.FileInformation{}, + }, + }, + { + UsedVersion: false, + AlreadyInstalled: false, + VersionInfo: api_client.VersionInfo{ + Version: "go1.20rc2", + IsStable: true, + Files: []api_client.FileInformation{}, + }, + }, + } + + testCases := []struct { + testTitle string + semver version.Semver + expecectedVersion *version.ExtendedVersion + }{ + { + testTitle: "should return the correct version when semver contains only the major version", + semver: version.Semver{Major: intToUnsigned(1)}, + expecectedVersion: versions[0], + }, + { + testTitle: "should return the correct version when semver contains the major and the minor version", + semver: version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(20)}, + expecectedVersion: versions[2], + }, + { + testTitle: "should return the correct version when semver contains the major, the minor and the patch version", + semver: version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(20), Patch: intToUnsigned(6)}, + expecectedVersion: versions[4], + }, + { + testTitle: "should return the correct version when semver contains the major, the minor and the rc version", + semver: version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(20), ReleaseCandidate: intToUnsigned(2)}, + expecectedVersion: versions[5], + }, + { + testTitle: "should return the nil when the version is not found", + semver: version.Semver{Major: intToUnsigned(1), Minor: intToUnsigned(30)}, + expecectedVersion: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.testTitle, func(t *testing.T) { + fileHelpers := &testutils.FakeFilesHelper{} + clientAPI := testutils.FakeGoClientAPI{} + installer := &testutils.FakeInstaller{} + log := logger.New(&testutils.FakeStdout{}, nil) + + versioner := version.New(fileHelpers, clientAPI, installer, log) + + res := versioner.FindVersionBasedOnSemverName(versions, &tc.semver) + + if !cmp.Equal(res, tc.expecectedVersion) { + t.Errorf("Wrong array received, got=%s", cmp.Diff(res, tc.expecectedVersion)) + } + }) + } +}