From 6e806072da24865873b01c95f8c84df45956e237 Mon Sep 17 00:00:00 2001 From: Jannis Metrikat <120120832+jmetrikat@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:25:13 +0200 Subject: [PATCH 1/5] Add version update hint --- cmd/root.go | 52 +++++++++- cmd/shell/shell.go | 2 + core/updatecheck/updatecheck.go | 163 ++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 core/updatecheck/updatecheck.go diff --git a/cmd/root.go b/cmd/root.go index 2a62751..11e5f37 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,26 @@ import ( ) var ( - versionFlag bool - rootCmd = &cobra.Command{ + versionFlag bool + noUpdateCheck bool + 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) { + return + } + updatecheck.Start(context.Background()) + }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + if !shouldCheckUpdate(cmd) { + return + } + if msg, ok := updatecheck.Hint(core.GetVersion()); ok { + output.Warning(msg) + } + }, Run: func(cmd *cobra.Command, args []string) { if versionFlag { output.Print(core.GetVersionBrief()) @@ -47,6 +65,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 +80,32 @@ 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 + } + path := cmd.CommandPath() + if path == "anytype" || strings.HasPrefix(path, "anytype service") { + return false + } + switch cmd.Name() { + case "serve", "shell", "version", "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..46901c8 --- /dev/null +++ b/core/updatecheck/updatecheck.go @@ -0,0 +1,163 @@ +package updatecheck + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/mod/semver" + + "github.com/anyproto/anytype-cli/core/config" +) + +const ( + cacheFileName = "update-check.json" + refreshInterval = 24 * time.Hour + fetchTimeout = 3 * time.Second + latestURL = "https://api.github.com/repos/anyproto/anytype-cli/releases/latest" +) + +type cache struct { + Latest string `json:"latest"` + CheckedAt time.Time `json:"checkedAt"` +} + +var ( + mu sync.RWMutex + latest string + disabled bool +) + +// Disable turns off the update check for the lifetime of this process. +// Useful for long-running modes like the interactive shell. +func Disable() { + mu.Lock() + disabled = true + mu.Unlock() +} + +// Start seeds the latest-version result from the on-disk cache and, if the +// cache is stale or missing, kicks off a background refresh. Silent on failure. +func Start(ctx context.Context) { + mu.RLock() + off := disabled + mu.RUnlock() + if off { + return + } + + path := cachePath() + c, err := readCache(path) + if err == nil { + mu.Lock() + latest = c.Latest + mu.Unlock() + if time.Since(c.CheckedAt) < refreshInterval { + return + } + } + + go func() { + v, err := fetchLatest(ctx) + if err != nil { + return + } + mu.Lock() + latest = v + mu.Unlock() + _ = writeCache(path, cache{Latest: v, CheckedAt: time.Now()}) + }() +} + +// Hint returns a user-facing message if the cached latest version is newer +// than current. Returns ("", false) when disabled, on dev builds, when no +// cached value is available, or when current is already up to date. +func Hint(current string) (string, bool) { + mu.RLock() + off := disabled + v := latest + mu.RUnlock() + if off || v == "" { + return "", false + } + if !strings.HasPrefix(current, "v") { + return "", false + } + base := current + if idx := strings.Index(base, "-"); idx != -1 { + base = base[:idx] + } + if semver.Compare(base, v) >= 0 { + return "", false + } + return fmt.Sprintf("A new version (%s) is available. Run: anytype update", v), true +} + +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) { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + 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 +} From 9a72d6937908e143fd0f02db5f3ce27d35e6e3f9 Mon Sep 17 00:00:00 2001 From: Jannis Metrikat <120120832+jmetrikat@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:26:28 +0200 Subject: [PATCH 2/5] Refactor update check logic in root command --- cmd/root.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 11e5f37..318ed72 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -91,12 +91,8 @@ func shouldCheckUpdate(cmd *cobra.Command) bool { if !strings.HasPrefix(core.GetVersion(), "v") { return false } - path := cmd.CommandPath() - if path == "anytype" || strings.HasPrefix(path, "anytype service") { - return false - } switch cmd.Name() { - case "serve", "shell", "version", "update": + case "serve", "shell", "update": return false } return true From 35903c31583cf7f3b19a5ecff67ad30d45912f0b Mon Sep 17 00:00:00 2001 From: Jannis Metrikat <120120832+jmetrikat@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:37:52 +0200 Subject: [PATCH 3/5] Refactor update check --- cmd/root.go | 11 ++-- core/updatecheck/updatecheck.go | 109 +++++++++++++++----------------- 2 files changed, 56 insertions(+), 64 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 318ed72..9f937ee 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,21 +24,18 @@ import ( var ( 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) { - return + if shouldCheckUpdate(cmd) { + updateCh = updatecheck.Start(context.Background()) } - updatecheck.Start(context.Background()) }, PersistentPostRun: func(cmd *cobra.Command, args []string) { - if !shouldCheckUpdate(cmd) { - return - } - if msg, ok := updatecheck.Hint(core.GetVersion()); ok { + if msg, ok := updatecheck.Hint(updateCh, core.GetVersion()); ok { output.Warning(msg) } }, diff --git a/core/updatecheck/updatecheck.go b/core/updatecheck/updatecheck.go index 46901c8..5bf287f 100644 --- a/core/updatecheck/updatecheck.go +++ b/core/updatecheck/updatecheck.go @@ -8,7 +8,7 @@ import ( "os" "path/filepath" "strings" - "sync" + "sync/atomic" "time" "golang.org/x/mod/semver" @@ -19,7 +19,7 @@ import ( const ( cacheFileName = "update-check.json" refreshInterval = 24 * time.Hour - fetchTimeout = 3 * time.Second + checkDeadline = 400 * time.Millisecond latestURL = "https://api.github.com/repos/anyproto/anytype-cli/releases/latest" ) @@ -28,70 +28,49 @@ type cache struct { CheckedAt time.Time `json:"checkedAt"` } -var ( - mu sync.RWMutex - latest string - disabled bool -) +var disabled atomic.Bool // Disable turns off the update check for the lifetime of this process. -// Useful for long-running modes like the interactive shell. -func Disable() { - mu.Lock() - disabled = true - mu.Unlock() -} - -// Start seeds the latest-version result from the on-disk cache and, if the -// cache is stale or missing, kicks off a background refresh. Silent on failure. -func Start(ctx context.Context) { - mu.RLock() - off := disabled - mu.RUnlock() - if off { - return - } - - path := cachePath() - c, err := readCache(path) - if err == nil { - mu.Lock() - latest = c.Latest - mu.Unlock() - if time.Since(c.CheckedAt) < refreshInterval { - return - } - } - +// Used by long-running modes like the interactive shell where rootCmd +// is re-executed for each nested command. +func Disable() { disabled.Store(true) } + +// Start launches a background update check, capped at checkDeadline, and +// returns a channel that yields the latest release tag (or is closed without +// a value on failure). The channel is buffered, so the sender never blocks. +// Returns nil when disabled. +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() { - v, err := fetchLatest(ctx) - if err != nil { - return + defer cancel() + defer close(ch) + if v, ok := latestVersion(ctx); ok { + ch <- v } - mu.Lock() - latest = v - mu.Unlock() - _ = writeCache(path, cache{Latest: v, CheckedAt: time.Now()}) }() + return ch } -// Hint returns a user-facing message if the cached latest version is newer -// than current. Returns ("", false) when disabled, on dev builds, when no -// cached value is available, or when current is already up to date. -func Hint(current string) (string, bool) { - mu.RLock() - off := disabled - v := latest - mu.RUnlock() - if off || v == "" { +// Hint reads the result of a Start and returns a user-facing message if the +// latest release is newer than current. A nil channel, dev builds, and empty +// results all yield ("", false). +func Hint(ch <-chan string, current string) (string, bool) { + if ch == nil || !strings.HasPrefix(current, "v") { return "", false } - if !strings.HasPrefix(current, "v") { + + v := <-ch + if v == "" { return "", false } + base := current - if idx := strings.Index(base, "-"); idx != -1 { - base = base[:idx] + if i := strings.Index(base, "-"); i != -1 { + base = base[:i] } if semver.Compare(base, v) >= 0 { return "", false @@ -99,6 +78,25 @@ func Hint(current string) (string, bool) { 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) } @@ -131,9 +129,6 @@ func writeCache(path string, c cache) error { } func fetchLatest(ctx context.Context) (string, error) { - ctx, cancel := context.WithTimeout(ctx, fetchTimeout) - defer cancel() - req, err := http.NewRequestWithContext(ctx, "GET", latestURL, nil) if err != nil { return "", err From 268701648da6d31d036feeac4811735640177e83 Mon Sep 17 00:00:00 2001 From: Jannis Metrikat <120120832+jmetrikat@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:40:57 +0200 Subject: [PATCH 4/5] Enhance update check output --- cmd/root.go | 1 + core/updatecheck/updatecheck.go | 12 ++---------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 9f937ee..260c247 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -36,6 +36,7 @@ var ( }, PersistentPostRun: func(cmd *cobra.Command, args []string) { if msg, ok := updatecheck.Hint(updateCh, core.GetVersion()); ok { + fmt.Fprintln(os.Stderr) output.Warning(msg) } }, diff --git a/core/updatecheck/updatecheck.go b/core/updatecheck/updatecheck.go index 5bf287f..344248f 100644 --- a/core/updatecheck/updatecheck.go +++ b/core/updatecheck/updatecheck.go @@ -30,15 +30,10 @@ type cache struct { var disabled atomic.Bool -// Disable turns off the update check for the lifetime of this process. -// Used by long-running modes like the interactive shell where rootCmd -// is re-executed for each nested command. +// 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) } -// Start launches a background update check, capped at checkDeadline, and -// returns a channel that yields the latest release tag (or is closed without -// a value on failure). The channel is buffered, so the sender never blocks. -// Returns nil when disabled. func Start(ctx context.Context) <-chan string { if disabled.Load() { return nil @@ -55,9 +50,6 @@ func Start(ctx context.Context) <-chan string { return ch } -// Hint reads the result of a Start and returns a user-facing message if the -// latest release is newer than current. A nil channel, dev builds, and empty -// results all yield ("", false). func Hint(ch <-chan string, current string) (string, bool) { if ch == nil || !strings.HasPrefix(current, "v") { return "", false From 81b3135e96828feefa890338a3dc9b287f6d4f0d Mon Sep 17 00:00:00 2001 From: Jannis Metrikat <120120832+jmetrikat@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:36:43 +0200 Subject: [PATCH 5/5] Reduce blocking waitTime for hint --- core/updatecheck/updatecheck.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/updatecheck/updatecheck.go b/core/updatecheck/updatecheck.go index 344248f..1ed480f 100644 --- a/core/updatecheck/updatecheck.go +++ b/core/updatecheck/updatecheck.go @@ -20,6 +20,7 @@ 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" ) @@ -55,7 +56,12 @@ func Hint(ch <-chan string, current string) (string, bool) { return "", false } - v := <-ch + var v string + select { + case v = <-ch: + case <-time.After(hintWait): + return "", false + } if v == "" { return "", false }