Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
70 changes: 65 additions & 5 deletions cmd/flags/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package flags
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
Expand All @@ -22,7 +24,6 @@ const (
usageFormatFlag = "format"
usageWrapperModulesFlag = "wrapper-modules"
usageDefinitionsFlag = "definitions"
usageEnvsFlag = "envs"
usageEvalWindowFlag = "eval-window"
usageEvalSeriesFlag = "eval-series"
usageExposuresFlag = "exposures"
Expand Down Expand Up @@ -52,7 +53,8 @@ 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().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,7 +72,6 @@ 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)
evalWindowRaw, _ := cmd.Flags().GetString(usageEvalWindowFlag)
evalSeries, _ := cmd.Flags().GetBool(usageEvalSeriesFlag)
exposures, _ := cmd.Flags().GetBool(usageExposuresFlag)
Expand All @@ -82,6 +83,19 @@ func runUsage(cmd *cobra.Command, args []string) error {
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 +128,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 +148,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
Loading