diff --git a/cmd/release_utils.go b/cli/asset.go similarity index 69% rename from cmd/release_utils.go rename to cli/asset.go index 0caad8fc0..19e3a16a3 100644 --- a/cmd/release_utils.go +++ b/cli/asset.go @@ -1,20 +1,20 @@ -package cmd +package cli import ( "bytes" "fmt" "io/ioutil" "net/http" - "strings" ) -type asset struct { +// Asset is a build for a particular system, uploaded to a GitHub release. +type Asset struct { ID int `json:"id"` Name string `json:"name"` ContentType string `json:"content_type"` } -func (a *asset) download() (*bytes.Reader, error) { +func (a *Asset) download() (*bytes.Reader, error) { downloadURL := fmt.Sprintf("https://api.github.com/repos/exercism/cli/releases/assets/%d", a.ID) req, err := http.NewRequest("GET", downloadURL, nil) if err != nil { @@ -35,13 +35,3 @@ func (a *asset) download() (*bytes.Reader, error) { return bytes.NewReader(bs), nil } - -type release struct { - Location string `json:"html_url"` - TagName string `json:"tag_name"` - Assets []asset `json:"assets"` -} - -func (r *release) Version() string { - return strings.TrimPrefix(r.TagName, "v") -} diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 000000000..439bd679e --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,180 @@ +package cli + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "runtime" + "strings" + "time" + + "github.com/blang/semver" + "github.com/exercism/cli/debug" + update "github.com/inconshreveable/go-update" +) + +var ( + // BuildOS is the operating system (GOOS) used during the build process. + BuildOS string + // BuildARM is the ARM version (GOARM) used during the build process. + BuildARM string + // BuildARCH is the architecture (GOARCH) used during the build process. + BuildARCH string +) + +var ( + osMap = map[string]string{ + "darwin": "mac", + "linux": "linux", + "windows": "windows", + } + + archMap = map[string]string{ + "amd64": "64bit", + "386": "32bit", + "arm": "arm", + } +) + +var ( + // HTTPClient is the client used to make HTTP calls in the cli package. + HTTPClient = &http.Client{Timeout: 10 * time.Second} + // LatestReleaseURL is the endpoint that provides information about the latest release. + LatestReleaseURL = "https://api.github.com/repos/exercism/cli/releases/latest" +) + +// CLI is information about the CLI itself. +type CLI struct { + Version string + LatestRelease *Release +} + +// New creates a CLI, setting it to a particular version. +func New(version string) *CLI { + return &CLI{ + Version: version, + } +} + +// IsUpToDate compares the current version to that of the latest release. +func (c *CLI) IsUpToDate() (bool, error) { + if c.LatestRelease == nil { + resp, err := HTTPClient.Get(LatestReleaseURL) + if err != nil { + return false, err + } + defer resp.Body.Close() + + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return false, err + } + c.LatestRelease = &rel + } + + rv, err := semver.Make(c.LatestRelease.Version()) + if err != nil { + return false, fmt.Errorf("unable to parse latest version (%s): %s", c.LatestRelease.Version(), err) + } + cv, err := semver.Make(c.Version) + if err != nil { + return false, fmt.Errorf("unable to parse current version (%s): %s", c.Version, err) + } + + return rv.EQ(cv), nil +} + +// Upgrade allows the user to upgrade to the latest version of the CLI. +func (c *CLI) Upgrade() error { + var ( + OS = osMap[runtime.GOOS] + ARCH = archMap[runtime.GOARCH] + ) + + if OS == "" || ARCH == "" { + return fmt.Errorf("unable to upgrade: OS %s ARCH %s", OS, ARCH) + } + + buildName := fmt.Sprintf("%s-%s", OS, ARCH) + if BuildARCH == "arm" { + if BuildARM == "" { + return fmt.Errorf("unable to upgrade: arm version not found") + } + buildName = fmt.Sprintf("%s-v%s", buildName, BuildARM) + } + + var downloadRC *bytes.Reader + for _, a := range c.LatestRelease.Assets { + if strings.Contains(a.Name, buildName) { + debug.Printf("Downloading %s\n", a.Name) + var err error + downloadRC, err = a.download() + if err != nil { + return fmt.Errorf("error downloading executable: %s", err) + } + break + } + } + if downloadRC == nil { + return fmt.Errorf("no executable found for %s/%s%s", BuildOS, BuildARCH, BuildARM) + } + + bin, err := extractBinary(downloadRC, OS) + if err != nil { + return err + } + defer bin.Close() + + return update.Apply(bin, update.Options{}) +} + +func extractBinary(source *bytes.Reader, os string) (binary io.ReadCloser, err error) { + if os == "windows" { + zr, err := zip.NewReader(source, int64(source.Len())) + if err != nil { + return nil, err + } + + for _, f := range zr.File { + return f.Open() + } + } else { + gr, err := gzip.NewReader(source) + if err != nil { + return nil, err + } + defer gr.Close() + + tr := tar.NewReader(gr) + for { + _, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + tmpfile, err := ioutil.TempFile("", "temp-exercism") + if err != nil { + return nil, err + } + + if _, err = io.Copy(tmpfile, tr); err != nil { + return nil, err + } + if _, err := tmpfile.Seek(0, 0); err != nil { + return nil, err + } + + binary = tmpfile + } + } + + return binary, nil +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 000000000..0ea8aca8c --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,50 @@ +package cli + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsUpToDate(t *testing.T) { + tests := []struct { + cliVersion string + releaseTag string + ok bool + }{ + {"1.0.0", "v1.0.1", false}, + {"2.0.1", "v2.0.1", true}, + } + + for _, test := range tests { + c := &CLI{ + Version: test.cliVersion, + LatestRelease: &Release{TagName: test.releaseTag}, + } + + ok, err := c.IsUpToDate() + assert.NoError(t, err) + assert.Equal(t, test.ok, ok, test.cliVersion) + } +} + +func TestIsUpToDateWithoutRelease(t *testing.T) { + fakeEndpoint := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintln(w, `{"tag_name": "v2.0.0"}`) + }) + ts := httptest.NewServer(fakeEndpoint) + defer ts.Close() + LatestReleaseURL = ts.URL + + c := &CLI{ + Version: "1.0.0", + } + + ok, err := c.IsUpToDate() + assert.NoError(t, err) + assert.False(t, ok) + assert.NotNil(t, c.LatestRelease) +} diff --git a/cli/release.go b/cli/release.go new file mode 100644 index 000000000..973057f4e --- /dev/null +++ b/cli/release.go @@ -0,0 +1,15 @@ +package cli + +import "strings" + +// Release is a specific build of the CLI, released on GitHub. +type Release struct { + Location string `json:"html_url"` + TagName string `json:"tag_name"` + Assets []Asset `json:"assets"` +} + +// Version is the CLI version that is built for the release. +func (r *Release) Version() string { + return strings.TrimPrefix(r.TagName, "v") +} diff --git a/cmd/configure.go b/cmd/configure.go index ddcf19fa0..eef04ea95 100644 --- a/cmd/configure.go +++ b/cmd/configure.go @@ -6,13 +6,13 @@ import ( "os" "github.com/exercism/cli/config" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) // Configure stores settings in a JSON file. // If a setting is not passed as an argument, default // values are used. -func Configure(ctx *cli.Context) error { +func Configure(ctx *app.Context) error { c, err := config.New(ctx.GlobalString("config")) if err != nil { log.Fatal(err) diff --git a/cmd/debug.go b/cmd/debug.go index 6d8766dd2..f6f64b6a2 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -10,9 +10,10 @@ import ( "sync" "time" + "github.com/exercism/cli/cli" "github.com/exercism/cli/config" "github.com/exercism/cli/paths" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) type pingResult struct { @@ -23,32 +24,30 @@ type pingResult struct { } // Debug provides information about the user's environment and configuration. -func Debug(ctx *cli.Context) error { +func Debug(ctx *app.Context) error { defer fmt.Printf("\nIf you are having trouble and need to file a GitHub issue (https://github.com/exercism/exercism.io/issues) please include this information (except your API key. Keep that private).\n") client := &http.Client{Timeout: 20 * time.Second} + cli.HTTPClient = client fmt.Printf("\n**** Debug Information ****\n") fmt.Printf("Exercism CLI Version: %s\n", ctx.App.Version) - u, err := NewUpgrader(client) + self := cli.New(ctx.App.Version) + ok, err := self.IsUpToDate() if err != nil { log.Println("unable to fetch latest release: " + err.Error()) } else { - rel := u.release - needed, err := u.IsUpgradeNeeded(ctx.App.Version) - if err != nil { - log.Printf("unable to check semver: %s\n", err) - } else if needed { - defer fmt.Printf("\nA newer version of the CLI (%s) can be downloaded here: %s\n", rel.TagName, rel.Location) + if !ok { + defer fmt.Printf("\nA newer version of the CLI (%s) can be downloaded here: %s\n", self.LatestRelease.TagName, self.LatestRelease.Location) } - fmt.Printf("Exercism CLI Latest Release: %s\n", rel.Version()) + fmt.Printf("Exercism CLI Latest Release: %s\n", self.LatestRelease.Version()) } fmt.Printf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH) - fmt.Printf("Build OS/Architecture %s/%s\n", BuildOS, BuildARCH) - if BuildARM != "" { - fmt.Printf("Build ARMv%s\n", BuildARM) + fmt.Printf("Build OS/Architecture %s/%s\n", cli.BuildOS, cli.BuildARCH) + if cli.BuildARM != "" { + fmt.Printf("Build ARMv%s\n", cli.BuildARM) } fmt.Printf("Home Dir: %s\n", paths.Home) @@ -119,7 +118,7 @@ func Debug(ctx *cli.Context) error { return nil } -func printConfigFileData(ctx *cli.Context, cfg *config.Config) error { +func printConfigFileData(ctx *app.Context, cfg *config.Config) error { configured := true if _, err := os.Stat(cfg.File); err != nil { if os.IsNotExist(err) { diff --git a/cmd/download.go b/cmd/download.go index 856387970..a2e356a3b 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -10,11 +10,11 @@ import ( "github.com/exercism/cli/api" "github.com/exercism/cli/config" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) // Download returns specified iteration with its related problem. -func Download(ctx *cli.Context) error { +func Download(ctx *app.Context) error { c, err := config.New(ctx.GlobalString("config")) if err != nil { log.Fatal(err) diff --git a/cmd/fetch.go b/cmd/fetch.go index 91f34ed78..40c14f8b1 100644 --- a/cmd/fetch.go +++ b/cmd/fetch.go @@ -8,11 +8,11 @@ import ( "github.com/exercism/cli/api" "github.com/exercism/cli/config" "github.com/exercism/cli/user" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) // Fetch downloads exercism problems and writes them to disk. -func Fetch(ctx *cli.Context) error { +func Fetch(ctx *app.Context) error { c, err := config.New(ctx.GlobalString("config")) if err != nil { log.Fatal(err) @@ -75,7 +75,6 @@ func Fetch(ctx *cli.Context) error { hw.Summarize(user.HWAll) return nil - // return cli.NewExitError("no good", 10) } func setSubmissionState(problems []*api.Problem, submissionInfo map[string][]api.SubmissionInfo) error { diff --git a/cmd/list.go b/cmd/list.go index 2659ce103..bc5a0a19d 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -7,13 +7,13 @@ import ( "github.com/exercism/cli/api" "github.com/exercism/cli/config" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) const msgExplainFetch = "In order to fetch a specific assignment, call the fetch command with a specific assignment.\n\nexercism fetch %s %s\n\n" // List returns the full list of assignments for a given track. -func List(ctx *cli.Context) error { +func List(ctx *app.Context) error { c, err := config.New(ctx.GlobalString("config")) if err != nil { log.Fatal(err) diff --git a/cmd/open.go b/cmd/open.go index 486dddabc..5197b60ba 100644 --- a/cmd/open.go +++ b/cmd/open.go @@ -10,11 +10,11 @@ import ( "github.com/exercism/cli/api" "github.com/exercism/cli/config" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) // Open uses the given track and problem and opens it in the browser. -func Open(ctx *cli.Context) error { +func Open(ctx *app.Context) error { c, err := config.New(ctx.GlobalString("config")) if err != nil { log.Fatal(err) diff --git a/cmd/restore.go b/cmd/restore.go index 68482147a..816db136e 100644 --- a/cmd/restore.go +++ b/cmd/restore.go @@ -6,11 +6,11 @@ import ( "github.com/exercism/cli/api" "github.com/exercism/cli/config" "github.com/exercism/cli/user" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) // Restore returns a user's solved problems. -func Restore(ctx *cli.Context) error { +func Restore(ctx *app.Context) error { c, err := config.New(ctx.GlobalString("config")) if err != nil { log.Fatal(err) diff --git a/cmd/skip.go b/cmd/skip.go index f8adbe6ef..0a30fd124 100644 --- a/cmd/skip.go +++ b/cmd/skip.go @@ -7,11 +7,11 @@ import ( "github.com/exercism/cli/api" "github.com/exercism/cli/config" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) // Skip allows a user to skip a specific problem. -func Skip(ctx *cli.Context) error { +func Skip(ctx *app.Context) error { c, err := config.New(ctx.GlobalString("config")) if err != nil { log.Fatal(err) diff --git a/cmd/status.go b/cmd/status.go index 6e5de1fc7..605a9413a 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -7,12 +7,12 @@ import ( "github.com/exercism/cli/api" "github.com/exercism/cli/config" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) // Status is a command that allows a user to view their progress in a given // language track. -func Status(ctx *cli.Context) error { +func Status(ctx *app.Context) error { c, err := config.New(ctx.GlobalString("config")) if err != nil { log.Fatal(err) diff --git a/cmd/submit.go b/cmd/submit.go index 094ab40a9..32d5fe476 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -10,11 +10,11 @@ import ( "github.com/exercism/cli/api" "github.com/exercism/cli/config" "github.com/exercism/cli/paths" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) // Submit posts an iteration to the API. -func Submit(ctx *cli.Context) error { +func Submit(ctx *app.Context) error { if len(ctx.Args()) == 0 { log.Fatal("Please enter a file name") } diff --git a/cmd/tracks.go b/cmd/tracks.go index d556fa66d..a551b6239 100644 --- a/cmd/tracks.go +++ b/cmd/tracks.go @@ -7,11 +7,11 @@ import ( "github.com/exercism/cli/api" "github.com/exercism/cli/config" "github.com/exercism/cli/user" - "github.com/urfave/cli" + app "github.com/urfave/cli" ) // Tracks lists available tracks. -func Tracks(ctx *cli.Context) error { +func Tracks(ctx *app.Context) error { c, err := config.New(ctx.GlobalString("config")) if err != nil { log.Fatal(err) diff --git a/cmd/upgrade.go b/cmd/upgrade.go index e864c48c6..42d81c074 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -1,210 +1,24 @@ package cmd import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" - "encoding/json" "fmt" - "io" - "io/ioutil" - "log" - "net/http" - "runtime" - "strings" - "time" - "github.com/blang/semver" - "github.com/inconshreveable/go-update" - "github.com/urfave/cli" + "github.com/exercism/cli/cli" + app "github.com/urfave/cli" ) -var ( - // BuildOS is the operating system (GOOS) used during the build process. - BuildOS string - // BuildARM is the ARM version (GOARM) used during the build process. - BuildARM string - // BuildARCH is the architecture (GOARCH) used during the build process. - BuildARCH string -) - -var ( - osMap = map[string]string{ - "darwin": "mac", - "linux": "linux", - "windows": "windows", - } - - archMap = map[string]string{ - "amd64": "64bit", - "386": "32bit", - "arm": "arm", - } -) - -type upgrader struct { - client *http.Client - release *release -} - -func (u *upgrader) fetchLatestRelease() (*release, error) { - resp, err := u.client.Get("https://api.github.com/repos/exercism/cli/releases/latest") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var rel release - if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { - return nil, err - } - - return &rel, nil -} - -func (u *upgrader) IsUpgradeNeeded(currentVersion string) (bool, error) { - rel := u.release - latestVer, err := semver.Make(rel.Version()) - if err != nil { - return false, fmt.Errorf("Unable to parse latest version (%s): %s", rel.Version(), err) - } - currentVer, err := semver.Make(currentVersion) - if err != nil { - return false, fmt.Errorf("Unable to parse current version (%s): %s", currentVersion, err) - } - - return latestVer.GT(currentVer), nil -} - -func NewUpgrader(client *http.Client) (*upgrader, error) { - if client == nil { - client = &http.Client{Timeout: 10 * time.Second} - } - u := &upgrader{client: client} - rel, err := u.fetchLatestRelease() - if err != nil { - return nil, err - } - u.release = rel - return u, nil -} - -func (u *upgrader) Upgrade() error { - var ( - OS = osMap[runtime.GOOS] - ARCH = archMap[runtime.GOARCH] - ) - - if OS == "" || ARCH == "" { - return fmt.Errorf("unable to upgrade: OS %s ARCH %s", OS, ARCH) - } - - buildName := fmt.Sprintf("%s-%s", OS, ARCH) - if BuildARCH == "arm" { - if BuildARM == "" { - return fmt.Errorf("unable to upgrade: arm version not found") - } - buildName = fmt.Sprintf("%s-v%s", buildName, BuildARM) - } - - var downloadRC *bytes.Reader - for _, a := range u.release.Assets { - if strings.Contains(a.Name, buildName) { - // TODO: This should be debug - fmt.Printf("Downloading %s\n", a.Name) - var err error - downloadRC, err = a.download() - if err != nil { - return fmt.Errorf("error downloading executable: %s", err) - } - break - } - } - if downloadRC == nil { - return fmt.Errorf("no executable found for %s/%s%s", BuildOS, BuildARCH, BuildARM) - } - - bin, err := u.extractBinary(downloadRC, OS) - if err != nil { - return err - } - defer bin.Close() - - if err := update.Apply(bin, update.Options{}); err != nil { - return err - } - return nil -} - -func (u *upgrader) extractBinary(source *bytes.Reader, os string) (binary io.ReadCloser, err error) { - if os == "windows" { - zr, err := zip.NewReader(source, int64(source.Len())) - if err != nil { - return nil, err - } - - for _, f := range zr.File { - return f.Open() - } - } else { - gr, err := gzip.NewReader(source) - if err != nil { - return nil, err - } - defer gr.Close() - - tr := tar.NewReader(gr) - for { - _, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - return nil, err - } - tmpfile, err := ioutil.TempFile("", "temp-exercism") - if err != nil { - return nil, err - } - - if _, err = io.Copy(tmpfile, tr); err != nil { - return nil, err - } else { - if _, err := tmpfile.Seek(0, 0); err != nil { - return nil, err - } - - binary = tmpfile - } - } - } - - return binary, nil -} - // Upgrade allows the user to upgrade to the latest version of the CLI. -func Upgrade(ctx *cli.Context) error { - u, err := NewUpgrader(nil) +func Upgrade(ctx *app.Context) error { + c := cli.New(ctx.App.Version) + ok, err := c.IsUpToDate() if err != nil { - log.Fatal(err) - } - - upgradeNeeded, err := u.IsUpgradeNeeded(ctx.App.Version) - if err != nil { - log.Fatalf("unable to check for upgrade: %s", err) return err } - - if !upgradeNeeded { - fmt.Println("Your CLI is up to date!") - return nil - } - - if err := u.Upgrade(); err != nil { - log.Fatal(err) + if !ok { + if err := c.Upgrade(); err != nil { + return err + } } - - fmt.Println("Successfully upgraded!") + fmt.Println("Your CLI is up to date!") return nil }