From 9217b8bb108abc355befa88610ee55d0f3dbb920 Mon Sep 17 00:00:00 2001 From: Cody Spath Date: Mon, 11 May 2026 15:09:30 -0400 Subject: [PATCH 1/6] add command to check for updates --- cmd/cliflags/flags.go | 93 ++++++++-------- cmd/config/testdata/help.golden | 1 + cmd/root.go | 25 +++++ internal/config/config.go | 24 +++-- internal/update/update.go | 183 ++++++++++++++++++++++++++++++++ internal/update/update_test.go | 164 ++++++++++++++++++++++++++++ 6 files changed, 437 insertions(+), 53 deletions(-) create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go diff --git a/cmd/cliflags/flags.go b/cmd/cliflags/flags.go index c644282df..763739b4b 100644 --- a/cmd/cliflags/flags.go +++ b/cmd/cliflags/flags.go @@ -27,55 +27,58 @@ const ( DevStreamURIDefault = "https://stream.launchdarkly.com" PortDefault = "8765" - AccessTokenFlag = "access-token" - AnalyticsOptOut = "analytics-opt-out" - BaseURIFlag = "base-uri" - CorsEnabledFlag = "cors-enabled" - CorsOriginFlag = "cors-origin" - DataFlag = "data" - DryRunFlag = "dry-run" - DevStreamURIFlag = "dev-stream-uri" - EmailsFlag = "emails" - EnvironmentFlag = "environment" - FieldsFlag = "fields" - FlagFlag = "flag" - JSONFlag = "json" - OutputFlag = "output" - PortFlag = "port" - ProjectFlag = "project" - RoleFlag = "role" - SyncOnceFlag = "sync-once" + AccessTokenFlag = "access-token" + AnalyticsOptOut = "analytics-opt-out" + BaseURIFlag = "base-uri" + CorsEnabledFlag = "cors-enabled" + CorsOriginFlag = "cors-origin" + DataFlag = "data" + DryRunFlag = "dry-run" + DevStreamURIFlag = "dev-stream-uri" + EmailsFlag = "emails" + EnvironmentFlag = "environment" + FieldsFlag = "fields" + FlagFlag = "flag" + JSONFlag = "json" + OutputFlag = "output" + PortFlag = "port" + ProjectFlag = "project" + RoleFlag = "role" + SyncOnceFlag = "sync-once" + UpdateCheckOptOut = "update-check-opt-out" - AccessTokenFlagDescription = "LaunchDarkly access token with write-level access" - AnalyticsOptOutDescription = "Opt out of analytics tracking" - BaseURIFlagDescription = "LaunchDarkly base URI" - CorsEnabledFlagDescription = "Enable CORS headers for browser-based developer tools (default: false)" - CorsOriginFlagDescription = "Allowed CORS origin. Use '*' for all origins (default: '*')" - DevStreamURIDescription = "Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint" - DryRunFlagDescription = "Validate the change without persisting it. Returns a preview of the result." - EnvironmentFlagDescription = "Default environment key" - FieldsFlagDescription = "Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)" - FlagFlagDescription = "Default feature flag key" - JSONFlagDescription = "Output JSON format (shorthand for --output json)" - OutputFlagDescription = "Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise)" - PortFlagDescription = "Port for the dev server to run on" - ProjectFlagDescription = "Default project key" - SyncOnceFlagDescription = "Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied." + AccessTokenFlagDescription = "LaunchDarkly access token with write-level access" + AnalyticsOptOutDescription = "Opt out of analytics tracking" + BaseURIFlagDescription = "LaunchDarkly base URI" + CorsEnabledFlagDescription = "Enable CORS headers for browser-based developer tools (default: false)" + CorsOriginFlagDescription = "Allowed CORS origin. Use '*' for all origins (default: '*')" + DevStreamURIDescription = "Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint" + DryRunFlagDescription = "Validate the change without persisting it. Returns a preview of the result." + EnvironmentFlagDescription = "Default environment key" + FieldsFlagDescription = "Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)" + FlagFlagDescription = "Default feature flag key" + JSONFlagDescription = "Output JSON format (shorthand for --output json)" + OutputFlagDescription = "Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise)" + PortFlagDescription = "Port for the dev server to run on" + ProjectFlagDescription = "Default project key" + SyncOnceFlagDescription = "Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied." + UpdateCheckOptOutDescription = "Opt out of update check" ) func AllFlagsHelp() map[string]string { return map[string]string{ - AccessTokenFlag: AccessTokenFlagDescription, - AnalyticsOptOut: AnalyticsOptOutDescription, - BaseURIFlag: BaseURIFlagDescription, - CorsEnabledFlag: CorsEnabledFlagDescription, - CorsOriginFlag: CorsOriginFlagDescription, - DevStreamURIFlag: DevStreamURIDescription, - EnvironmentFlag: EnvironmentFlagDescription, - FlagFlag: FlagFlagDescription, - OutputFlag: OutputFlagDescription, - PortFlag: PortFlagDescription, - ProjectFlag: ProjectFlagDescription, - SyncOnceFlag: SyncOnceFlagDescription, + AccessTokenFlag: AccessTokenFlagDescription, + AnalyticsOptOut: AnalyticsOptOutDescription, + BaseURIFlag: BaseURIFlagDescription, + CorsEnabledFlag: CorsEnabledFlagDescription, + CorsOriginFlag: CorsOriginFlagDescription, + DevStreamURIFlag: DevStreamURIDescription, + EnvironmentFlag: EnvironmentFlagDescription, + FlagFlag: FlagFlagDescription, + OutputFlag: OutputFlagDescription, + PortFlag: PortFlagDescription, + ProjectFlag: ProjectFlagDescription, + SyncOnceFlag: SyncOnceFlagDescription, + UpdateCheckOptOut: UpdateCheckOptOutDescription, } } diff --git a/cmd/config/testdata/help.golden b/cmd/config/testdata/help.golden index b94c7ceaf..a485fb9b8 100644 --- a/cmd/config/testdata/help.golden +++ b/cmd/config/testdata/help.golden @@ -13,6 +13,7 @@ Supported settings: - `port`: Port for the dev server to run on - `project`: Default project key - `sync-once`: Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied. +- `update-check-opt-out`: Opt out of update check Usage: ldcli config [flags] diff --git a/cmd/root.go b/cmd/root.go index d498e07eb..210261e36 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/google/uuid" "github.com/spf13/cobra" @@ -35,6 +36,7 @@ import ( "github.com/launchdarkly/ldcli/internal/members" "github.com/launchdarkly/ldcli/internal/projects" "github.com/launchdarkly/ldcli/internal/resources" + "github.com/launchdarkly/ldcli/internal/update" ) type APIClients struct { @@ -319,6 +321,19 @@ See each command's help for details on how to use the generated script.`, rootCm rootCmd.cmd.SetUsageTemplate(getUsageTemplate()) + // Start update check in the background so it runs in parallel with command execution. + type updateResult struct { + info *update.UpdateInfo + } + updateCh := make(chan updateResult, 1) + skipUpdateCheck := viper.GetBool(cliflags.UpdateCheckOptOut) || + !term.IsTerminal(int(os.Stderr.Fd())) + if !skipUpdateCheck { + go func() { + updateCh <- updateResult{info: update.CheckForUpdate(version)} + }() + } + err = rootCmd.Execute() var outcome string @@ -349,6 +364,16 @@ See each command's help for details on how to use the generated script.`, rootCm } analyticsClient.Wait() + + if !skipUpdateCheck { + select { + case result := <-updateCh: + if result.info != nil && result.info.IsNewer { + fmt.Fprint(os.Stderr, update.NotificationMessage(result.info)) + } + case <-time.After(500 * time.Millisecond): + } + } } // setFlagsFromConfig reads in the config file if it exists and uses any flag values for commands. diff --git a/internal/config/config.go b/internal/config/config.go index b6f720042..421560534 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,14 +20,15 @@ type ReadFile func(name string) ([]byte, error) // Config represents the data stored in the config file. type Config struct { - AccessToken string `json:"access-token,omitempty" yaml:"access-token,omitempty"` - AnalyticsOptOut *bool `json:"analytics-opt-out,omitempty" yaml:"analytics-opt-out,omitempty"` - BaseURI string `json:"base-uri,omitempty" yaml:"base-uri,omitempty"` - DevStreamURI string `json:"dev-stream-uri,omitempty" yaml:"dev-stream-uri,omitempty"` - Environment string `json:"environment,omitempty" yaml:"environment,omitempty"` - Flag string `json:"flag,omitempty" yaml:"flag,omitempty"` - Output string `json:"output,omitempty" yaml:"output,omitempty"` - Project string `json:"project,omitempty" yaml:"project,omitempty"` + AccessToken string `json:"access-token,omitempty" yaml:"access-token,omitempty"` + AnalyticsOptOut *bool `json:"analytics-opt-out,omitempty" yaml:"analytics-opt-out,omitempty"` + BaseURI string `json:"base-uri,omitempty" yaml:"base-uri,omitempty"` + DevStreamURI string `json:"dev-stream-uri,omitempty" yaml:"dev-stream-uri,omitempty"` + Environment string `json:"environment,omitempty" yaml:"environment,omitempty"` + Flag string `json:"flag,omitempty" yaml:"flag,omitempty"` + Output string `json:"output,omitempty" yaml:"output,omitempty"` + Project string `json:"project,omitempty" yaml:"project,omitempty"` + UpdateCheckOptOut *bool `json:"update-check-opt-out,omitempty" yaml:"update-check-opt-out,omitempty"` } func New(filename string, readFile ReadFile) (Config, error) { @@ -95,6 +96,13 @@ func (c Config) Update(kvs []string) (Config, []string, error) { c.Output = val.String() case cliflags.ProjectFlag: c.Project = v + case cliflags.UpdateCheckOptOut: + val, err := strconv.ParseBool(v) + if err != nil { + return Config{}, nil, errors.NewError("update-check-opt-out must be true or false") + } + + c.UpdateCheckOptOut = &val } } } diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 000000000..381711957 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,183 @@ +package update + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/launchdarkly/ldcli/internal/config" +) + +const ( + defaultGitHubReleasesURL = "https://api.github.com/repos/launchdarkly/ldcli/releases/latest" + cacheTTL = 24 * time.Hour + cacheFilename = "update-check.json" + httpTimeout = 3 * time.Second +) + +var releasesURL = defaultGitHubReleasesURL + +type UpdateInfo struct { + CurrentVersion string + LatestVersion string + IsNewer bool +} + +type cacheEntry struct { + LatestVersion string `json:"latest_version"` + CheckedAt time.Time `json:"checked_at"` +} + +type githubRelease struct { + TagName string `json:"tag_name"` +} + +// cacheFilePathFn is a function variable to allow test overrides. +var cacheFilePathFn = defaultCacheFilePath + +func defaultCacheFilePath() string { + configFile := config.GetConfigFile() + return filepath.Join(filepath.Dir(configFile), cacheFilename) +} + +func readCache() (*cacheEntry, error) { + data, err := os.ReadFile(cacheFilePathFn()) + if err != nil { + return nil, err + } + + var entry cacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return nil, err + } + + return &entry, nil +} + +func writeCache(entry *cacheEntry) { + data, err := json.Marshal(entry) + if err != nil { + return + } + + _ = os.MkdirAll(filepath.Dir(cacheFilePathFn()), os.ModePerm) + _ = os.WriteFile(cacheFilePathFn(), data, 0644) +} + +func fetchLatestVersion(client *http.Client) (string, error) { + req, err := http.NewRequest(http.MethodGet, releasesURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var release githubRelease + if err := json.Unmarshal(body, &release); err != nil { + return "", err + } + + return normalizeVersion(release.TagName), nil +} + +// normalizeVersion strips a leading "v" prefix if present. +func normalizeVersion(v string) string { + return strings.TrimPrefix(v, "v") +} + +// compareVersions returns true if latest is newer than current. +// Uses a simple split-and-compare on major.minor.patch integers. +func compareVersions(current, latest string) bool { + parseParts := func(v string) [3]int { + var parts [3]int + n, _ := fmt.Sscanf(v, "%d.%d.%d", &parts[0], &parts[1], &parts[2]) + if n < 1 { + return [3]int{} + } + return parts + } + + c := parseParts(current) + l := parseParts(latest) + + for i := 0; i < 3; i++ { + if l[i] > c[i] { + return true + } + if l[i] < c[i] { + return false + } + } + + return false +} + +// CheckForUpdate checks GitHub for the latest release and compares it to the +// current version. It caches the result to avoid hitting the network on every +// invocation. Returns nil when no update is available or on any error. +func CheckForUpdate(currentVersion string) *UpdateInfo { + if currentVersion == "dev" || currentVersion == "test" || currentVersion == "" { + return nil + } + + cached, err := readCache() + if err == nil && time.Since(cached.CheckedAt) < cacheTTL { + if !compareVersions(currentVersion, cached.LatestVersion) { + return nil + } + return &UpdateInfo{ + CurrentVersion: currentVersion, + LatestVersion: cached.LatestVersion, + IsNewer: true, + } + } + + client := &http.Client{Timeout: httpTimeout} + latest, err := fetchLatestVersion(client) + if err != nil { + return nil + } + + writeCache(&cacheEntry{ + LatestVersion: latest, + CheckedAt: time.Now(), + }) + + if !compareVersions(currentVersion, latest) { + return nil + } + + return &UpdateInfo{ + CurrentVersion: currentVersion, + LatestVersion: latest, + IsNewer: true, + } +} + +// NotificationMessage returns a user-facing string about the available update. +func NotificationMessage(info *UpdateInfo) string { + return fmt.Sprintf( + "\nA new version of ldcli is available: %s → %s\nhttps://github.com/launchdarkly/ldcli/releases/latest\n", + info.CurrentVersion, + info.LatestVersion, + ) +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 000000000..69abaf70d --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,164 @@ +package update + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeVersion(t *testing.T) { + assert.Equal(t, "2.1.0", normalizeVersion("v2.1.0")) + assert.Equal(t, "2.1.0", normalizeVersion("2.1.0")) + assert.Equal(t, "", normalizeVersion("")) +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + current string + latest string + want bool + }{ + {"newer major", "1.0.0", "2.0.0", true}, + {"newer minor", "2.0.0", "2.1.0", true}, + {"newer patch", "2.1.0", "2.1.1", true}, + {"same version", "2.1.0", "2.1.0", false}, + {"older major", "3.0.0", "2.0.0", false}, + {"older minor", "2.2.0", "2.1.0", false}, + {"older patch", "2.1.2", "2.1.1", false}, + {"empty current", "", "2.1.0", true}, + {"empty latest", "2.1.0", "", false}, + {"both empty", "", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := compareVersions(tt.current, tt.latest) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFetchLatestVersion(t *testing.T) { + t.Run("parses tag_name from GitHub response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "application/vnd.github.v3+json", r.Header.Get("Accept")) + w.WriteHeader(http.StatusOK) + resp := githubRelease{TagName: "v3.0.0"} + data, _ := json.Marshal(resp) + _, _ = w.Write(data) + })) + defer server.Close() + + origURL := releasesURL + releasesURL = server.URL + defer func() { releasesURL = origURL }() + + version, err := fetchLatestVersion(server.Client()) + require.NoError(t, err) + assert.Equal(t, "3.0.0", version) + }) + + t.Run("handles non-200 status", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + origURL := releasesURL + releasesURL = server.URL + defer func() { releasesURL = origURL }() + + _, err := fetchLatestVersion(server.Client()) + assert.Error(t, err) + }) +} + +func TestReadWriteCache(t *testing.T) { + tmpDir := t.TempDir() + origFn := cacheFilePathFn + cacheFilePathFn = func() string { + return filepath.Join(tmpDir, cacheFilename) + } + defer func() { cacheFilePathFn = origFn }() + + _, err := readCache() + assert.Error(t, err, "cache should not exist yet") + + now := time.Now().Truncate(time.Second) + writeCache(&cacheEntry{ + LatestVersion: "3.0.0", + CheckedAt: now, + }) + + cached, err := readCache() + require.NoError(t, err) + assert.Equal(t, "3.0.0", cached.LatestVersion) + assert.Equal(t, now.Unix(), cached.CheckedAt.Unix()) +} + +func TestCheckForUpdate_DevVersion(t *testing.T) { + assert.Nil(t, CheckForUpdate("dev")) + assert.Nil(t, CheckForUpdate("test")) + assert.Nil(t, CheckForUpdate("")) +} + +func TestCheckForUpdate_CachedNewerVersion(t *testing.T) { + tmpDir := t.TempDir() + origFn := cacheFilePathFn + cacheFilePathFn = func() string { + return filepath.Join(tmpDir, cacheFilename) + } + defer func() { cacheFilePathFn = origFn }() + + entry := cacheEntry{ + LatestVersion: "5.0.0", + CheckedAt: time.Now(), + } + data, _ := json.Marshal(entry) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, cacheFilename), data, 0644)) + + info := CheckForUpdate("2.1.0") + require.NotNil(t, info) + assert.True(t, info.IsNewer) + assert.Equal(t, "2.1.0", info.CurrentVersion) + assert.Equal(t, "5.0.0", info.LatestVersion) +} + +func TestCheckForUpdate_CachedSameVersion(t *testing.T) { + tmpDir := t.TempDir() + origFn := cacheFilePathFn + cacheFilePathFn = func() string { + return filepath.Join(tmpDir, cacheFilename) + } + defer func() { cacheFilePathFn = origFn }() + + entry := cacheEntry{ + LatestVersion: "2.1.0", + CheckedAt: time.Now(), + } + data, _ := json.Marshal(entry) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, cacheFilename), data, 0644)) + + info := CheckForUpdate("2.1.0") + assert.Nil(t, info) +} + +func TestNotificationMessage(t *testing.T) { + info := &UpdateInfo{ + CurrentVersion: "2.1.0", + LatestVersion: "3.0.0", + IsNewer: true, + } + msg := NotificationMessage(info) + assert.Contains(t, msg, "2.1.0") + assert.Contains(t, msg, "3.0.0") + assert.Contains(t, msg, "https://github.com/launchdarkly/ldcli/releases/latest") +} From 90f093e32a880a03c94e07b339cde0f2ff378fe6 Mon Sep 17 00:00:00 2001 From: Cody Spath Date: Tue, 12 May 2026 11:33:15 -0400 Subject: [PATCH 2/6] fix bugbot issue --- internal/update/update.go | 1 + internal/update/update_test.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/update/update.go b/internal/update/update.go index 381711957..b61d3d538 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -135,6 +135,7 @@ func compareVersions(current, latest string) bool { // current version. It caches the result to avoid hitting the network on every // invocation. Returns nil when no update is available or on any error. func CheckForUpdate(currentVersion string) *UpdateInfo { + currentVersion = normalizeVersion(currentVersion) if currentVersion == "dev" || currentVersion == "test" || currentVersion == "" { return nil } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 69abaf70d..a611982f4 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -132,6 +132,25 @@ func TestCheckForUpdate_CachedNewerVersion(t *testing.T) { assert.Equal(t, "5.0.0", info.LatestVersion) } +func TestCheckForUpdate_VPrefixCurrentVersion(t *testing.T) { + tmpDir := t.TempDir() + origFn := cacheFilePathFn + cacheFilePathFn = func() string { + return filepath.Join(tmpDir, cacheFilename) + } + defer func() { cacheFilePathFn = origFn }() + + entry := cacheEntry{ + LatestVersion: "2.1.0", + CheckedAt: time.Now(), + } + data, _ := json.Marshal(entry) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, cacheFilename), data, 0644)) + + info := CheckForUpdate("v2.1.0") + assert.Nil(t, info, "v-prefixed currentVersion matching latest should not report an update") +} + func TestCheckForUpdate_CachedSameVersion(t *testing.T) { tmpDir := t.TempDir() origFn := cacheFilePathFn From 61932dcf0d74b0dac3e72327f743526ed374e26a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 25 Jun 2026 16:18:19 +0000 Subject: [PATCH 3/6] refactor: address PR feedback on update checker - Extract update check timeout to named constant - Add errorCacheTTL constant (1 hour) to cache failed update checks - Write error cache entry when GitHub API fails to avoid hammering the API during rate limiting or network issues Co-authored-by: Ramon Niebla --- cmd/root.go | 3 ++- internal/update/update.go | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 210261e36..8f93432a0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -365,13 +365,14 @@ See each command's help for details on how to use the generated script.`, rootCm analyticsClient.Wait() + const updateCheckTimeout = 500 * time.Millisecond if !skipUpdateCheck { select { case result := <-updateCh: if result.info != nil && result.info.IsNewer { fmt.Fprint(os.Stderr, update.NotificationMessage(result.info)) } - case <-time.After(500 * time.Millisecond): + case <-time.After(updateCheckTimeout): } } } diff --git a/internal/update/update.go b/internal/update/update.go index b61d3d538..545aa8d69 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -16,6 +16,7 @@ import ( const ( defaultGitHubReleasesURL = "https://api.github.com/repos/launchdarkly/ldcli/releases/latest" cacheTTL = 24 * time.Hour + errorCacheTTL = 1 * time.Hour cacheFilename = "update-check.json" httpTimeout = 3 * time.Second ) @@ -155,6 +156,10 @@ func CheckForUpdate(currentVersion string) *UpdateInfo { client := &http.Client{Timeout: httpTimeout} latest, err := fetchLatestVersion(client) if err != nil { + writeCache(&cacheEntry{ + LatestVersion: currentVersion, + CheckedAt: time.Now().Add(errorCacheTTL - cacheTTL), + }) return nil } From 43c3674f6907a022b60eeeedcb197ac695326fc5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 23:47:29 +0000 Subject: [PATCH 4/6] fix: persist update-check backoff before GitHub fetch Write the error-cache TTL before the HTTP call so a 500ms wait or os.Exit cannot drop the rate-limit protection. Preserve a previously known latest version so the next command can print the notice from cache instead of raising the post-command wait. Co-authored-by: Ramon Niebla --- cmd/root.go | 28 +++++--- internal/update/update.go | 57 +++++++++------- internal/update/update_test.go | 117 +++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 32 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index e9cb12fa4..ddf67cc49 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -379,6 +379,20 @@ See each command's help for details on how to use the generated script.`, rootCm err = rootCmd.Execute() + const updateCheckTimeout = 500 * time.Millisecond + waitForUpdateNotice := func() { + if skipUpdateCheck { + return + } + select { + case result := <-updateCh: + if result.info != nil && result.info.IsNewer { + fmt.Fprint(os.Stderr, update.NotificationMessage(result.info)) + } + case <-time.After(updateCheckTimeout): + } + } + var outcome string switch { case rootCmd.HelpCalled(): @@ -386,6 +400,9 @@ See each command's help for details on how to use the generated script.`, rootCm case err != nil: outcome = analytics.ERROR fmt.Fprintln(os.Stderr, err.Error()) + // Give the background check time to persist its backoff cache before + // the process dies. os.Exit would otherwise discard that write. + waitForUpdateNotice() os.Exit(1) default: outcome = analytics.SUCCESS @@ -408,16 +425,7 @@ See each command's help for details on how to use the generated script.`, rootCm analyticsClient.Wait() - const updateCheckTimeout = 500 * time.Millisecond - if !skipUpdateCheck { - select { - case result := <-updateCh: - if result.info != nil && result.info.IsNewer { - fmt.Fprint(os.Stderr, update.NotificationMessage(result.info)) - } - case <-time.After(updateCheckTimeout): - } - } + waitForUpdateNotice() } // setFlagsFromConfig reads in the config file if it exists and uses any flag values for commands. diff --git a/internal/update/update.go b/internal/update/update.go index 545aa8d69..f6f10cdb1 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -132,6 +132,33 @@ func compareVersions(current, latest string) bool { return false } +func updateInfoIfNewer(currentVersion, latestVersion string) *UpdateInfo { + if !compareVersions(currentVersion, latestVersion) { + return nil + } + return &UpdateInfo{ + CurrentVersion: currentVersion, + LatestVersion: latestVersion, + IsNewer: true, + } +} + +func lastKnownLatest(cached *cacheEntry, currentVersion string) string { + if cached != nil && cached.LatestVersion != "" { + return cached.LatestVersion + } + return currentVersion +} + +// persistCheckAttempt records a short-lived cache entry before a network fetch +// so a process exit cannot drop the backoff TTL that prevents hammering GitHub. +func persistCheckAttempt(currentVersion string, cached *cacheEntry) { + writeCache(&cacheEntry{ + LatestVersion: lastKnownLatest(cached, currentVersion), + CheckedAt: time.Now().Add(errorCacheTTL - cacheTTL), + }) +} + // CheckForUpdate checks GitHub for the latest release and compares it to the // current version. It caches the result to avoid hitting the network on every // invocation. Returns nil when no update is available or on any error. @@ -143,24 +170,18 @@ func CheckForUpdate(currentVersion string) *UpdateInfo { cached, err := readCache() if err == nil && time.Since(cached.CheckedAt) < cacheTTL { - if !compareVersions(currentVersion, cached.LatestVersion) { - return nil - } - return &UpdateInfo{ - CurrentVersion: currentVersion, - LatestVersion: cached.LatestVersion, - IsNewer: true, - } + return updateInfoIfNewer(currentVersion, cached.LatestVersion) } + // Write the backoff entry before the HTTP call. Fast commands and os.Exit + // often kill this goroutine before fetchLatestVersion returns, and without + // this write the next invocation would hit GitHub again immediately. + persistCheckAttempt(currentVersion, cached) + client := &http.Client{Timeout: httpTimeout} latest, err := fetchLatestVersion(client) if err != nil { - writeCache(&cacheEntry{ - LatestVersion: currentVersion, - CheckedAt: time.Now().Add(errorCacheTTL - cacheTTL), - }) - return nil + return updateInfoIfNewer(currentVersion, lastKnownLatest(cached, currentVersion)) } writeCache(&cacheEntry{ @@ -168,15 +189,7 @@ func CheckForUpdate(currentVersion string) *UpdateInfo { CheckedAt: time.Now(), }) - if !compareVersions(currentVersion, latest) { - return nil - } - - return &UpdateInfo{ - CurrentVersion: currentVersion, - LatestVersion: latest, - IsNewer: true, - } + return updateInfoIfNewer(currentVersion, latest) } // NotificationMessage returns a user-facing string about the available update. diff --git a/internal/update/update_test.go b/internal/update/update_test.go index a611982f4..a663158a3 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -181,3 +181,120 @@ func TestNotificationMessage(t *testing.T) { assert.Contains(t, msg, "3.0.0") assert.Contains(t, msg, "https://github.com/launchdarkly/ldcli/releases/latest") } + +func withCacheDir(t *testing.T) { + t.Helper() + tmpDir := t.TempDir() + origFn := cacheFilePathFn + cacheFilePathFn = func() string { + return filepath.Join(tmpDir, cacheFilename) + } + t.Cleanup(func() { cacheFilePathFn = origFn }) +} + +func withReleasesURL(t *testing.T, url string) { + t.Helper() + origURL := releasesURL + releasesURL = url + t.Cleanup(func() { releasesURL = origURL }) +} + +func TestCheckForUpdate_WritesBackoffCacheBeforeFetchCompletes(t *testing.T) { + withCacheDir(t) + + started := make(chan struct{}) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(started) + <-release + w.WriteHeader(http.StatusOK) + resp := githubRelease{TagName: "v9.0.0"} + data, _ := json.Marshal(resp) + _, _ = w.Write(data) + })) + defer server.Close() + withReleasesURL(t, server.URL) + + done := make(chan *UpdateInfo, 1) + go func() { + done <- CheckForUpdate("2.1.0") + }() + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("fetch never started") + } + + cached, err := readCache() + require.NoError(t, err, "backoff cache must be on disk before the GitHub call returns") + assert.Equal(t, "2.1.0", cached.LatestVersion) + remaining := cacheTTL - time.Since(cached.CheckedAt) + assert.InDelta(t, errorCacheTTL.Seconds(), remaining.Seconds(), 5) + + close(release) + info := <-done + require.NotNil(t, info) + assert.Equal(t, "9.0.0", info.LatestVersion) +} + +func TestCheckForUpdate_DoesNotRefetchDuringErrorBackoff(t *testing.T) { + withCacheDir(t) + + var hits int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits++ + w.WriteHeader(http.StatusTooManyRequests) + })) + defer server.Close() + withReleasesURL(t, server.URL) + + assert.Nil(t, CheckForUpdate("2.1.0")) + assert.Nil(t, CheckForUpdate("2.1.0")) + assert.Equal(t, 1, hits, "second call should use the backoff cache instead of hitting GitHub") +} + +func TestCheckForUpdate_PreservesKnownLatestOnFetchError(t *testing.T) { + withCacheDir(t) + + writeCache(&cacheEntry{ + LatestVersion: "5.0.0", + CheckedAt: time.Now().Add(-cacheTTL - time.Minute), + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + withReleasesURL(t, server.URL) + + info := CheckForUpdate("2.1.0") + require.NotNil(t, info) + assert.Equal(t, "5.0.0", info.LatestVersion) + + cached, err := readCache() + require.NoError(t, err) + assert.Equal(t, "5.0.0", cached.LatestVersion) +} + +func TestCheckForUpdate_FetchesAndCachesNewerVersion(t *testing.T) { + withCacheDir(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + resp := githubRelease{TagName: "v5.0.0"} + data, _ := json.Marshal(resp) + _, _ = w.Write(data) + })) + defer server.Close() + withReleasesURL(t, server.URL) + + info := CheckForUpdate("2.1.0") + require.NotNil(t, info) + assert.Equal(t, "5.0.0", info.LatestVersion) + + cached, err := readCache() + require.NoError(t, err) + assert.Equal(t, "5.0.0", cached.LatestVersion) + assert.Less(t, time.Since(cached.CheckedAt), time.Second) +} From 8a7ed68ca7539d576d6e88c1acfb1a9d8d6c494d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 00:23:10 +0000 Subject: [PATCH 5/6] feat(update): print from cache or wait 1s for a live check Show a cached newer version immediately after the command. If the cache is cold, wait up to 1s for this run's GitHub result. The pre-fetch backoff write still lands if we exit first, so the next command can print from cache without raising the exit budget. Co-authored-by: Ramon Niebla --- cmd/root.go | 11 ++++++- internal/update/update.go | 18 +++++++++++ internal/update/update_test.go | 56 ++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index ddf67cc49..09fcea8d1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -379,11 +379,20 @@ See each command's help for details on how to use the generated script.`, rootCm err = rootCmd.Execute() - const updateCheckTimeout = 500 * time.Millisecond + const updateCheckTimeout = time.Second waitForUpdateNotice := func() { if skipUpdateCheck { return } + // B: print immediately from cache when a previous run (or this + // run's pre-fetch write) already recorded a newer version. + if info := update.CachedUpdate(version); info != nil { + fmt.Fprint(os.Stderr, update.NotificationMessage(info)) + return + } + // A: otherwise wait up to 1s for this run's fetch. The backoff + // cache is written before the HTTP call, so exiting here still + // leaves a result for the next command. select { case result := <-updateCh: if result.info != nil && result.info.IsNewer { diff --git a/internal/update/update.go b/internal/update/update.go index f6f10cdb1..87883c813 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -192,6 +192,24 @@ func CheckForUpdate(currentVersion string) *UpdateInfo { return updateInfoIfNewer(currentVersion, latest) } +// CachedUpdate returns a notice from a still-valid local cache without +// touching the network. A previous run (or this run's pre-fetch write) +// may already know a newer version; the next command can print that even +// if this run exited before GitHub answered. +func CachedUpdate(currentVersion string) *UpdateInfo { + currentVersion = normalizeVersion(currentVersion) + if currentVersion == "dev" || currentVersion == "test" || currentVersion == "" { + return nil + } + + cached, err := readCache() + if err != nil || time.Since(cached.CheckedAt) >= cacheTTL { + return nil + } + + return updateInfoIfNewer(currentVersion, cached.LatestVersion) +} + // NotificationMessage returns a user-facing string about the available update. func NotificationMessage(info *UpdateInfo) string { return fmt.Sprintf( diff --git a/internal/update/update_test.go b/internal/update/update_test.go index a663158a3..851be7e79 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -298,3 +298,59 @@ func TestCheckForUpdate_FetchesAndCachesNewerVersion(t *testing.T) { assert.Equal(t, "5.0.0", cached.LatestVersion) assert.Less(t, time.Since(cached.CheckedAt), time.Second) } + +func TestCachedUpdate(t *testing.T) { + t.Run("no cache", func(t *testing.T) { + withCacheDir(t) + assert.Nil(t, CachedUpdate("2.1.0")) + }) + + t.Run("newer version in cache", func(t *testing.T) { + withCacheDir(t) + writeCache(&cacheEntry{ + LatestVersion: "5.0.0", + CheckedAt: time.Now(), + }) + + info := CachedUpdate("2.1.0") + require.NotNil(t, info) + assert.Equal(t, "2.1.0", info.CurrentVersion) + assert.Equal(t, "5.0.0", info.LatestVersion) + }) + + t.Run("same version in cache", func(t *testing.T) { + withCacheDir(t) + writeCache(&cacheEntry{ + LatestVersion: "2.1.0", + CheckedAt: time.Now(), + }) + assert.Nil(t, CachedUpdate("2.1.0")) + }) + + t.Run("expired cache", func(t *testing.T) { + withCacheDir(t) + writeCache(&cacheEntry{ + LatestVersion: "5.0.0", + CheckedAt: time.Now().Add(-cacheTTL - time.Minute), + }) + assert.Nil(t, CachedUpdate("2.1.0")) + }) + + t.Run("pre-fetch backoff still yields a notice", func(t *testing.T) { + withCacheDir(t) + persistCheckAttempt("2.1.0", &cacheEntry{LatestVersion: "5.0.0"}) + + info := CachedUpdate("2.1.0") + require.NotNil(t, info) + assert.Equal(t, "5.0.0", info.LatestVersion) + }) + + t.Run("skips dev versions", func(t *testing.T) { + withCacheDir(t) + writeCache(&cacheEntry{ + LatestVersion: "5.0.0", + CheckedAt: time.Now(), + }) + assert.Nil(t, CachedUpdate("dev")) + }) +} From dda8d1910107ffe0714525706206c6c212916325 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 00:27:33 +0000 Subject: [PATCH 6/6] style: rewrite update-check comments in plain language Drop the A/B labels and design-doc wording so the comments match the rest of the file. Co-authored-by: Ramon Niebla --- cmd/root.go | 12 +++++------- internal/update/update.go | 16 +++++++--------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 09fcea8d1..0cc6357e1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -384,15 +384,13 @@ See each command's help for details on how to use the generated script.`, rootCm if skipUpdateCheck { return } - // B: print immediately from cache when a previous run (or this - // run's pre-fetch write) already recorded a newer version. + // Already know there's a newer version? Just say so. if info := update.CachedUpdate(version); info != nil { fmt.Fprint(os.Stderr, update.NotificationMessage(info)) return } - // A: otherwise wait up to 1s for this run's fetch. The backoff - // cache is written before the HTTP call, so exiting here still - // leaves a result for the next command. + // Otherwise give this check a second. The cache is already on + // disk, so the next command can still show it if we time out. select { case result := <-updateCh: if result.info != nil && result.info.IsNewer { @@ -409,8 +407,8 @@ See each command's help for details on how to use the generated script.`, rootCm case err != nil: outcome = analytics.ERROR fmt.Fprintln(os.Stderr, err.Error()) - // Give the background check time to persist its backoff cache before - // the process dies. os.Exit would otherwise discard that write. + // Give the background check a moment to write the cache. os.Exit + // would otherwise kill it immediately. waitForUpdateNotice() os.Exit(1) default: diff --git a/internal/update/update.go b/internal/update/update.go index 87883c813..fb288a422 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -150,8 +150,9 @@ func lastKnownLatest(cached *cacheEntry, currentVersion string) string { return currentVersion } -// persistCheckAttempt records a short-lived cache entry before a network fetch -// so a process exit cannot drop the backoff TTL that prevents hammering GitHub. +// persistCheckAttempt notes that we tried, before we talk to GitHub. If the +// CLI exits while the request is still running, the next command waits an +// hour instead of immediately hitting the API again. func persistCheckAttempt(currentVersion string, cached *cacheEntry) { writeCache(&cacheEntry{ LatestVersion: lastKnownLatest(cached, currentVersion), @@ -173,9 +174,8 @@ func CheckForUpdate(currentVersion string) *UpdateInfo { return updateInfoIfNewer(currentVersion, cached.LatestVersion) } - // Write the backoff entry before the HTTP call. Fast commands and os.Exit - // often kill this goroutine before fetchLatestVersion returns, and without - // this write the next invocation would hit GitHub again immediately. + // Write the cache first. Fast commands often exit before GitHub answers, + // and we don't want those to retry on every run. persistCheckAttempt(currentVersion, cached) client := &http.Client{Timeout: httpTimeout} @@ -192,10 +192,8 @@ func CheckForUpdate(currentVersion string) *UpdateInfo { return updateInfoIfNewer(currentVersion, latest) } -// CachedUpdate returns a notice from a still-valid local cache without -// touching the network. A previous run (or this run's pre-fetch write) -// may already know a newer version; the next command can print that even -// if this run exited before GitHub answered. +// CachedUpdate looks at the local cache only. If we already know there's a +// newer version, we can tell the user without waiting on GitHub. func CachedUpdate(currentVersion string) *UpdateInfo { currentVersion = normalizeVersion(currentVersion) if currentVersion == "dev" || currentVersion == "test" || currentVersion == "" {