diff --git a/cmd/cliflags/flags.go b/cmd/cliflags/flags.go index c644282d..fc52040d 100644 --- a/cmd/cliflags/flags.go +++ b/cmd/cliflags/flags.go @@ -37,6 +37,7 @@ const ( DevStreamURIFlag = "dev-stream-uri" EmailsFlag = "emails" EnvironmentFlag = "environment" + EnvsFlag = "envs" FieldsFlag = "fields" FlagFlag = "flag" JSONFlag = "json" @@ -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)" @@ -72,6 +74,7 @@ func AllFlagsHelp() map[string]string { CorsOriginFlag: CorsOriginFlagDescription, DevStreamURIFlag: DevStreamURIDescription, EnvironmentFlag: EnvironmentFlagDescription, + EnvsFlag: EnvsFlagDescription, FlagFlag: FlagFlagDescription, OutputFlag: OutputFlagDescription, PortFlag: PortFlagDescription, diff --git a/cmd/config/testdata/help.golden b/cmd/config/testdata/help.golden index b94c7cea..e288305e 100644 --- a/cmd/config/testdata/help.golden +++ b/cmd/config/testdata/help.golden @@ -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 diff --git a/cmd/flags/usage.go b/cmd/flags/usage.go index b206969e..77ab966c 100644 --- a/cmd/flags/usage.go +++ b/cmd/flags/usage.go @@ -3,7 +3,10 @@ package flags import ( "encoding/json" "fmt" + "net/http" + "net/url" "os" + "path/filepath" "strings" "time" @@ -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" ) @@ -22,7 +26,7 @@ const ( usageFormatFlag = "format" usageWrapperModulesFlag = "wrapper-modules" usageDefinitionsFlag = "definitions" - usageEnvsFlag = "envs" + usageSaveFlag = "save" usageEvalWindowFlag = "eval-window" usageEvalSeriesFlag = "eval-series" usageExposuresFlag = "exposures" @@ -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") @@ -70,7 +76,7 @@ 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) @@ -78,10 +84,54 @@ func runUsage(cmd *cobra.Command, args []string) error { 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 + } + var evalWindow time.Duration if evalWindowRaw != "" { d, err := time.ParseDuration(evalWindowRaw) @@ -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, @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index b6f72004..ac1bedf6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "github.com/mitchellh/go-homedir" "gopkg.in/yaml.v3" @@ -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) { @@ -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: @@ -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) { diff --git a/internal/flagusage/repoconfig/repoconfig.go b/internal/flagusage/repoconfig/repoconfig.go new file mode 100644 index 00000000..40592988 --- /dev/null +++ b/internal/flagusage/repoconfig/repoconfig.go @@ -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) +} diff --git a/internal/flagusage/repoconfig/repoconfig_test.go b/internal/flagusage/repoconfig/repoconfig_test.go new file mode 100644 index 00000000..22d4694b --- /dev/null +++ b/internal/flagusage/repoconfig/repoconfig_test.go @@ -0,0 +1,64 @@ +package repoconfig + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFindRepoRoot(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + got, ok := FindRepoRoot(nested) + if !ok { + t.Fatal("expected to find repo root") + } + if got != root { + t.Errorf("got %q, want %q", got, root) + } +} + +func TestFindRepoRootNotFound(t *testing.T) { + // A tmp dir with no .git anywhere above it up to the tmp root won't find one, + // unless the OS tmp dir itself happens to be inside a repo — use a dir we + // control and stop the walk by asserting it terminates rather than a specific + // root, since we can't control what's above t.TempDir() on CI. + dir := t.TempDir() + _, _ = FindRepoRoot(dir) // just must not hang or panic +} + +func TestSaveAndLoad(t *testing.T) { + root := t.TempDir() + cfg := Config{WrapperModules: "@gonfalon/dogfood-flags", Definitions: "./packages/dogfood-flags/src"} + + if err := Save(root, cfg); err != nil { + t.Fatal(err) + } + + got, err := Load(root) + if err != nil { + t.Fatal(err) + } + if got != cfg { + t.Errorf("got %+v, want %+v", got, cfg) + } +} + +func TestLoadMissingFileIsNotError(t *testing.T) { + root := t.TempDir() + + got, err := Load(root) + if err != nil { + t.Fatalf("expected no error for missing file, got %v", err) + } + if got != (Config{}) { + t.Errorf("expected zero-value Config, got %+v", got) + } +}