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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 48 additions & 45 deletions cmd/cliflags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
1 change: 1 addition & 0 deletions cmd/config/testdata/help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
25 changes: 25 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
"time"

"github.com/google/uuid"
"github.com/spf13/cobra"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Can we move this timeout into a constant

}
}
}

// setFlagsFromConfig reads in the config file if it exists and uses any flag values for commands.
Expand Down
24 changes: 16 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
}
}
Expand Down
184 changes: 184 additions & 0 deletions internal/update/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
cacheTTL = 24 * time.Hour
cacheTTL = 24 * time.Hour
errorCacheTTL = 1 * 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 {
currentVersion = normalizeVersion(currentVersion)
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(),
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm thinking we need to update this to make sure we don't hammer the GitHUb API if it's down or rate limiting us:

Suggested change
})
client := &http.Client{Timeout: httpTimeout}
latest, err := fetchLatestVersion(client)
if err != nil {
writeCache(&cacheEntry{
LatestVersion: currentVersion,
CheckedAt: time.Now().Add(errorCacheTTL - cacheTTL), // -23h, expires in 1h
})
return nil
}
writeCache(&cacheEntry{
LatestVersion: latest,
CheckedAt: time.Now(),
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The suggested change box got funky but you should get the idea


if !compareVersions(currentVersion, latest) {
return nil
}

return &UpdateInfo{
CurrentVersion: currentVersion,
LatestVersion: latest,
IsNewer: true,
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

// 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,
)
}
Loading
Loading