diff --git a/.gitignore b/.gitignore index 619182c..322cebf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.so *.dylib cli +bin/ # Test binary, built with `go test -c` *.test diff --git a/docs/commands/openfeature_manifest_add.md b/docs/commands/openfeature_manifest_add.md index ffc04c0..c89cf40 100644 --- a/docs/commands/openfeature_manifest_add.md +++ b/docs/commands/openfeature_manifest_add.md @@ -8,7 +8,22 @@ Add a new flag to the manifest Add a new flag to the manifest file with the specified configuration. +Interactive Mode: + When the flag key or other values are omitted, the command prompts interactively for missing values: + - Flag key (if not provided as argument) + - Flag type (defaults to boolean if not specified) + - Default value (required) + - Description (optional, press Enter to skip) + + Use --no-input to disable interactive prompts (required for CI/automation). + Examples: + # Interactive mode - prompts for key, type, value, and description + openfeature manifest add + + # Interactive mode with key - prompts for type, value, and description + openfeature manifest add new-feature + # Add a boolean flag (default type) openfeature manifest add new-feature --default-value false @@ -23,9 +38,12 @@ Examples: # Add an object flag openfeature manifest add config --type object --default-value '{"key":"value"}' + + # Disable interactive prompts (for automation) + openfeature manifest add my-flag --default-value true --no-input ``` -openfeature manifest add [flag-name] [flags] +openfeature manifest add [flag-key] [flags] ``` ### Options diff --git a/go.mod b/go.mod index 51fb252..2b676e4 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.11.1 github.com/xeipuuv/gojsonschema v1.2.0 + golang.org/x/term v0.35.0 golang.org/x/text v0.29.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -82,7 +83,6 @@ require ( golang.org/x/net v0.44.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.35.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/grpc v1.75.1 // indirect diff --git a/internal/cmd/manifest_add.go b/internal/cmd/manifest_add.go index 281a553..469da6d 100644 --- a/internal/cmd/manifest_add.go +++ b/internal/cmd/manifest_add.go @@ -19,11 +19,26 @@ import ( func GetManifestAddCmd() *cobra.Command { manifestAddCmd := &cobra.Command{ - Use: "add [flag-name]", + Use: "add [flag-key]", Short: "Add a new flag to the manifest", Long: `Add a new flag to the manifest file with the specified configuration. +Interactive Mode: + When the flag key or other values are omitted, the command prompts interactively for missing values: + - Flag key (if not provided as argument) + - Flag type (defaults to boolean if not specified) + - Default value (required) + - Description (optional, press Enter to skip) + + Use --no-input to disable interactive prompts (required for CI/automation). + Examples: + # Interactive mode - prompts for key, type, value, and description + openfeature manifest add + + # Interactive mode with key - prompts for type, value, and description + openfeature manifest add new-feature + # Add a boolean flag (default type) openfeature manifest add new-feature --default-value false @@ -37,23 +52,55 @@ Examples: openfeature manifest add discount-rate --type float --default-value 0.15 # Add an object flag - openfeature manifest add config --type object --default-value '{"key":"value"}'`, - Args: cobra.ExactArgs(1), + openfeature manifest add config --type object --default-value '{"key":"value"}' + + # Disable interactive prompts (for automation) + openfeature manifest add my-flag --default-value true --no-input`, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + return fmt.Errorf("too many arguments: expected 0 or 1 flag-key, got %d\n\nUsage: %s", len(args), cmd.Use) + } + return nil + }, PreRunE: func(cmd *cobra.Command, args []string) error { return initializeConfig(cmd, "manifest.add") }, RunE: func(cmd *cobra.Command, args []string) error { - flagName := args[0] manifestPath := config.GetManifestPath(cmd) + noInput := config.ShouldDisableInteractivePrompts(cmd) + + // Handle flag key: use argument if provided, otherwise prompt if interactive mode + var flagName string + if len(args) > 0 { + flagName = args[0] + } else { + if noInput { + return errors.New("flag-key argument is required when --no-input is set") + } + // Prompt for flag key + promptText := "Enter flag key (e.g., 'my-feature', 'enable-new-ui')" + keyInput, err := pterm.DefaultInteractiveTextInput.WithDefaultText("").Show(promptText) + if err != nil { + return fmt.Errorf("failed to prompt for flag key: %w", err) + } + flagName = strings.TrimSpace(keyInput) + if flagName == "" { + return errors.New("flag key cannot be empty") + } + } // Get flag configuration from command flags flagType, _ := cmd.Flags().GetString("type") defaultValueStr, _ := cmd.Flags().GetString("default-value") description, _ := cmd.Flags().GetString("description") - // Validate that default-value is provided - if !cmd.Flags().Changed("default-value") { - return errors.New("--default-value is required") + // Handle flag type: prompt if not changed and not --no-input + if !cmd.Flags().Changed("type") && !noInput { + selectedType, err := promptForFlagType(flagName) + if err != nil { + return fmt.Errorf("failed to get flag type: %w", err) + } + flagType = selectedType } // Parse flag type @@ -62,10 +109,36 @@ Examples: return fmt.Errorf("invalid flag type: %w", err) } - // Parse and validate default value - defaultValue, err := parseDefaultValue(defaultValueStr, parsedType) - if err != nil { - return fmt.Errorf("invalid default value for type %s: %w", flagType, err) + // Handle default-value: prompt if missing and not --no-input + var defaultValue interface{} + if !cmd.Flags().Changed("default-value") { + if noInput { + return errors.New("--default-value is required") + } + // Prompt for default value + defaultValue, err = promptForDefaultValue(&flagset.Flag{ + Key: flagName, + Type: parsedType, + }) + if err != nil { + return fmt.Errorf("failed to get default value: %w", err) + } + } else { + // Parse and validate default value from flag + defaultValue, err = parseDefaultValue(defaultValueStr, parsedType) + if err != nil { + return fmt.Errorf("invalid default value for type %s: %w", flagType, err) + } + } + + // Handle description: prompt if missing and not --no-input + if !cmd.Flags().Changed("description") && !noInput { + promptText := fmt.Sprintf("Enter description for flag '%s' (press Enter to skip)", flagName) + descInput, err := pterm.DefaultInteractiveTextInput.WithDefaultText("").Show(promptText) + if err != nil { + return fmt.Errorf("failed to prompt for description: %w", err) + } + description = descInput } // Load existing manifest @@ -114,11 +187,6 @@ Examples: logger.Default.Debug(fmt.Sprintf("Added flag: name=%s, type=%s, defaultValue=%v, description=%s", flagName, flagType, defaultValue, description)) - // Display all current flags - displayFlagList(fs, manifestPath) - pterm.Println("Use the 'generate' command to update type-safe clients with the new flag.") - pterm.Println() - return nil }, } @@ -184,3 +252,21 @@ func parseDefaultValue(value string, flagType flagset.FlagType) (interface{}, er return nil, fmt.Errorf("unsupported flag type: %v", flagType) } } + +// promptForFlagType prompts the user to select a flag type +func promptForFlagType(flagName string) (string, error) { + prompt := fmt.Sprintf("Select type for flag '%s'", flagName) + options := []string{"boolean", "string", "integer", "float", "object"} + + selectedType, err := pterm.DefaultInteractiveSelect. + WithOptions(options). + WithDefaultOption("boolean"). + WithFilter(false). + Show(prompt) + + if err != nil { + return "", fmt.Errorf("failed to prompt for flag type: %w", err) + } + + return selectedType, nil +} diff --git a/internal/cmd/manifest_add_test.go b/internal/cmd/manifest_add_test.go index 498fd4b..3140120 100644 --- a/internal/cmd/manifest_add_test.go +++ b/internal/cmd/manifest_add_test.go @@ -15,11 +15,11 @@ import ( func TestManifestAddCmd(t *testing.T) { tests := []struct { - name string - args []string + name string + args []string existingManifest string - expectedError string - validateResult func(t *testing.T, fs afero.Fs) + expectedError string + validateResult func(t *testing.T, fs afero.Fs) }{ { name: "add boolean flag to empty manifest", @@ -176,6 +176,7 @@ func TestManifestAddCmd(t *testing.T) { name: "error on missing default value", args: []string{ "add", "new-flag", + "--no-input", }, existingManifest: `{ "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", @@ -364,7 +365,7 @@ func TestManifestAddCmd_CreateNewManifest(t *testing.T) { assert.Equal(t, "The first flag in a new manifest", flag["description"]) } -func TestManifestAddCmd_DisplaysListAfterAdd(t *testing.T) { +func TestManifestAddCmd_DisplaysSuccessMessage(t *testing.T) { // Setup fs := afero.NewMemMapFs() filesystem.SetFileSystem(fs) @@ -388,18 +389,9 @@ func TestManifestAddCmd_DisplaysListAfterAdd(t *testing.T) { defer pterm.DisableOutput() buf := &bytes.Buffer{} - oldStdout := pterm.DefaultTable.Writer - oldSection := pterm.DefaultSection.Writer - oldInfo := pterm.Info.Writer oldSuccess := pterm.Success.Writer - pterm.DefaultTable.Writer = buf - pterm.DefaultSection.Writer = buf - pterm.Info.Writer = buf pterm.Success.Writer = buf defer func() { - pterm.DefaultTable.Writer = oldStdout - pterm.DefaultSection.Writer = oldSection - pterm.Info.Writer = oldInfo pterm.Success.Writer = oldSuccess }() @@ -418,12 +410,200 @@ func TestManifestAddCmd_DisplaysListAfterAdd(t *testing.T) { err = cmd.Execute() require.NoError(t, err) - // Validate output contains list of all flags + // Validate the flag was actually added to the manifest + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + flags := manifest["flags"].(map[string]interface{}) + assert.Len(t, flags, 2, "Should have 2 flags total") + assert.Contains(t, flags, "existing-flag", "Should still contain existing flag") + assert.Contains(t, flags, "new-flag", "Should contain newly added flag") + + // Validate success message is displayed output := buf.String() - assert.Contains(t, output, "existing-flag", "Output should contain existing flag") - assert.Contains(t, output, "new-flag", "Output should contain newly added flag") - assert.Contains(t, output, "(2)", "Output should show total count of 2 flags") - assert.Contains(t, output, "string", "Output should show flag types") - assert.Contains(t, output, "boolean", "Output should show flag types") + assert.Contains(t, output, "new-flag", "Output should contain the flag name") + assert.Contains(t, output, "added successfully", "Output should contain success message") + assert.Contains(t, output, "flags.json", "Output should contain the manifest path") +} + +func TestManifestAddCmd_NoInputFlag(t *testing.T) { + tests := []struct { + name string + args []string + expectedError string + validateResult func(t *testing.T, fs afero.Fs) + }{ + { + name: "no-input with all flags provided succeeds", + args: []string{ + "add", "test-flag", + "--default-value", "true", + "--description", "Test flag", + "--no-input", + }, + validateResult: func(t *testing.T, fs afero.Fs) { + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + flags := manifest["flags"].(map[string]interface{}) + assert.Contains(t, flags, "test-flag") + + flag := flags["test-flag"].(map[string]interface{}) + assert.Equal(t, "boolean", flag["flagType"]) + assert.Equal(t, true, flag["defaultValue"]) + assert.Equal(t, "Test flag", flag["description"]) + }, + }, + { + name: "no-input with missing default-value fails", + args: []string{ + "add", "test-flag", + "--no-input", + }, + expectedError: "--default-value is required", + }, + { + name: "no-input with description omitted succeeds", + args: []string{ + "add", "test-flag", + "--default-value", "false", + "--no-input", + }, + validateResult: func(t *testing.T, fs afero.Fs) { + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + flags := manifest["flags"].(map[string]interface{}) + assert.Contains(t, flags, "test-flag") + + flag := flags["test-flag"].(map[string]interface{}) + assert.Equal(t, "boolean", flag["flagType"]) + assert.Equal(t, false, flag["defaultValue"]) + // Description should be empty when not provided with --no-input + desc, exists := flag["description"] + if exists { + assert.Equal(t, "", desc) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create empty manifest + existingManifest := `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }` + err := afero.WriteFile(fs, "flags.json", []byte(existingManifest), 0644) + require.NoError(t, err) + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + // Set args with manifest path + args := append(tt.args, "-m", "flags.json") + cmd.SetArgs(args) + + // Execute command + err = cmd.Execute() + + // Validate + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + if tt.validateResult != nil { + tt.validateResult(t, fs) + } + } + }) + } } +func TestManifestAddCmd_AutoDetectNonInteractive(t *testing.T) { + // This test verifies that when stdin is not a terminal (like in test environments), + // the command automatically behaves as if --no-input was set + + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create empty manifest + existingManifest := `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }` + err := afero.WriteFile(fs, "flags.json", []byte(existingManifest), 0644) + require.NoError(t, err) + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + // Test without --no-input flag, but in non-interactive environment (test) + // This should behave the same as with --no-input + cmd.SetArgs([]string{ + "add", "test-flag", + "-m", "flags.json", + }) + + // Execute command + err = cmd.Execute() + + // Should fail with the same error as --no-input since stdin is not a terminal + assert.Error(t, err) + assert.Contains(t, err.Error(), "--default-value is required") +} + +func TestManifestAddCmd_NoFlagKeyArgument(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create empty manifest + existingManifest := `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }` + err := afero.WriteFile(fs, "flags.json", []byte(existingManifest), 0644) + require.NoError(t, err) + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + // Test with no flag key argument and --no-input + // This should fail with a clear error message + cmd.SetArgs([]string{ + "add", + "--default-value", "true", + "--no-input", + "-m", "flags.json", + }) + + // Execute command + err = cmd.Execute() + + // Should fail indicating flag-key is required in no-input mode + assert.Error(t, err) + assert.Contains(t, err.Error(), "flag-key argument is required when --no-input is set") +} diff --git a/internal/config/flags.go b/internal/config/flags.go index 1edb13c..203d6be 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -1,8 +1,11 @@ package config import ( + "os" + "github.com/spf13/cobra" "github.com/spf13/viper" + "golang.org/x/term" ) // Flag name constants to avoid duplication @@ -154,3 +157,16 @@ func AddManifestAddFlags(cmd *cobra.Command) { func AddManifestListFlags(cmd *cobra.Command) { // Currently no specific flags for list command, but function exists for consistency } + +// ShouldDisableInteractivePrompts returns true if interactive prompts should be disabled +// This happens when: +// - The --no-input flag is set, OR +// - stdin is not a terminal (e.g., in tests, CI, or when input is piped) +func ShouldDisableInteractivePrompts(cmd *cobra.Command) bool { + noInput := GetNoInput(cmd) + if noInput { + return true + } + // Automatically disable prompting if stdin is not a terminal + return !term.IsTerminal(int(os.Stdin.Fd())) +}