Skip to content
Draft
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
3 changes: 3 additions & 0 deletions cmd/cliflags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const (
DevStreamURIFlag = "dev-stream-uri"
EmailsFlag = "emails"
EnvironmentFlag = "environment"
EnvsFlag = "envs"
FieldsFlag = "fields"
FlagFlag = "flag"
JSONFlag = "json"
Expand All @@ -54,6 +55,7 @@ const (
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"
EnvsFlagDescription = "Default comma-separated environment keys to check flag status across (default: auto-discover the project's critical environments)"
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)"
Expand All @@ -72,6 +74,7 @@ func AllFlagsHelp() map[string]string {
CorsOriginFlag: CorsOriginFlagDescription,
DevStreamURIFlag: DevStreamURIDescription,
EnvironmentFlag: EnvironmentFlagDescription,
EnvsFlag: EnvsFlagDescription,
FlagFlag: FlagFlagDescription,
OutputFlag: OutputFlagDescription,
PortFlag: PortFlagDescription,
Expand Down
1 change: 1 addition & 0 deletions cmd/config/testdata/help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Supported settings:
- `cors-origin`: Allowed CORS origin. Use '*' for all origins (default: '*')
- `dev-stream-uri`: Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint
- `environment`: Default environment key
- `envs`: Default comma-separated environment keys to check flag status across (default: auto-discover the project's critical environments)
- `flag`: Default feature flag key
- `output`: Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise)
- `port`: Port for the dev server to run on
Expand Down
106 changes: 101 additions & 5 deletions cmd/flags/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package flags
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -14,6 +17,7 @@ import (
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
"github.com/launchdarkly/ldcli/internal/flagusage/enrich"
"github.com/launchdarkly/ldcli/internal/flagusage/render"
"github.com/launchdarkly/ldcli/internal/flagusage/repoconfig"
"github.com/launchdarkly/ldcli/internal/flagusage/scanner"
)

Expand All @@ -22,7 +26,7 @@ const (
usageFormatFlag = "format"
usageWrapperModulesFlag = "wrapper-modules"
usageDefinitionsFlag = "definitions"
usageEnvsFlag = "envs"
usageSaveFlag = "save"
usageEvalWindowFlag = "eval-window"
usageEvalSeriesFlag = "eval-series"
usageExposuresFlag = "exposures"
Expand Down Expand Up @@ -52,7 +56,9 @@ func initUsageFlags(cmd *cobra.Command) {
cmd.Flags().String(usageFormatFlag, "text", "Output format: text, json")
cmd.Flags().String(usageWrapperModulesFlag, "", "OVERRIDE (rarely needed): comma-separated wrapper module paths to force-track; modules are auto-discovered via node_modules")
cmd.Flags().String(usageDefinitionsFlag, "", "OVERRIDE (rarely needed): dir of wrapper definition files; definitions are auto-discovered via node_modules — pass this only if deps aren't installed")
cmd.Flags().String(usageEnvsFlag, "", "Comma-separated environment keys to check (default: all)")
cmd.Flags().Bool(usageSaveFlag, false, fmt.Sprintf("Persist --%s/--%s for this repo to %s at the repo root (found by walking up from --dir to the nearest .git); the file is only created when --%s is passed", usageWrapperModulesFlag, usageDefinitionsFlag, repoconfig.Filename, usageSaveFlag))
cmd.Flags().StringSlice(cliflags.EnvsFlag, nil, cliflags.EnvsFlagDescription)
_ = viper.BindPFlag(cliflags.EnvsFlag, cmd.Flags().Lookup(cliflags.EnvsFlag))
cmd.Flags().String(usageEvalWindowFlag, "", "Evaluation lookback window (e.g. 24h, 6h); default 168h (7d)")
cmd.Flags().Bool(usageEvalSeriesFlag, false, "Fetch the per-bucket evaluation timeseries (daily, or hourly for windows <24h); default fetches window totals only via a single batched call per flag")
cmd.Flags().Bool(usageExposuresFlag, false, "Fetch true unique-context counts per context kind (the figure a guarded release gates on), not just total evaluations")
Expand All @@ -70,18 +76,62 @@ func runUsage(cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString(usageFormatFlag)
wrapperModules, _ := cmd.Flags().GetString(usageWrapperModulesFlag)
definitionsDir, _ := cmd.Flags().GetString(usageDefinitionsFlag)
envsRaw, _ := cmd.Flags().GetString(usageEnvsFlag)
save, _ := cmd.Flags().GetBool(usageSaveFlag)
evalWindowRaw, _ := cmd.Flags().GetString(usageEvalWindowFlag)
evalSeries, _ := cmd.Flags().GetBool(usageEvalSeriesFlag)
exposures, _ := cmd.Flags().GetBool(usageExposuresFlag)
contextKindsRaw, _ := cmd.Flags().GetString(usageContextKindsFlag)
flagFilter, _ := cmd.Flags().GetString(cliflags.FlagFlag)
width, _ := cmd.Flags().GetInt(usageWidthFlag)

// definitions/wrapper-modules are per-repo settings (a source path and a
// module name specific to this codebase), not per-user ones, so they're
// pinned via a repo-local file rather than ldcli's global config — an
// explicit CLI flag always wins over what's saved there.
repoRoot, foundRepoRoot := repoconfig.FindRepoRoot(dir)
if foundRepoRoot {
local, err := repoconfig.Load(repoRoot)
if err != nil {
return fmt.Errorf("reading %s: %w", repoconfig.Filename, err)
}
if !cmd.Flags().Changed(usageWrapperModulesFlag) && local.WrapperModules != "" {
wrapperModules = local.WrapperModules
}
if !cmd.Flags().Changed(usageDefinitionsFlag) && local.Definitions != "" {
definitionsDir = local.Definitions
}
}

if save {
if !foundRepoRoot {
return fmt.Errorf("--%s requires --%s to be inside a git repository (no .git found above %s)", usageSaveFlag, usageDirFlag, dir)
}
if err := repoconfig.Save(repoRoot, repoconfig.Config{
WrapperModules: wrapperModules,
Definitions: definitionsDir,
}); err != nil {
return fmt.Errorf("saving %s: %w", repoconfig.Filename, err)
}
fmt.Fprintf(cmd.ErrOrStderr(), "Saved --%s/--%s to %s\n", usageWrapperModulesFlag, usageDefinitionsFlag, filepath.Join(repoRoot, repoconfig.Filename))
}

project := viper.GetString(cliflags.ProjectFlag)
token := viper.GetString(cliflags.AccessTokenFlag)
baseURL := viper.GetString(cliflags.BaseURIFlag)

// --envs, then LD_ENVS, then the ldcli config file (all via viper); if none of
// those set anything, fall back to live-discovering the project's critical
// environments — the same signal `flagpls setup` persists, just resolved lazily
// instead of requiring an explicit setup step.
envs := viper.GetStringSlice(cliflags.EnvsFlag)
if len(envs) == 0 {
discovered, err := fetchCriticalEnvs(token, baseURL, project)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: couldn't auto-discover critical environments (%v); pass --envs explicitly\n", err)
}
envs = discovered
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Silent default env resolution change

Medium Severity

Omitting --envs now live-discovers critical environments via the API instead of relying on the prior implicit DefaultCriticalEnvs behavior, changing which environments appear in flags usage output. The command only prints a stderr notice when discovery fails, not when the default resolution path changes on success.

Fix in Cursor Fix in Web

Triggered by learned rule: Breaking CLI default changes require transitional stderr warnings

Reviewed by Cursor Bugbot for commit 82aa923. Configure here.


var evalWindow time.Duration
if evalWindowRaw != "" {
d, err := time.ParseDuration(evalWindowRaw)
Expand Down Expand Up @@ -114,7 +164,7 @@ func runUsage(cmd *cobra.Command, args []string) error {
client := enrich.NewClient(token, baseURL)
details, err := enrich.Enrich(client, scanResult, enrich.Options{
ProjectKey: project,
Environments: splitNonEmpty(envsRaw),
Environments: envs,
EvalWindow: evalWindow,
Series: evalSeries,
Exposures: exposures,
Expand All @@ -134,11 +184,57 @@ func runUsage(cmd *cobra.Command, args []string) error {
if windowLabel == "" {
windowLabel = "7d"
}
render.Enriched(os.Stdout, details, windowLabel, splitNonEmpty(envsRaw), width)
render.Enriched(os.Stdout, details, windowLabel, envs, width)

return nil
}

// fetchCriticalEnvs lists the project's environments and returns the keys marked
// `critical` in LaunchDarkly (the env-level "Critical environment" toggle). It hits
// the REST API directly rather than the generated api-client-go SDK because ldcli's
// current SDK version (v14) doesn't expose the Critical field on its Environment
// model (added in a later API/SDK version).
func fetchCriticalEnvs(token, baseURL, projectKey string) ([]string, error) {
u, err := url.JoinPath(baseURL, "api/v2/projects", projectKey, "environments")
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, u+"?limit=200", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", token)

client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("listing environments failed (HTTP %d)", resp.StatusCode)
}

var body struct {
Items []struct {
Key string `json:"key"`
Critical bool `json:"critical"`
} `json:"items"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, err
}

var critical []string
for _, e := range body.Items {
if e.Critical {
critical = append(critical, e.Key)
}
}
return critical, nil
}

func splitNonEmpty(s string) []string {
if s == "" {
return nil
Expand Down
35 changes: 27 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"

"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v3"
Expand All @@ -20,14 +21,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"`
Envs []string `json:"envs,omitempty" yaml:"envs,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"`
}

func New(filename string, readFile ReadFile) (Config, error) {
Expand Down Expand Up @@ -85,6 +87,8 @@ func (c Config) Update(kvs []string) (Config, []string, error) {
c.DevStreamURI = v
case cliflags.EnvironmentFlag:
c.Environment = v
case cliflags.EnvsFlag:
c.Envs = splitCSV(v)
case cliflags.FlagFlag:
c.Flag = v
case cliflags.OutputFlag:
Expand All @@ -102,6 +106,21 @@ func (c Config) Update(kvs []string) (Config, []string, error) {
return c, updatedFields, nil
}

// splitCSV splits a comma-separated flag value into a trimmed, non-empty slice.
func splitCSV(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}

// Remove validates the key exists but doesn't do anything else since we unset the value when
// writing to disk.
func (c Config) Remove(key string) (Config, error) {
Expand Down
70 changes: 70 additions & 0 deletions internal/flagusage/repoconfig/repoconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Package repoconfig persists `flags usage` settings that are inherently
// per-repository (which wrapper module/definitions dir a monorepo uses) rather
// than per-user, so they don't belong in ldcli's global ~/.config/ldcli/config.yml.
// Unlike the global config, this file lives at the scanned repo's root and is
// safe to check into version control so a team shares the same settings.
package repoconfig

import (
"os"
"path/filepath"

"gopkg.in/yaml.v3"
)

// Filename is the repo-local config file, written at the repo root.
const Filename = ".ldcli-flags-usage.yml"

// Config is the subset of `flags usage` flags that make sense to pin per-repo.
type Config struct {
WrapperModules string `yaml:"wrapper-modules,omitempty"`
Definitions string `yaml:"definitions,omitempty"`
}

// FindRepoRoot walks up from dir looking for a `.git` entry (directory or file,
// the latter for git worktrees) and returns its parent. ok is false if none is
// found before reaching the filesystem root.
func FindRepoRoot(dir string) (root string, ok bool) {
dir, err := filepath.Abs(dir)
if err != nil {
return "", false
}
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir, true
}
parent := filepath.Dir(dir)
if parent == dir {
return "", false
}
dir = parent
}
}

// Load reads the repo-local config at repoRoot. A missing file is not an
// error — it returns a zero-value Config, since most repos won't have one.
func Load(repoRoot string) (Config, error) {
data, err := os.ReadFile(filepath.Join(repoRoot, Filename))
if err != nil {
if os.IsNotExist(err) {
return Config{}, nil
}
return Config{}, err
}

var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return Config{}, err
}
return c, nil
}

// Save writes cfg to repoRoot, creating the file only when called — `flags
// usage` never writes it implicitly, only when the caller passes --save.
func Save(repoRoot string, cfg Config) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(repoRoot, Filename), data, 0o644)
}
Loading
Loading