From 530aa9ac9e19d71980355acb122ad8cd9670a43a Mon Sep 17 00:00:00 2001 From: Jacob Gillespie Date: Wed, 23 Mar 2022 23:01:05 +0000 Subject: [PATCH] Add CLI update notice --- .goreleaser.yml | 1 - cmd/depot/main.go | 65 +++++++++++++++++++++++++++++++ go.mod | 6 ++- go.sum | 2 + internal/update/update.go | 82 +++++++++++++++++++++++++++++++++++++++ pkg/api/api.go | 18 +++++++++ pkg/api/request.go | 16 ++++++-- pkg/config/config.go | 4 ++ 8 files changed, 187 insertions(+), 7 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index d453ddb1..ad054810 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -14,7 +14,6 @@ builds: main: ./cmd/depot ldflags: - -s -w -X github.com/depot/cli/internal/build.Version={{.Version}} -X github.com/depot/cli/internal/build.Date={{time "2006-01-02"}} -X github.com/depot/cli/internal/build.SentryEnvironment=release - - -X main.updaterEnabled=depot/cli id: macos goos: [darwin] goarch: [amd64, arm64] diff --git a/cmd/depot/main.go b/cmd/depot/main.go index 3cc84317..181bdb81 100644 --- a/cmd/depot/main.go +++ b/cmd/depot/main.go @@ -1,12 +1,18 @@ package main import ( + "fmt" "log" "os" "github.com/depot/cli/internal/build" + "github.com/depot/cli/internal/update" + "github.com/depot/cli/pkg/api" "github.com/depot/cli/pkg/cmd/root" + "github.com/depot/cli/pkg/config" "github.com/getsentry/sentry-go" + "github.com/mattn/go-isatty" + "github.com/mgutz/ansi" ) func main() { @@ -29,11 +35,70 @@ func runMain() int { buildVersion := build.Version buildDate := build.Date + updateMessageChan := make(chan *update.ReleaseInfo) + go func() { + rel, _ := checkForUpdate(buildVersion) + updateMessageChan <- rel + }() + rootCmd := root.NewCmdRoot(buildVersion, buildDate) if err := rootCmd.Execute(); err != nil { return 1 } + newRelease := <-updateMessageChan + if newRelease != nil { + isHomebrew := update.IsUnderHomebrew() + fmt.Fprintf(os.Stderr, "\n\n%s%s%s %s → %s\n", + ansi.Color("A new release of depot is available, released on ", "yellow"), + ansi.Color(newRelease.PublishedAt.Format("2006-01-02"), "yellow"), + ansi.Color(":", "yellow"), + ansi.Color(buildVersion, "cyan"), + ansi.Color(newRelease.Version, "cyan")) + if isHomebrew { + fmt.Fprintf(os.Stderr, "To upgrade, run: %s\n", "brew update && brew upgrade depot/tap/depot") + } + fmt.Fprintf(os.Stderr, "%s\n\n", + ansi.Color(fmt.Sprintf("https://github.com/depot/cli/releases/tag/v%s", newRelease.Version), "yellow")) + } + return 0 } + +func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) { + if !shouldCheckForUpdate() { + return nil, nil + } + + client, err := api.NewDepotFromEnv(config.GetApiToken()) + if err != nil { + return nil, err + } + + stateFilePath, err := config.StateFile() + if err != nil { + return nil, err + } + + fmt.Println(stateFilePath) + + return update.CheckForUpdate(client, stateFilePath, currentVersion) +} + +func shouldCheckForUpdate() bool { + if os.Getenv("DEPOT_NO_UPDATE_NOTIFIER") != "" { + return false + } + return !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr) +} + +func isCI() bool { + return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari + os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity + os.Getenv("RUN_ID") != "" // TaskCluster, dsari +} + +func isTerminal(f *os.File) bool { + return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) +} diff --git a/go.mod b/go.mod index dff1cabe..0bb454d5 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,9 @@ require ( github.com/docker/docker v20.10.7+incompatible github.com/docker/go-units v0.4.0 github.com/getsentry/sentry-go v0.13.0 + github.com/hashicorp/go-version v1.2.0 + github.com/mattn/go-isatty v0.0.14 + github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b github.com/moby/buildkit v0.10.0-rc2.0.20220308185020-fdecd0ae108b github.com/morikuni/aec v1.0.0 github.com/pkg/errors v0.9.1 @@ -20,6 +23,7 @@ require ( github.com/spf13/viper v1.10.1 golang.org/x/net v0.0.0-20220225172249-27dd8689420f google.golang.org/grpc v1.44.0 + gopkg.in/yaml.v2 v2.4.0 ) require ( @@ -61,7 +65,6 @@ require ( github.com/klauspost/compress v1.15.0 // indirect github.com/magiconair/properties v1.8.5 // indirect github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect github.com/miekg/pkcs11 v1.0.3 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect @@ -113,7 +116,6 @@ require ( google.golang.org/protobuf v1.27.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.66.2 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/apimachinery v0.23.4 // indirect k8s.io/client-go v0.23.4 // indirect k8s.io/klog/v2 v2.30.0 // indirect diff --git a/go.sum b/go.sum index 24e85328..04a06d1a 100644 --- a/go.sum +++ b/go.sum @@ -804,6 +804,7 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -948,6 +949,7 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5 github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/maxbrunsfeld/counterfeiter/v6 v6.2.2/go.mod h1:eD9eIE7cdwcMi9rYluz88Jz2VyhSmden33/aXg4oVIY= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/pkcs11 v1.0.3 h1:iMwmD7I5225wv84WxIG/bmxz9AXjWvTWIbM/TYHvWtw= diff --git a/internal/update/update.go b/internal/update/update.go index 8ff037ef..758e86cc 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -5,8 +5,12 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/cli/safeexec" + "github.com/depot/cli/pkg/api" + "github.com/hashicorp/go-version" + "gopkg.in/yaml.v2" ) // Check whether the depot binary was found under the Homebrew prefix @@ -29,3 +33,81 @@ func IsUnderHomebrew() bool { brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) return strings.HasPrefix(binary, brewBinPrefix) } + +type ReleaseInfo struct { + Version string `json:"version"` + URL string `json:"url"` + PublishedAt time.Time `json:"publishedAt"` +} + +type StateEntry struct { + CheckedForUpdateAt time.Time `yaml:"checkedForUpdateAt"` + LatestRelease ReleaseInfo `yaml:"latestRelease"` +} + +func CheckForUpdate(client *api.Depot, stateFilePath, currentVersion string) (*ReleaseInfo, error) { + state, _ := readStateFile(stateFilePath) + if state != nil && time.Since(state.CheckedForUpdateAt) < time.Hour*1 { + return nil, nil + } + + releaseResponse, err := client.LatestRelease() + if err != nil { + return nil, err + } + release := ReleaseInfo{Version: releaseResponse.Version, URL: releaseResponse.URL, PublishedAt: releaseResponse.PublishedAt} + + state = &StateEntry{CheckedForUpdateAt: time.Now(), LatestRelease: release} + err = writeStateFile(stateFilePath, state) + if err != nil { + return nil, err + } + + if versionGreaterThan(release.Version, currentVersion) { + return &release, nil + } + + return nil, nil +} + +func readStateFile(stateFilePath string) (*StateEntry, error) { + content, err := os.ReadFile(stateFilePath) + if err != nil { + return nil, err + } + + var stateEntry StateEntry + err = yaml.Unmarshal(content, &stateEntry) + if err != nil { + return nil, err + } + + return &stateEntry, nil +} + +func writeStateFile(stateFilePath string, state *StateEntry) error { + content, err := yaml.Marshal(state) + if err != nil { + return err + } + + err = os.MkdirAll(filepath.Dir(stateFilePath), 0755) + if err != nil { + return err + } + + err = os.WriteFile(stateFilePath, content, 0600) + return err +} + +func versionGreaterThan(a, b string) bool { + versionA, err := version.NewVersion(a) + if err != nil { + return false + } + versionB, err := version.NewVersion(b) + if err != nil { + return false + } + return versionA.GreaterThan(versionB) +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 4fd4e344..5dc53666 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -3,6 +3,8 @@ package api import ( "fmt" "os" + "runtime" + "time" ) type Depot struct { @@ -52,3 +54,19 @@ func (d *Depot) FinishBuild(buildID string) error { ) return err } + +type ReleaseResponse struct { + OK bool `json:"ok"` + Version string `json:"version"` + URL string `json:"url"` + PublishedAt time.Time `json:"publishedAt"` +} + +func (d *Depot) LatestRelease() (*ReleaseResponse, error) { + return apiRequest[ReleaseResponse]( + "GET", + fmt.Sprintf("%s/api/cli/release/%s/%s/latest", d.BaseURL, runtime.GOOS, runtime.GOARCH), + d.token, + nil, + ) +} diff --git a/pkg/api/request.go b/pkg/api/request.go index e241aab3..bbc37b1a 100644 --- a/pkg/api/request.go +++ b/pkg/api/request.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "io/ioutil" "net/http" ) @@ -14,13 +15,20 @@ type ErrorResponse struct { } func apiRequest[Response interface{}](method, url, token string, payload interface{}) (*Response, error) { - jsonBytes, err := json.Marshal(payload) - if err != nil { - return nil, err + var requestBody io.Reader + + if payload != nil { + jsonBytes, err := json.Marshal(payload) + if err != nil { + return nil, err + } + requestBody = bytes.NewReader(jsonBytes) + } else { + requestBody = nil } client := &http.Client{} - req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonBytes)) + req, err := http.NewRequest(method, url, requestBody) if err != nil { return nil, err } diff --git a/pkg/config/config.go b/pkg/config/config.go index 2ace7d24..3e4eb45c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -34,3 +34,7 @@ func ClearApiToken() error { viper.Set("api_token", "") return viper.WriteConfig() } + +func StateFile() (string, error) { + return xdg.ConfigFile("depot/state.yaml") +}