From 76070bca11190bd52996628809ad5c2a323ce1ec Mon Sep 17 00:00:00 2001 From: irisyann Date: Tue, 21 Apr 2026 16:28:08 +0800 Subject: [PATCH 01/10] Introduce version info & update command --- cmd/root.go | 6 +- cmd/update.go | 132 +++++++++++++++++++++++++++++++++ internal/display/banner.go | 20 ++++- internal/updater/checker.go | 141 ++++++++++++++++++++++++++++++++++++ 4 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 cmd/update.go create mode 100644 internal/updater/checker.go diff --git a/cmd/root.go b/cmd/root.go index d3994b8..bd59a4b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "os" "github.com/coingecko/coingecko-cli/internal/display" + "github.com/coingecko/coingecko-cli/internal/updater" "github.com/spf13/cobra" ) @@ -21,7 +22,10 @@ var rootCmd = &cobra.Command{ Version: version, Run: func(cmd *cobra.Command, args []string) { display.PrintLogo() - display.PrintWelcomeBox() + display.PrintWelcomeBox(version) + if info := updater.Check(version); info != nil && info.UpdateAvailable { + display.PrintUpdateReminder(info.CurrentVersion, info.LatestVersion) + } }, } diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..efb89e3 --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/charmbracelet/huh" + "github.com/coingecko/coingecko-cli/internal/display" + "github.com/coingecko/coingecko-cli/internal/updater" + "github.com/spf13/cobra" +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Upgrade the CLI to the latest version", + RunE: runUpdate, +} + +func init() { + updateCmd.Flags().String("method", "", "Install method override (homebrew, go, script)") + rootCmd.AddCommand(updateCmd) +} + +func runUpdate(cmd *cobra.Command, args []string) error { + display.PrintBanner() + + method, _ := cmd.Flags().GetString("method") + if method == "" { + method = detectInstallMethod() + } else { + switch method { + case "homebrew", "go", "script": + default: + return fmt.Errorf("unknown install method %q — must be one of: homebrew, go, script", method) + } + } + + warnf("Checking for updates...\n") + latest, err := updater.FetchLatest() + if err != nil { + return fmt.Errorf("checking for updates: %w", err) + } + if !validVersion(latest) { + return fmt.Errorf("unexpected version format from GitHub: %q", latest) + } + + if latest == version { + warnf("Already up to date (%s).\n", version) + return nil + } + + warnf("Current: v%s → Latest: v%s (install via: %s)\n\n", version, latest, method) + + var confirmed bool + if err := huh.NewConfirm(). + Title(fmt.Sprintf("Update cg v%s → v%s?", version, latest)). + Value(&confirmed). + Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return nil + } + return err + } + if !confirmed { + return nil + } + + return runInstallCommand(method) +} + +var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + +func validVersion(v string) bool { return semverRe.MatchString(v) } + +func detectInstallMethod() string { + exe, err := os.Executable() + if err != nil { + return "script" + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return "script" + } + + if strings.Contains(exe, "/Cellar/") || + strings.Contains(exe, "/homebrew/") || + strings.Contains(exe, "/opt/homebrew/") { + return "homebrew" + } + + gobin := os.Getenv("GOBIN") + if gobin == "" { + gopath := os.Getenv("GOPATH") + if gopath == "" { + home, _ := os.UserHomeDir() + gopath = filepath.Join(home, "go") + } + gobin = filepath.Join(gopath, "bin") + } + if strings.HasPrefix(exe, gobin+string(filepath.Separator)) { + return "go" + } + + return "script" +} + +func runInstallCommand(method string) error { + var name string + var args []string + switch method { + case "homebrew": + name = "brew" + args = []string{"upgrade", "coingecko/coingecko-cli/cg"} + case "go": + name = "go" + args = []string{"install", "github.com/coingecko/coingecko-cli@latest"} + default: // "script" + name = "sh" + args = []string{"-c", "curl -fsSL https://raw.githubusercontent.com/coingecko/coingecko-cli/main/install.sh | sh"} + } + + c := exec.Command(name, args...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() +} diff --git a/internal/display/banner.go b/internal/display/banner.go index 2cf839e..fa1da13 100644 --- a/internal/display/banner.go +++ b/internal/display/banner.go @@ -5,6 +5,7 @@ import ( "io" "os" "strings" + "unicode/utf8" "golang.org/x/term" ) @@ -44,14 +45,19 @@ func PrintLogo() { } // PrintWelcomeBox prints a bordered quick-start box to stderr. -func PrintWelcomeBox() { +// version is the embedded build version (e.g. "v1.2.3") shown in the header. +func PrintWelcomeBox(version string) { w := os.Stderr top := "+" + strings.Repeat("-", boxWidth) + "+" blank := "|" + strings.Repeat(" ", boxWidth) + "|" sep := boxRow(w, dimColor+strings.Repeat("-", boxWidth-2)+colorReset, boxWidth-2) + // "◆ CoinGecko CLI " is 16 runes; version length varies. + versionVisible := 16 + utf8.RuneCountInString(version) + _, _ = fmt.Fprintln(w, top) _, _ = fmt.Fprintln(w, blank) + printColoredRow(w, brandGreen+"◆ CoinGecko CLI "+colorReset+version, versionVisible) printColoredRow(w, yellowBold+"Official API Command Line Interface"+colorReset, 35) _, _ = fmt.Fprintln(w, blank) _, _ = fmt.Fprintln(w, sep) @@ -91,7 +97,7 @@ func printColoredRow(w *os.File, content string, visible int) { } if !ColorEnabled() { plain := ansiRegex.ReplaceAllString(content, "") - plainPad := boxWidth - 2 - len(plain) + plainPad := boxWidth - 2 - utf8.RuneCountInString(plain) if plainPad < 0 { plainPad = 0 } @@ -131,6 +137,16 @@ func boxRow(w *os.File, content string, visible int) string { return fmt.Sprintf("| %s%s |", content, strings.Repeat(" ", pad)) } +// PrintUpdateReminder writes a one-liner update notice to stderr. +func PrintUpdateReminder(current, latest string) { + if ColorEnabled() { + fmt.Fprintf(os.Stderr, " %sUpdate available:%s %s → v%s. Run %scg update%s to upgrade.\n\n", + yellowBold, colorReset, current, latest, yellowBold, colorReset) + } else { + fmt.Fprintf(os.Stderr, " Update available: %s → v%s. Run `cg update` to upgrade.\n\n", current, latest) + } +} + // BannerLines is the number of terminal rows FprintBanner occupies // (leading \n + text + trailing \n\n). Used by watch to position the // status line without hardcoding a row number. diff --git a/internal/updater/checker.go b/internal/updater/checker.go new file mode 100644 index 0000000..f0cd616 --- /dev/null +++ b/internal/updater/checker.go @@ -0,0 +1,141 @@ +package updater + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + githubReleasesURL = "https://api.github.com/repos/coingecko/coingecko-cli/releases/latest" + cacheTTL = 24 * time.Hour +) + +// Info holds the result of an update check. +type Info struct { + CurrentVersion string + LatestVersion string + UpdateAvailable bool +} + +type cacheEntry struct { + CheckedAt time.Time `json:"checked_at"` + LatestVersion string `json:"latest_version"` +} + +// Check returns update info, or nil if the check should be skipped or fails. +// Results are cached for 24 hours. Set CG_NO_UPDATE_CHECK=1 to skip. +func Check(currentVersion string) *Info { + if os.Getenv("CG_NO_UPDATE_CHECK") == "1" || currentVersion == "dev" || currentVersion == "" { + return nil + } + + latest := cachedVersion() + if latest == "" { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + v, err := fetchLatest(ctx) + if err != nil || v == "" { + return nil + } + saveCache(v) + latest = v + } + + return &Info{ + CurrentVersion: currentVersion, + LatestVersion: latest, + UpdateAvailable: latest != currentVersion, + } +} + +// FetchLatest fetches the latest release tag from GitHub, updates the cache, and returns the tag. +func FetchLatest() (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + v, err := fetchLatest(ctx) + if err != nil { + return "", err + } + if v == "" { + return "", fmt.Errorf("GitHub returned empty version tag") + } + saveCache(v) + return v, nil +} + +func fetchLatest(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubReleasesURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "coingecko-cli") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned %d", resp.StatusCode) + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + return strings.TrimPrefix(release.TagName, "v"), nil +} + +func cacheFilePath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "coingecko-cli", "update_check.json"), nil +} + +func cachedVersion() string { + path, err := cacheFilePath() + if err != nil { + return "" + } + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var c cacheEntry + if err := json.Unmarshal(data, &c); err != nil { + return "" + } + if time.Since(c.CheckedAt) > cacheTTL { + return "" + } + return c.LatestVersion +} + +func saveCache(latest string) { + path, err := cacheFilePath() + if err != nil { + return + } + _ = os.MkdirAll(filepath.Dir(path), 0o700) + c := cacheEntry{ + CheckedAt: time.Now(), + LatestVersion: latest, + } + data, err := json.Marshal(c) + if err != nil { + return + } + _ = os.WriteFile(path, data, 0o600) +} From 628067e35f7ac512a238347a9396085ab2caa336 Mon Sep 17 00:00:00 2001 From: irisyann Date: Tue, 21 Apr 2026 16:34:58 +0800 Subject: [PATCH 02/10] Fix lint error --- internal/updater/checker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/updater/checker.go b/internal/updater/checker.go index f0cd616..13d6605 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -81,7 +81,7 @@ func fetchLatest(ctx context.Context) (string, error) { if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("GitHub API returned %d", resp.StatusCode) From b2a1b692479b415228a6a2d8b6f3aa587e027e00 Mon Sep 17 00:00:00 2001 From: irisyann Date: Tue, 21 Apr 2026 21:51:22 +0800 Subject: [PATCH 03/10] Update readme --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 1897a54..369f404 100644 --- a/README.md +++ b/README.md @@ -333,6 +333,21 @@ cg watch --ids bitcoin --dry-run # Show WebSocket request info --- +### `cg update` — Upgrade the CLI + +Check for a new version and upgrade in one step. Auto-detects whether you installed via Homebrew, `go install`, or the install script, and hands off to the right tool. + +```sh +cg update + +# Override install method if auto-detection gets it wrong +cg update --method homebrew # or: go, script +``` + +The CLI also checks for updates on launch (cached 24h) and shows a reminder if you're behind. Set `CG_NO_UPDATE_CHECK=1` to disable this in CI. + +--- + ## Category Filtering CoinGecko tracks 500+ categories including Real World Assets, commodities, and tokenized stocks. Use the `--category` flag to filter: @@ -422,6 +437,7 @@ Commands: top-gainers-losers Show top gaining and losing coins (paid plans only) watch Stream live coin prices via WebSocket (analyst or above) tui Interactive terminal UI (markets, trending) + update Upgrade the CLI to the latest version commands List all commands with API metadata (for agents/LLMs) help Print help for a command From f2f14171602d11360b3de30b0f745d8877086b9a Mon Sep 17 00:00:00 2001 From: irisyann Date: Wed, 22 Apr 2026 12:32:21 +0800 Subject: [PATCH 04/10] Ensure version prefix is trimmed before running comparison --- cmd/update.go | 5 +++-- internal/display/banner.go | 2 +- internal/updater/checker.go | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/update.go b/cmd/update.go index efb89e3..eddafa9 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -49,7 +49,8 @@ func runUpdate(cmd *cobra.Command, args []string) error { return fmt.Errorf("unexpected version format from GitHub: %q", latest) } - if latest == version { + currentVer := strings.TrimPrefix(version, "v") + if latest == currentVer { warnf("Already up to date (%s).\n", version) return nil } @@ -58,7 +59,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { var confirmed bool if err := huh.NewConfirm(). - Title(fmt.Sprintf("Update cg v%s → v%s?", version, latest)). + Title(fmt.Sprintf("Update cg v%s → v%s?", currentVer, latest)). Value(&confirmed). Run(); err != nil { if errors.Is(err, huh.ErrUserAborted) { diff --git a/internal/display/banner.go b/internal/display/banner.go index fa1da13..95eb794 100644 --- a/internal/display/banner.go +++ b/internal/display/banner.go @@ -143,7 +143,7 @@ func PrintUpdateReminder(current, latest string) { fmt.Fprintf(os.Stderr, " %sUpdate available:%s %s → v%s. Run %scg update%s to upgrade.\n\n", yellowBold, colorReset, current, latest, yellowBold, colorReset) } else { - fmt.Fprintf(os.Stderr, " Update available: %s → v%s. Run `cg update` to upgrade.\n\n", current, latest) + fmt.Fprintf(os.Stderr, " Update available: v%s → v%s. Run `cg update` to upgrade.\n\n", current, latest) } } diff --git a/internal/updater/checker.go b/internal/updater/checker.go index 13d6605..35c4a66 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -50,7 +50,7 @@ func Check(currentVersion string) *Info { return &Info{ CurrentVersion: currentVersion, LatestVersion: latest, - UpdateAvailable: latest != currentVersion, + UpdateAvailable: latest != strings.TrimPrefix(currentVersion, "v"), } } From c56f82fb59b14f881ebbc230dba34e1a86bcfef7 Mon Sep 17 00:00:00 2001 From: irisyann Date: Wed, 22 Apr 2026 13:56:59 +0800 Subject: [PATCH 05/10] Add test cases for checker and update --- cmd/update.go | 10 ++- cmd/update_test.go | 75 +++++++++++++++++ internal/updater/checker.go | 28 ++++++- internal/updater/checker_test.go | 139 +++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 cmd/update_test.go create mode 100644 internal/updater/checker_test.go diff --git a/cmd/update.go b/cmd/update.go index eddafa9..2905807 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -15,6 +15,8 @@ import ( "github.com/spf13/cobra" ) +var fetchLatestFunc = updater.FetchLatest + var updateCmd = &cobra.Command{ Use: "update", Short: "Upgrade the CLI to the latest version", @@ -41,7 +43,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { } warnf("Checking for updates...\n") - latest, err := updater.FetchLatest() + latest, err := fetchLatestFunc() if err != nil { return fmt.Errorf("checking for updates: %w", err) } @@ -54,8 +56,12 @@ func runUpdate(cmd *cobra.Command, args []string) error { warnf("Already up to date (%s).\n", version) return nil } + if updater.VersionGreater(currentVer, latest) { + warnf("Current version (v%s) is ahead of the latest release (v%s).\n", currentVer, latest) + return nil + } - warnf("Current: v%s → Latest: v%s (install via: %s)\n\n", version, latest, method) + warnf("Current: v%s → Latest: v%s (install via: %s)\n\n", currentVer, latest, method) var confirmed bool if err := huh.NewConfirm(). diff --git a/cmd/update_test.go b/cmd/update_test.go new file mode 100644 index 0000000..1eed7d5 --- /dev/null +++ b/cmd/update_test.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidVersion(t *testing.T) { + valid := []string{"1.2.3", "0.0.1", "10.20.30", "0.0.0"} + for _, v := range valid { + assert.True(t, validVersion(v), "expected valid: %s", v) + } + + invalid := []string{"v1.2.3", "1.2", "1.2.3.4", "1.2.x", "", "abc", "1.2."} + for _, v := range invalid { + assert.False(t, validVersion(v), "expected invalid: %s", v) + } +} + +func TestRunUpdate_AlreadyUpToDate(t *testing.T) { + orig := fetchLatestFunc + fetchLatestFunc = func() (string, error) { return "1.2.3", nil } + t.Cleanup(func() { fetchLatestFunc = orig }) + + origVersion := version + version = "1.2.3" + t.Cleanup(func() { version = origVersion }) + + err := updateCmd.RunE(updateCmd, nil) + assert.NoError(t, err) +} + +func TestRunUpdate_CurrentAhead(t *testing.T) { + orig := fetchLatestFunc + fetchLatestFunc = func() (string, error) { return "1.0.0", nil } + t.Cleanup(func() { fetchLatestFunc = orig }) + + origVersion := version + version = "2.0.0" + t.Cleanup(func() { version = origVersion }) + + err := updateCmd.RunE(updateCmd, nil) + assert.NoError(t, err) +} + +func TestRunUpdate_InvalidVersionFromGitHub(t *testing.T) { + orig := fetchLatestFunc + fetchLatestFunc = func() (string, error) { return "not-a-version", nil } + t.Cleanup(func() { fetchLatestFunc = orig }) + + origVersion := version + version = "1.2.3" + t.Cleanup(func() { version = origVersion }) + + err := updateCmd.RunE(updateCmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected version format") +} + +func TestRunUpdate_FetchError(t *testing.T) { + orig := fetchLatestFunc + fetchLatestFunc = func() (string, error) { return "", fmt.Errorf("network timeout") } + t.Cleanup(func() { fetchLatestFunc = orig }) + + origVersion := version + version = "1.2.3" + t.Cleanup(func() { version = origVersion }) + + err := updateCmd.RunE(updateCmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "network timeout") +} diff --git a/internal/updater/checker.go b/internal/updater/checker.go index 35c4a66..9f886e4 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -11,9 +11,11 @@ import ( "time" ) -const ( +const cacheTTL = 24 * time.Hour + +var ( githubReleasesURL = "https://api.github.com/repos/coingecko/coingecko-cli/releases/latest" - cacheTTL = 24 * time.Hour + userConfigDirFunc = os.UserConfigDir ) // Info holds the result of an update check. @@ -50,7 +52,7 @@ func Check(currentVersion string) *Info { return &Info{ CurrentVersion: currentVersion, LatestVersion: latest, - UpdateAvailable: latest != strings.TrimPrefix(currentVersion, "v"), + UpdateAvailable: VersionGreater(latest, strings.TrimPrefix(currentVersion, "v")), } } @@ -97,7 +99,7 @@ func fetchLatest(ctx context.Context) (string, error) { } func cacheFilePath() (string, error) { - dir, err := os.UserConfigDir() + dir, err := userConfigDirFunc() if err != nil { return "", err } @@ -139,3 +141,21 @@ func saveCache(latest string) { } _ = os.WriteFile(path, data, 0o600) } + +// VersionGreater reports whether bare semver a is strictly greater than b. +// Both must be "MAJOR.MINOR.PATCH" without a leading v. +func VersionGreater(a, b string) bool { + ap, bp := parseSemver(a), parseSemver(b) + for i := range 3 { + if ap[i] != bp[i] { + return ap[i] > bp[i] + } + } + return false +} + +func parseSemver(v string) [3]int { + var p [3]int + fmt.Sscanf(v, "%d.%d.%d", &p[0], &p[1], &p[2]) + return p +} diff --git a/internal/updater/checker_test.go b/internal/updater/checker_test.go new file mode 100644 index 0000000..b4d79b7 --- /dev/null +++ b/internal/updater/checker_test.go @@ -0,0 +1,139 @@ +package updater + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersionGreater(t *testing.T) { + tests := []struct { + a, b string + want bool + }{ + {"1.2.3", "1.2.2", true}, + {"1.2.3", "1.2.3", false}, + {"1.2.3", "1.2.4", false}, + {"2.0.0", "1.9.9", true}, + {"1.10.0", "1.9.0", true}, // numeric, not lexicographic + {"1.9.0", "1.10.0", false}, // lexicographic would be wrong here + {"0.0.1", "0.0.0", true}, + {"0.1.0", "0.0.9", true}, + {"0.0.0", "0.0.1", false}, + } + for _, tc := range tests { + assert.Equal(t, tc.want, VersionGreater(tc.a, tc.b), "%s > %s", tc.a, tc.b) + } +} + +func releaseServer(t *testing.T, tag string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"tag_name": tag}) + })) + origURL := githubReleasesURL + githubReleasesURL = srv.URL + t.Cleanup(func() { + githubReleasesURL = origURL + srv.Close() + }) +} + +func isolateCache(t *testing.T) { + t.Helper() + tmp := t.TempDir() + orig := userConfigDirFunc + userConfigDirFunc = func() (string, error) { return tmp, nil } + t.Cleanup(func() { userConfigDirFunc = orig }) +} + +func TestCheck_SkipsOnDevVersion(t *testing.T) { + assert.Nil(t, Check("dev")) +} + +func TestCheck_SkipsOnEmptyVersion(t *testing.T) { + assert.Nil(t, Check("")) +} + +func TestCheck_SkipsOnEnvVar(t *testing.T) { + t.Setenv("CG_NO_UPDATE_CHECK", "1") + assert.Nil(t, Check("1.2.3")) +} + +func TestCheck_UpdateAvailable(t *testing.T) { + isolateCache(t) + releaseServer(t, "v1.3.0") + + info := Check("1.2.0") + require.NotNil(t, info) + assert.True(t, info.UpdateAvailable) + assert.Equal(t, "1.2.0", info.CurrentVersion) + assert.Equal(t, "1.3.0", info.LatestVersion) +} + +func TestCheck_AlreadyCurrent(t *testing.T) { + isolateCache(t) + releaseServer(t, "v1.2.0") + + info := Check("1.2.0") + require.NotNil(t, info) + assert.False(t, info.UpdateAvailable) +} + +func TestCheck_CurrentAhead(t *testing.T) { + isolateCache(t) + releaseServer(t, "v1.2.0") + + info := Check("2.0.0") + require.NotNil(t, info) + assert.False(t, info.UpdateAvailable) +} + +func TestCheck_UsesCache(t *testing.T) { + isolateCache(t) + saveCache("1.9.9") + + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + _ = json.NewEncoder(w).Encode(map[string]string{"tag_name": "v2.0.0"}) + })) + defer srv.Close() + origURL := githubReleasesURL + githubReleasesURL = srv.URL + defer func() { githubReleasesURL = origURL }() + + info := Check("1.0.0") + require.NotNil(t, info) + assert.Equal(t, 0, calls, "should use cache without calling GitHub") + assert.Equal(t, "1.9.9", info.LatestVersion) +} + +func TestFetchLatest_StripsVPrefix(t *testing.T) { + isolateCache(t) + releaseServer(t, "v1.5.2") + + v, err := FetchLatest() + require.NoError(t, err) + assert.Equal(t, "1.5.2", v) +} + +func TestFetchLatest_ServerError(t *testing.T) { + isolateCache(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + origURL := githubReleasesURL + githubReleasesURL = srv.URL + defer func() { githubReleasesURL = origURL }() + + _, err := FetchLatest() + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} From 351b747f38583654c58e1b9ff7052b5b6c28c352 Mon Sep 17 00:00:00 2001 From: irisyann Date: Wed, 22 Apr 2026 14:40:45 +0800 Subject: [PATCH 06/10] Fix lint issues --- internal/updater/checker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/updater/checker.go b/internal/updater/checker.go index 9f886e4..928ba01 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -156,6 +156,6 @@ func VersionGreater(a, b string) bool { func parseSemver(v string) [3]int { var p [3]int - fmt.Sscanf(v, "%d.%d.%d", &p[0], &p[1], &p[2]) + _, _ = fmt.Sscanf(v, "%d.%d.%d", &p[0], &p[1], &p[2]) return p } From 03bcd45bc98320a80cd7c3732663d7b75f64076c Mon Sep 17 00:00:00 2001 From: irisyann Date: Mon, 27 Apr 2026 13:01:07 +0800 Subject: [PATCH 07/10] Apply copilot feedback --- cmd/update_test.go | 9 +++++++++ internal/display/banner.go | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cmd/update_test.go b/cmd/update_test.go index 1eed7d5..9dd3d0e 100644 --- a/cmd/update_test.go +++ b/cmd/update_test.go @@ -60,6 +60,15 @@ func TestRunUpdate_InvalidVersionFromGitHub(t *testing.T) { assert.Contains(t, err.Error(), "unexpected version format") } +func TestRunUpdate_InvalidMethod(t *testing.T) { + require.NoError(t, updateCmd.Flags().Set("method", "invalid")) + t.Cleanup(func() { _ = updateCmd.Flags().Set("method", "") }) + + err := updateCmd.RunE(updateCmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown install method") +} + func TestRunUpdate_FetchError(t *testing.T) { orig := fetchLatestFunc fetchLatestFunc = func() (string, error) { return "", fmt.Errorf("network timeout") } diff --git a/internal/display/banner.go b/internal/display/banner.go index 95eb794..60c6f12 100644 --- a/internal/display/banner.go +++ b/internal/display/banner.go @@ -139,8 +139,10 @@ func boxRow(w *os.File, content string, visible int) string { // PrintUpdateReminder writes a one-liner update notice to stderr. func PrintUpdateReminder(current, latest string) { + current = strings.TrimPrefix(current, "v") + latest = strings.TrimPrefix(latest, "v") if ColorEnabled() { - fmt.Fprintf(os.Stderr, " %sUpdate available:%s %s → v%s. Run %scg update%s to upgrade.\n\n", + fmt.Fprintf(os.Stderr, " %sUpdate available:%s v%s → v%s. Run %scg update%s to upgrade.\n\n", yellowBold, colorReset, current, latest, yellowBold, colorReset) } else { fmt.Fprintf(os.Stderr, " Update available: v%s → v%s. Run `cg update` to upgrade.\n\n", current, latest) From 6ce7f5827d845c9034ceecda4fe8ed3db55dffaa Mon Sep 17 00:00:00 2001 From: irisyann Date: Mon, 4 May 2026 11:22:53 +0800 Subject: [PATCH 08/10] refactor: centralise semver validation and guard update cache against pre-release tags Move ValidVersion into the updater package so both the background Check and the update command share one regex. Check() and FetchLatest() now reject non-MAJOR.MINOR.PATCH tags (e.g. rc1 builds) before writing to cache. Also normalise version prefix display in version cmd and banner. Co-Authored-By: Claude Sonnet 4.6 --- cmd/update.go | 10 +--------- cmd/update_test.go | 7 ++++--- cmd/version.go | 3 ++- internal/display/banner.go | 9 +++++---- internal/updater/checker.go | 12 +++++++++--- 5 files changed, 21 insertions(+), 20 deletions(-) diff --git a/cmd/update.go b/cmd/update.go index 2905807..57cb373 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -6,11 +6,9 @@ import ( "os" "os/exec" "path/filepath" - "regexp" "strings" "github.com/charmbracelet/huh" - "github.com/coingecko/coingecko-cli/internal/display" "github.com/coingecko/coingecko-cli/internal/updater" "github.com/spf13/cobra" ) @@ -29,8 +27,6 @@ func init() { } func runUpdate(cmd *cobra.Command, args []string) error { - display.PrintBanner() - method, _ := cmd.Flags().GetString("method") if method == "" { method = detectInstallMethod() @@ -47,7 +43,7 @@ func runUpdate(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("checking for updates: %w", err) } - if !validVersion(latest) { + if !updater.ValidVersion(latest) { return fmt.Errorf("unexpected version format from GitHub: %q", latest) } @@ -80,10 +76,6 @@ func runUpdate(cmd *cobra.Command, args []string) error { return runInstallCommand(method) } -var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) - -func validVersion(v string) bool { return semverRe.MatchString(v) } - func detectInstallMethod() string { exe, err := os.Executable() if err != nil { diff --git a/cmd/update_test.go b/cmd/update_test.go index 9dd3d0e..79f0e9e 100644 --- a/cmd/update_test.go +++ b/cmd/update_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/coingecko/coingecko-cli/internal/updater" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -11,12 +12,12 @@ import ( func TestValidVersion(t *testing.T) { valid := []string{"1.2.3", "0.0.1", "10.20.30", "0.0.0"} for _, v := range valid { - assert.True(t, validVersion(v), "expected valid: %s", v) + assert.True(t, updater.ValidVersion(v), "expected valid: %s", v) } - invalid := []string{"v1.2.3", "1.2", "1.2.3.4", "1.2.x", "", "abc", "1.2."} + invalid := []string{"v1.2.3", "1.2", "1.2.3.4", "1.2.x", "", "abc", "1.2.", "1.2.3-rc1"} for _, v := range invalid { - assert.False(t, validVersion(v), "expected invalid: %s", v) + assert.False(t, updater.ValidVersion(v), "expected invalid: %s", v) } } diff --git a/cmd/version.go b/cmd/version.go index e5d4354..3126f5f 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "runtime" + "strings" "github.com/spf13/cobra" ) @@ -21,7 +22,7 @@ var versionCmd = &cobra.Command{ "arch": runtime.GOARCH, }) } - fmt.Printf("cg %s\n", version) + fmt.Printf("cg v%s\n", strings.TrimPrefix(version, "v")) fmt.Printf(" commit: %s\n", commit) fmt.Printf(" built: %s\n", date) fmt.Printf(" go: %s\n", runtime.Version()) diff --git a/internal/display/banner.go b/internal/display/banner.go index 60c6f12..b605d1a 100644 --- a/internal/display/banner.go +++ b/internal/display/banner.go @@ -52,12 +52,13 @@ func PrintWelcomeBox(version string) { blank := "|" + strings.Repeat(" ", boxWidth) + "|" sep := boxRow(w, dimColor+strings.Repeat("-", boxWidth-2)+colorReset, boxWidth-2) + v := "v" + strings.TrimPrefix(version, "v") // "◆ CoinGecko CLI " is 16 runes; version length varies. - versionVisible := 16 + utf8.RuneCountInString(version) + versionVisible := 16 + utf8.RuneCountInString(v) _, _ = fmt.Fprintln(w, top) _, _ = fmt.Fprintln(w, blank) - printColoredRow(w, brandGreen+"◆ CoinGecko CLI "+colorReset+version, versionVisible) + printColoredRow(w, brandGreen+"◆ CoinGecko CLI "+colorReset+v, versionVisible) printColoredRow(w, yellowBold+"Official API Command Line Interface"+colorReset, 35) _, _ = fmt.Fprintln(w, blank) _, _ = fmt.Fprintln(w, sep) @@ -142,10 +143,10 @@ func PrintUpdateReminder(current, latest string) { current = strings.TrimPrefix(current, "v") latest = strings.TrimPrefix(latest, "v") if ColorEnabled() { - fmt.Fprintf(os.Stderr, " %sUpdate available:%s v%s → v%s. Run %scg update%s to upgrade.\n\n", + _, _ = fmt.Fprintf(os.Stderr, " %sUpdate available:%s v%s → v%s. Run %scg update%s to upgrade.\n\n", yellowBold, colorReset, current, latest, yellowBold, colorReset) } else { - fmt.Fprintf(os.Stderr, " Update available: v%s → v%s. Run `cg update` to upgrade.\n\n", current, latest) + _, _ = fmt.Fprintf(os.Stderr, " Update available: v%s → v%s. Run `cg update` to upgrade.\n\n", current, latest) } } diff --git a/internal/updater/checker.go b/internal/updater/checker.go index 928ba01..93365cc 100644 --- a/internal/updater/checker.go +++ b/internal/updater/checker.go @@ -7,12 +7,18 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strings" "time" ) const cacheTTL = 24 * time.Hour +var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + +// ValidVersion reports whether v is a bare MAJOR.MINOR.PATCH semver string. +func ValidVersion(v string) bool { return semverRe.MatchString(v) } + var ( githubReleasesURL = "https://api.github.com/repos/coingecko/coingecko-cli/releases/latest" userConfigDirFunc = os.UserConfigDir @@ -42,7 +48,7 @@ func Check(currentVersion string) *Info { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() v, err := fetchLatest(ctx) - if err != nil || v == "" { + if err != nil || !ValidVersion(v) { return nil } saveCache(v) @@ -64,8 +70,8 @@ func FetchLatest() (string, error) { if err != nil { return "", err } - if v == "" { - return "", fmt.Errorf("GitHub returned empty version tag") + if !ValidVersion(v) { + return "", fmt.Errorf("GitHub returned unexpected version tag: %q", v) } saveCache(v) return v, nil From f0cfb424329e07c97911dacc0d06551da3c07dfc Mon Sep 17 00:00:00 2001 From: irisyann Date: Mon, 4 May 2026 11:27:10 +0800 Subject: [PATCH 09/10] test: extract classifyInstallPath and add tests for install method detection Co-Authored-By: Claude Sonnet 4.6 --- cmd/update.go | 5 ++++ cmd/update_test.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/cmd/update.go b/cmd/update.go index 57cb373..eefbb63 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -85,7 +85,12 @@ func detectInstallMethod() string { if err != nil { return "script" } + return classifyInstallPath(exe) +} +// classifyInstallPath returns the install method ("homebrew", "go", or "script") +// for a resolved executable path. +func classifyInstallPath(exe string) string { if strings.Contains(exe, "/Cellar/") || strings.Contains(exe, "/homebrew/") || strings.Contains(exe, "/opt/homebrew/") { diff --git a/cmd/update_test.go b/cmd/update_test.go index 79f0e9e..e6bf5ff 100644 --- a/cmd/update_test.go +++ b/cmd/update_test.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "os" + "path/filepath" "testing" "github.com/coingecko/coingecko-cli/internal/updater" @@ -70,6 +72,77 @@ func TestRunUpdate_InvalidMethod(t *testing.T) { assert.Contains(t, err.Error(), "unknown install method") } +func TestClassifyInstallPath_Homebrew(t *testing.T) { + cases := []struct { + path string + desc string + }{ + {"/usr/local/Cellar/cg/1.0.0/bin/cg", "Cellar path"}, + {"/home/linuxbrew/.linuxbrew/homebrew/bin/cg", "homebrew path"}, + {"/opt/homebrew/bin/cg", "opt/homebrew path"}, + } + for _, tc := range cases { + assert.Equal(t, "homebrew", classifyInstallPath(tc.path), tc.desc) + } +} + +func TestClassifyInstallPath_Go_ExplicitGOBIN(t *testing.T) { + gobin := t.TempDir() + t.Setenv("GOBIN", gobin) + t.Setenv("GOPATH", "") + + exe := filepath.Join(gobin, "cg") + assert.Equal(t, "go", classifyInstallPath(exe)) +} + +func TestClassifyInstallPath_Go_DefaultGOPATH(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + + t.Setenv("GOBIN", "") + t.Setenv("GOPATH", "") + + exe := filepath.Join(home, "go", "bin", "cg") + assert.Equal(t, "go", classifyInstallPath(exe)) +} + +func TestClassifyInstallPath_Go_ExplicitGOPATH(t *testing.T) { + gopath := t.TempDir() + t.Setenv("GOBIN", "") + t.Setenv("GOPATH", gopath) + + exe := filepath.Join(gopath, "bin", "cg") + assert.Equal(t, "go", classifyInstallPath(exe)) +} + +func TestClassifyInstallPath_Script(t *testing.T) { + t.Setenv("GOBIN", "") + t.Setenv("GOPATH", "") + + cases := []string{ + "/usr/local/bin/cg", + "/home/user/.local/bin/cg", + "/tmp/cg", + } + for _, exe := range cases { + assert.Equal(t, "script", classifyInstallPath(exe), "path: %s", exe) + } +} + +func TestClassifyInstallPath_GoBinNotParentDir(t *testing.T) { + gobin := "/home/user/go/bin" + t.Setenv("GOBIN", gobin) + + // A path that starts with the gobin string but isn't under it (no separator) + exe := gobin + "extra/cg" + assert.Equal(t, "script", classifyInstallPath(exe)) +} + +func TestDetectInstallMethod_ReturnsValidMethod(t *testing.T) { + method := detectInstallMethod() + assert.Contains(t, []string{"homebrew", "go", "script"}, method) +} + func TestRunUpdate_FetchError(t *testing.T) { orig := fetchLatestFunc fetchLatestFunc = func() (string, error) { return "", fmt.Errorf("network timeout") } From 1d31f0a17892c2bd94cb843108b04362c7c35131 Mon Sep 17 00:00:00 2001 From: irisyann Date: Thu, 7 May 2026 13:46:14 +0800 Subject: [PATCH 10/10] Update docs to guide users to alias the coingecko-cli binary to cg --- CLAUDE.md | 2 +- README.md | 12 ++++++++++++ cmd/update.go | 1 + 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e4968a..17befb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ coingecko-cli/ - **Release workflow**: `.github/workflows/release.yml` triggers on `v*` tags, requires `HOMEBREW_TAP_TOKEN` repo secret for tap repo write access - **Tagging**: always tag from `main` after pulling latest — `git tag vX.Y.Z && git push origin vX.Y.Z` - **Install script**: `install.sh` downloads the latest release binary from GitHub Releases -- **Go install**: `go install github.com/coingecko/coingecko-cli@latest` +- **Go install**: `go install github.com/coingecko/coingecko-cli@latest` — produces a binary named `coingecko-cli` (module-path basename). Users alias or symlink it to `cg`; the `cg update --method go` flow prints this heads-up before running install. ## Key Design Decisions diff --git a/README.md b/README.md index 369f404..b451e5d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,18 @@ curl -sSfL https://raw.githubusercontent.com/coingecko/coingecko-cli/main/instal go install github.com/coingecko/coingecko-cli@latest ``` +This installs a binary named `coingecko-cli` (matching the module path). To use it as `cg`, add an alias to your shell rc: + +```sh +alias cg=coingecko-cli +``` + +Or symlink it onto your `$PATH`: + +```sh +ln -s "$(go env GOBIN 2>/dev/null || echo "$(go env GOPATH)/bin")/coingecko-cli" /usr/local/bin/cg +``` + ### Manual Download the binary for your platform from [Releases](https://github.com/coingecko/coingecko-cli/releases), extract, and place `cg` in your `$PATH`. diff --git a/cmd/update.go b/cmd/update.go index eefbb63..641b141 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -121,6 +121,7 @@ func runInstallCommand(method string) error { name = "brew" args = []string{"upgrade", "coingecko/coingecko-cli/cg"} case "go": + warnf("Note: 'go install' produces a binary named 'coingecko-cli'. If you invoke this CLI as 'cg', make sure you have an alias or symlink (e.g. alias cg=coingecko-cli).\n\n") name = "go" args = []string{"install", "github.com/coingecko/coingecko-cli@latest"} default: // "script"