Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -19,11 +22,24 @@ import (
)

var (
versionFlag bool
rootCmd = &cobra.Command{
versionFlag bool
noUpdateCheck bool
updateCh <-chan string
rootCmd = &cobra.Command{
Use: "anytype <command> <subcommand> [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())
Expand All @@ -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(),
Expand All @@ -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
}
2 changes: 2 additions & 0 deletions cmd/shell/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
},
Expand Down
156 changes: 156 additions & 0 deletions core/updatecheck/updatecheck.go
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +54 to +77
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New update-check behavior is introduced here (version comparison / hint formatting / cache fallback), but there are no unit tests for the updatecheck package. Adding focused tests for Hint (including pre-release versions) and for latestVersion’s cache/refresh behavior (with a stubbed HTTP client or httptest server) would help prevent regressions.

Copilot uses AI. Check for mistakes.

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)
}
Comment on lines +98 to +100
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cachePath() joins config.GetConfigDir() with the cache filename, but GetConfigDir() can return an empty string when os.UserHomeDir() fails. In that case the cache file will be read/written relative to the current working directory, which is surprising and can cause permission issues. Consider returning an empty path / disabling caching when the config dir is unavailable, and have latestVersion treat that as a non-fatal "no cache" condition.

Copilot uses AI. Check for mistakes.

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)
}
Comment on lines +122 to +127
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writeCache uses os.Rename(tmp, path) to replace the cache file. On Windows, os.Rename fails if the destination already exists, so the cache may never refresh after the first successful write (and the CLI may re-fetch on every run once CheckedAt becomes stale). Handle Windows/overwrite semantics explicitly (e.g., remove existing destination or retry with an OS-specific replace strategy) and consider surfacing/logging the error instead of ignoring it upstream.

Copilot uses AI. Check for mistakes.

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
}
Loading