diff --git a/.golangci.yaml b/.golangci.yaml index a35e0db..aa1866c 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -20,7 +20,6 @@ linters: - gocritic - gocyclo - godot - - godox - gofmt - gofumpt - goheader diff --git a/go.mod b/go.mod index 7528730..3abe6d3 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/hbagdi/hit go 1.17 require ( + github.com/blang/semver/v4 v4.0.0 github.com/fatih/color v1.13.0 github.com/ghodss/yaml v1.0.0 github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f diff --git a/go.sum b/go.sum index 492d3b0..32b6d0c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= diff --git a/pkg/cache/setup.go b/pkg/cache/setup.go index 461e4b9..2703d51 100644 --- a/pkg/cache/setup.go +++ b/pkg/cache/setup.go @@ -15,24 +15,43 @@ const ( var cacheFilePath string +func getUserCacheDir() (string, error) { + userCacheDir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("failed to find user's cache directory: %w", err) + } + return userCacheDir, nil +} + +func HitCacheDir() (string, error) { + userCacheDir, err := getUserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(userCacheDir, cacheDir), nil +} + // init ensures that the cache files are correctly setup. func init() { - userCacheDir, err := os.UserCacheDir() + userCacheDir, err := getUserCacheDir() if err != nil { panic(fmt.Sprintf("failed to find cache directory: %v", err)) } if err = ensureDir(userCacheDir, os.ModePerm); err != nil { panic(err) } - if err = ensureDir(filepath.Join(userCacheDir, cacheDir), - os.ModePerm); err != nil { + hitCacheDir, err := HitCacheDir() + if err != nil { + panic(err) + } + if err = ensureDir(hitCacheDir, os.ModePerm); err != nil { panic(err) } - if err = ensureFile(filepath.Join(userCacheDir, - cacheDir, cacheFile), fileMode); err != nil { + hitCacheFile := filepath.Join(hitCacheDir, cacheFile) + if err = ensureFile(hitCacheFile, fileMode); err != nil { panic(err) } - cacheFilePath = filepath.Join(userCacheDir, cacheDir, cacheFile) + cacheFilePath = hitCacheFile } func ensureFile(path string, perm os.FileMode) error { diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 76b2767..ff9403c 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -4,15 +4,43 @@ import ( "context" "fmt" "log" + "strings" + "sync" + "github.com/blang/semver/v4" "github.com/hbagdi/hit/pkg/cache" executorPkg "github.com/hbagdi/hit/pkg/executor" + "github.com/hbagdi/hit/pkg/version" ) const ( minArgs = 2 ) +var ( + versionLoadMutex sync.Mutex + latestVersion string +) + +func init() { + go func() { + versionLoadMutex.Lock() + defer versionLoadMutex.Unlock() + version, err := version.LoadLatestVersion() + if err != nil { + // TODO(hbagdi): add logging + return + } + latestVersion = version + }() +} + +func getLatestVersion() string { + versionLoadMutex.Lock() + defer versionLoadMutex.Unlock() + return latestVersion +} + func Run(ctx context.Context, args ...string) (err error) { if len(args) < minArgs { return fmt.Errorf("need a request to execute") @@ -80,5 +108,36 @@ func Run(ctx context.Context, args ...string) (err error) { return err } + printLatestVersion() return err } + +func printLatestVersion() { + latestVersion := getLatestVersion() + if latestVersion == "" { + return + } + latest, err := semver.New(cleanVersionString(latestVersion)) + if err != nil { + // TODO(hbagdi): log error + return + } + + current, err := semver.New(cleanVersionString(version.Version)) + if err != nil { + // TODO(hbagdi): log error + return + } + if latest.GT(*current) { + fmt.Printf("New version(%s) available! Current installed version is"+ + " %s.\nCheckout https://hit.yolo42.com for details.\n", + latestVersion, version.Version) + } +} + +func cleanVersionString(v string) string { + if strings.HasPrefix(v, "v") { + return v[1:] + } + return v +} diff --git a/pkg/version/check_version.go b/pkg/version/check_version.go new file mode 100644 index 0000000..5f3cf2a --- /dev/null +++ b/pkg/version/check_version.go @@ -0,0 +1,149 @@ +package version + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/hbagdi/hit/pkg/cache" +) + +const ( + versionEndpoint = "https://hit-server.yolo42.com/api/v1/latest-version" + requestTimeout = 3 * time.Second +) + +func checkForUpdate() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + versionEndpoint, nil) + req.Header.Add("user-agent", "hit/"+Version) + if err != nil { + return "", err + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer func() { + // TODO(hbagdi): handle err + _ = res.Body.Close() + }() + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %v", res.StatusCode) + } + js, err := ioutil.ReadAll(res.Body) + if err != nil { + return "", err + } + return parseVersionFromResponseOrFile(js) +} + +func parseVersionFromResponseOrFile(js []byte) (string, error) { + var m map[string]interface{} + if err := json.Unmarshal(js, &m); err != nil { + return "", err + } + v, ok := m["version"] + if !ok { + return "", fmt.Errorf("no 'version' field in the response") + } + version, ok := v.(string) + if !ok { + return "", fmt.Errorf("expected 'version' field to be a string, "+ + "but got %T", v) + } + return version, nil +} + +var versionCacheFullPath string + +func versionCacheFileName() (string, error) { + if versionCacheFullPath != "" { + return versionCacheFullPath, nil + } + const versionCacheFilename = "latest_version.json" + hitCacheDir, err := cache.HitCacheDir() + if err != nil { + return "", err + } + return filepath.Join(hitCacheDir, versionCacheFilename), nil +} + +var errCacheMiss = fmt.Errorf("cache miss") + +func loadVersionFromCache() (string, error) { + filename, err := versionCacheFileName() + if err != nil { + return "", err + } + js, err := ioutil.ReadFile(filename) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", errCacheMiss + } + return "", err + } + cacheInfo, err := os.Stat(filename) + if err != nil { + return "", err + } + lastUpdated := cacheInfo.ModTime() + cutoff := time.Now().Add(-24 * time.Hour) + if lastUpdated.Before(cutoff) { + return "", errCacheMiss + } + return parseVersionFromResponseOrFile(js) +} + +func refreshVersionCache() (string, error) { + version, err := checkForUpdate() + if err != nil { + return "", err + } + // TODO(hbagdi): log this error + _ = updateCache(version) + + return version, nil +} + +func updateCache(version string) error { + const fileMode = 0o0600 + js, err := json.Marshal(map[string]string{ + "version": version, + }) + if err != nil { + return err + } + filename, err := versionCacheFileName() + if err != nil { + return err + } + err = ioutil.WriteFile(filename, js, fileMode) + if err != nil { + return fmt.Errorf("update version cache: %w", err) + } + return nil +} + +func LoadLatestVersion() (string, error) { + version, err := loadVersionFromCache() + if err != nil { + if err == errCacheMiss { + updatedVersion, err := refreshVersionCache() + if err != nil { + return "", err + } + return updatedVersion, nil + } + return "", err + } + return version, nil +}