diff --git a/cmd/root.go b/cmd/root.go index 2a62751..260c247 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,11 +1,14 @@ package cmd import ( + "context" "fmt" "os" + "strings" "github.com/anyproto/anytype-cli/core" "github.com/anyproto/anytype-cli/core/output" + "github.com/anyproto/anytype-cli/core/updatecheck" "github.com/spf13/cobra" "github.com/anyproto/anytype-cli/cmd/auth" @@ -19,11 +22,24 @@ import ( ) var ( - versionFlag bool - rootCmd = &cobra.Command{ + versionFlag bool + noUpdateCheck bool + updateCh <-chan string + rootCmd = &cobra.Command{ Use: "anytype [flags]", Short: "Command-line interface for Anytype", Long: "Command-line interface for Anytype", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if shouldCheckUpdate(cmd) { + updateCh = updatecheck.Start(context.Background()) + } + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if msg, ok := updatecheck.Hint(updateCh, core.GetVersion()); ok { + fmt.Fprintln(os.Stderr) + output.Warning(msg) + } + }, Run: func(cmd *cobra.Command, args []string) { if versionFlag { output.Print(core.GetVersionBrief()) @@ -47,6 +63,7 @@ func Execute() { func init() { rootCmd.Flags().BoolVarP(&versionFlag, "version", "v", false, "Show version information") rootCmd.Flags().BoolP("help", "h", false, "Show help for command") + rootCmd.PersistentFlags().BoolVar(&noUpdateCheck, "no-update-check", false, "Disable the background check for new CLI releases") rootCmd.AddCommand( auth.NewAuthCmd(), @@ -61,3 +78,28 @@ func init() { rootCmd.CompletionOptions.HiddenDefaultCmd = true } + +func shouldCheckUpdate(cmd *cobra.Command) bool { + if noUpdateCheck { + return false + } + if !isTerminal(os.Stderr) { + return false + } + if !strings.HasPrefix(core.GetVersion(), "v") { + return false + } + switch cmd.Name() { + case "serve", "shell", "update": + return false + } + return true +} + +func isTerminal(f *os.File) bool { + info, err := f.Stat() + if err != nil { + return false + } + return (info.Mode() & os.ModeCharDevice) != 0 +} diff --git a/cmd/shell/shell.go b/cmd/shell/shell.go index ad883f8..3f5529c 100644 --- a/cmd/shell/shell.go +++ b/cmd/shell/shell.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/anyproto/anytype-cli/core/output" + "github.com/anyproto/anytype-cli/core/updatecheck" ) func NewShellCmd(rootCmd *cobra.Command) *cobra.Command { @@ -17,6 +18,7 @@ func NewShellCmd(rootCmd *cobra.Command) *cobra.Command { Short: "Start interactive shell mode", Long: "Launch an interactive shell where you can run Anytype commands without the 'anytype' prefix. Type 'exit' to quit.", RunE: func(cmd *cobra.Command, args []string) error { + updatecheck.Disable() output.Info("Starting Anytype interactive shell. Type 'exit' to quit.") return runShell(rootCmd) }, diff --git a/core/updatecheck/updatecheck.go b/core/updatecheck/updatecheck.go new file mode 100644 index 0000000..1ed480f --- /dev/null +++ b/core/updatecheck/updatecheck.go @@ -0,0 +1,156 @@ +package updatecheck + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "golang.org/x/mod/semver" + + "github.com/anyproto/anytype-cli/core/config" +) + +const ( + cacheFileName = "update-check.json" + refreshInterval = 24 * time.Hour + checkDeadline = 400 * time.Millisecond + hintWait = 200 * time.Millisecond + latestURL = "https://api.github.com/repos/anyproto/anytype-cli/releases/latest" +) + +type cache struct { + Latest string `json:"latest"` + CheckedAt time.Time `json:"checkedAt"` +} + +var disabled atomic.Bool + +// Disable suppresses the check for the rest of the process. Used by the +// interactive shell, which re-executes rootCmd for each nested command. +func Disable() { disabled.Store(true) } + +func Start(ctx context.Context) <-chan string { + if disabled.Load() { + return nil + } + ctx, cancel := context.WithTimeout(ctx, checkDeadline) + ch := make(chan string, 1) + go func() { + defer cancel() + defer close(ch) + if v, ok := latestVersion(ctx); ok { + ch <- v + } + }() + return ch +} + +func Hint(ch <-chan string, current string) (string, bool) { + if ch == nil || !strings.HasPrefix(current, "v") { + return "", false + } + + var v string + select { + case v = <-ch: + case <-time.After(hintWait): + return "", false + } + if v == "" { + return "", false + } + + base := current + if i := strings.Index(base, "-"); i != -1 { + base = base[:i] + } + if semver.Compare(base, v) >= 0 { + return "", false + } + return fmt.Sprintf("A new version (%s) is available. Run: anytype update", v), true +} + +func latestVersion(ctx context.Context) (string, bool) { + path := cachePath() + c, cacheErr := readCache(path) + if cacheErr == nil && time.Since(c.CheckedAt) < refreshInterval { + return c.Latest, true + } + + v, err := fetchLatest(ctx) + if err == nil { + _ = writeCache(path, cache{Latest: v, CheckedAt: time.Now()}) + return v, true + } + + if cacheErr == nil && c.Latest != "" { + return c.Latest, true + } + return "", false +} + +func cachePath() string { + return filepath.Join(config.GetConfigDir(), cacheFileName) +} + +func readCache(path string) (cache, error) { + var c cache + data, err := os.ReadFile(path) + if err != nil { + return c, err + } + if err := json.Unmarshal(data, &c); err != nil { + return c, err + } + return c, nil +} + +func writeCache(path string, c cache) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.Marshal(c) + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func fetchLatest(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", latestURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + req.Header.Set("Authorization", "token "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("http %d", resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + return release.TagName, nil +}