From 9217b8bb108abc355befa88610ee55d0f3dbb920 Mon Sep 17 00:00:00 2001 From: Cody Spath Date: Mon, 11 May 2026 15:09:30 -0400 Subject: [PATCH 1/2] 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 c644282d..763739b4 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 b94c7cea..a485fb9b 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 d498e07e..210261e3 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 b6f72004..42156053 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 00000000..38171195 --- /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 00000000..69abaf70 --- /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/2] 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 38171195..b61d3d53 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 69abaf70..a611982f 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