diff --git a/internal/pkg/cli/command/config/cmd.go b/internal/pkg/cli/command/config/cmd.go index 467bf3e..3555665 100644 --- a/internal/pkg/cli/command/config/cmd.go +++ b/internal/pkg/cli/command/config/cmd.go @@ -21,9 +21,17 @@ func NewConfigCmd() *cobra.Command { Long: configHelp, } - cmd.AddCommand(NewSetColorCmd()) - cmd.AddCommand(NewSetApiKeyCmd()) + // Primary commands + cmd.AddCommand(NewGetCmd()) + cmd.AddCommand(NewSetCmd()) + cmd.AddCommand(NewUnsetCmd()) + cmd.AddCommand(NewListCmd()) + cmd.AddCommand(NewDescribeCmd()) + + // Deprecated aliases kept for backwards compatibility cmd.AddCommand(NewGetApiKeyCmd()) + cmd.AddCommand(NewSetApiKeyCmd()) + cmd.AddCommand(NewSetColorCmd()) cmd.AddCommand(NewSetEnvCmd()) return cmd diff --git a/internal/pkg/cli/command/config/config_test.go b/internal/pkg/cli/command/config/config_test.go new file mode 100644 index 0000000..a3da8fc --- /dev/null +++ b/internal/pkg/cli/command/config/config_test.go @@ -0,0 +1,58 @@ +package config + +import "context" + +// mockConfigService implements ConfigService for unit tests. +// Each field controls what the corresponding method returns. +// The last* fields record the arguments of the most recent call. +type mockConfigService struct { + // Get + getValue string + getSensitive bool + getErr error + lastGetKey string + + // Set + setLines []string + setErr error + lastSetKey string + lastSetValue string + + // Unset + unsetLines []string + unsetErr error + lastUnsetKey string + + // List + listResult []ConfigEntry + + // Describe + describeResult ConfigDescription + describeErr error + lastDescribeKey string +} + +func (m *mockConfigService) Get(key string) (string, bool, error) { + m.lastGetKey = key + return m.getValue, m.getSensitive, m.getErr +} + +func (m *mockConfigService) Set(ctx context.Context, key, value string) ([]string, error) { + m.lastSetKey = key + m.lastSetValue = value + return m.setLines, m.setErr +} + +func (m *mockConfigService) Unset(ctx context.Context, key string) ([]string, error) { + m.lastUnsetKey = key + return m.unsetLines, m.unsetErr +} + +func (m *mockConfigService) List() []ConfigEntry { + return m.listResult +} + +func (m *mockConfigService) Describe(key string) (ConfigDescription, error) { + m.lastDescribeKey = key + return m.describeResult, m.describeErr +} diff --git a/internal/pkg/cli/command/config/describe.go b/internal/pkg/cli/command/config/describe.go new file mode 100644 index 0000000..57b6b29 --- /dev/null +++ b/internal/pkg/cli/command/config/describe.go @@ -0,0 +1,98 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/presenters" + "github.com/pinecone-io/cli/internal/pkg/utils/text" + "github.com/spf13/cobra" +) + +type DescribeCmdOptions struct { + reveal bool + json bool +} + +func NewDescribeCmd() *cobra.Command { + options := DescribeCmdOptions{} + + cmd := &cobra.Command{ + Use: "describe ", + Short: "Show detailed information about a configuration setting", + Example: help.Examples(` + pc config describe api-key + pc config describe environment + pc config describe color --json + `), + Args: cobra.ExactArgs(1), + ValidArgs: visibleKeys(), + Run: func(cmd *cobra.Command, args []string) { + svc := newDefaultConfigService() + if err := runDescribeCmd(svc, args[0], options); err != nil { + msg.FailJSON(options.json, "%s", err) + exit.ErrorMsg(err.Error()) + } + }, + } + + cmd.Flags().BoolVar(&options.reveal, "reveal", false, "Reveal the full value for sensitive settings like api-key") + cmd.Flags().BoolVarP(&options.json, "json", "j", false, "Output as JSON") + + return cmd +} + +func runDescribeCmd(svc ConfigService, keyName string, opts DescribeCmdOptions) error { + // --json output for the describe command + type describeOutput struct { + Key string `json:"key"` + Value string `json:"value"` + Description string `json:"description"` + LongDescription string `json:"long_description,omitempty"` + Sensitive bool `json:"sensitive"` + ValidValues []string `json:"valid_values,omitempty"` + } + + desc, err := svc.Describe(keyName) + if err != nil { + return err + } + + value := desc.Value + if desc.Sensitive && !opts.reveal { + value = presenters.MaskHeadTail(value, 4, 4) + } + + if opts.json { + fmt.Fprintln(os.Stdout, text.IndentJSON(describeOutput{ + Key: desc.Key, + Value: value, + Description: desc.Description, + LongDescription: desc.LongDescription, + Sensitive: desc.Sensitive, + ValidValues: desc.ValidValues, + })) + return nil + } + + w := presenters.NewTabWriter() + fmt.Fprintf(w, "KEY\t%s\n", desc.Key) + fmt.Fprintf(w, "VALUE\t%s\n", displayValue(value)) + fmt.Fprintf(w, "SENSITIVE\t%s\n", text.BoolToString(desc.Sensitive)) + if len(desc.ValidValues) > 0 { + fmt.Fprintf(w, "VALID VALUES\t%s\n", strings.Join(desc.ValidValues, ", ")) + } + fmt.Fprintf(w, "DESCRIPTION\t%s\n", desc.Description) + w.Flush() + + if desc.LongDescription != "" { + fmt.Fprintln(os.Stdout) + fmt.Fprintln(os.Stdout, desc.LongDescription) + } + + return nil +} diff --git a/internal/pkg/cli/command/config/describe_test.go b/internal/pkg/cli/command/config/describe_test.go new file mode 100644 index 0000000..a31937f --- /dev/null +++ b/internal/pkg/cli/command/config/describe_test.go @@ -0,0 +1,93 @@ +package config + +import ( + "errors" + "testing" + + "github.com/pinecone-io/cli/internal/pkg/cli/testutils" + "github.com/stretchr/testify/assert" +) + +func Test_runDescribeCmd_ReturnsErrorOnUnknownKey(t *testing.T) { + svc := &mockConfigService{describeErr: errors.New("unknown config key")} + + err := runDescribeCmd(svc, "bad-key", DescribeCmdOptions{}) + + assert.Error(t, err) + assert.Equal(t, "bad-key", svc.lastDescribeKey) +} + +func Test_runDescribeCmd_TabularOutput(t *testing.T) { + svc := &mockConfigService{ + describeResult: ConfigDescription{ + Key: "environment", + Value: "production", + Description: "Pinecone environment", + Sensitive: false, + ValidValues: []string{"production", "staging"}, + }, + } + + out := testutils.CaptureStdout(t, func() { + err := runDescribeCmd(svc, "environment", DescribeCmdOptions{}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, "environment") + assert.Contains(t, out, "production") +} + +func Test_runDescribeCmd_JSONOutput(t *testing.T) { + svc := &mockConfigService{ + describeResult: ConfigDescription{ + Key: "environment", + Value: "production", + Description: "Pinecone environment", + Sensitive: false, + ValidValues: []string{"production", "staging"}, + }, + } + + out := testutils.CaptureStdout(t, func() { + err := runDescribeCmd(svc, "environment", DescribeCmdOptions{json: true}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, `"environment"`) + assert.Contains(t, out, `"production"`) + assert.Contains(t, out, `"valid_values"`) +} + +func Test_runDescribeCmd_MasksSensitiveKeyInJSON(t *testing.T) { + svc := &mockConfigService{ + describeResult: ConfigDescription{ + Key: "api-key", + Value: "supersecretvalue", + Sensitive: true, + }, + } + + out := testutils.CaptureStdout(t, func() { + err := runDescribeCmd(svc, "api-key", DescribeCmdOptions{json: true, reveal: false}) + assert.NoError(t, err) + }) + + assert.NotContains(t, out, "supersecretvalue") +} + +func Test_runDescribeCmd_RevealsSensitiveKeyInJSON(t *testing.T) { + svc := &mockConfigService{ + describeResult: ConfigDescription{ + Key: "api-key", + Value: "supersecretvalue", + Sensitive: true, + }, + } + + out := testutils.CaptureStdout(t, func() { + err := runDescribeCmd(svc, "api-key", DescribeCmdOptions{json: true, reveal: true}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, "supersecretvalue") +} diff --git a/internal/pkg/cli/command/config/get.go b/internal/pkg/cli/command/config/get.go new file mode 100644 index 0000000..e9e049d --- /dev/null +++ b/internal/pkg/cli/command/config/get.go @@ -0,0 +1,73 @@ +package config + +import ( + "fmt" + "os" + + "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/presenters" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/pinecone-io/cli/internal/pkg/utils/text" + "github.com/spf13/cobra" +) + +type GetCmdOptions struct { + reveal bool + json bool +} + +func NewGetCmd() *cobra.Command { + options := GetCmdOptions{} + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get the current value of a configuration setting", + Example: help.Examples(` + pc config get api-key + pc config get api-key --reveal + pc config get environment + pc config get color + `), + Args: cobra.ExactArgs(1), + ValidArgs: visibleKeys(), + Run: func(cmd *cobra.Command, args []string) { + svc := newDefaultConfigService() + if err := runGetCmd(svc, args[0], options); err != nil { + msg.FailJSON(options.json, "%s", err) + exit.ErrorMsg(err.Error()) + } + }, + } + + cmd.Flags().BoolVar(&options.reveal, "reveal", false, "Reveal the full value for sensitive settings like api-key") + cmd.Flags().BoolVarP(&options.json, "json", "j", false, "Output as JSON") + + return cmd +} + +func runGetCmd(svc ConfigService, keyName string, opts GetCmdOptions) error { + // --json output for the get command + type getOutput struct { + Key string `json:"key"` + Value string `json:"value"` + } + + value, sensitive, err := svc.Get(keyName) + if err != nil { + return err + } + + if sensitive && !opts.reveal { + value = presenters.MaskHeadTail(value, 4, 4) + } + + if opts.json { + fmt.Fprintln(os.Stdout, text.IndentJSON(getOutput{Key: keyName, Value: value})) + return nil + } + + msg.InfoMsg("%s: %s", style.Emphasis(keyName), displayValue(value)) + return nil +} diff --git a/internal/pkg/cli/command/config/get_api_key.go b/internal/pkg/cli/command/config/get_api_key.go index e288322..7eba217 100644 --- a/internal/pkg/cli/command/config/get_api_key.go +++ b/internal/pkg/cli/command/config/get_api_key.go @@ -21,8 +21,10 @@ func NewGetApiKeyCmd() *cobra.Command { options := GetAPIKeyCmdOptions{} cmd := &cobra.Command{ - Use: "get-api-key", - Short: "Get the current default API key configured for the Pinecone CLI", + Use: "get-api-key", + Short: "Get the current default API key configured for the Pinecone CLI", + Deprecated: "use 'pc config get api-key' instead", + Hidden: true, Example: help.Examples(` pc config get-api-key `), diff --git a/internal/pkg/cli/command/config/get_test.go b/internal/pkg/cli/command/config/get_test.go new file mode 100644 index 0000000..0a89f6c --- /dev/null +++ b/internal/pkg/cli/command/config/get_test.go @@ -0,0 +1,61 @@ +package config + +import ( + "errors" + "testing" + + "github.com/pinecone-io/cli/internal/pkg/cli/testutils" + "github.com/stretchr/testify/assert" +) + +func Test_runGetCmd_ReturnsErrorOnUnknownKey(t *testing.T) { + svc := &mockConfigService{getErr: errors.New("unknown config key")} + + err := runGetCmd(svc, "bad-key", GetCmdOptions{}) + + assert.Error(t, err) + assert.Equal(t, "bad-key", svc.lastGetKey) +} + +func Test_runGetCmd_Succeeds(t *testing.T) { + svc := &mockConfigService{getValue: "production"} + + err := runGetCmd(svc, "environment", GetCmdOptions{}) + + assert.NoError(t, err) + assert.Equal(t, "environment", svc.lastGetKey) +} + +func Test_runGetCmd_JSONOutput(t *testing.T) { + svc := &mockConfigService{getValue: "production"} + + out := testutils.CaptureStdout(t, func() { + err := runGetCmd(svc, "environment", GetCmdOptions{json: true}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, `"environment"`) + assert.Contains(t, out, `"production"`) +} + +func Test_runGetCmd_MasksSensitiveKeyInJSON(t *testing.T) { + svc := &mockConfigService{getValue: "supersecretvalue", getSensitive: true} + + out := testutils.CaptureStdout(t, func() { + err := runGetCmd(svc, "api-key", GetCmdOptions{json: true, reveal: false}) + assert.NoError(t, err) + }) + + assert.NotContains(t, out, "supersecretvalue") +} + +func Test_runGetCmd_RevealsSensitiveKeyInJSON(t *testing.T) { + svc := &mockConfigService{getValue: "supersecretvalue", getSensitive: true} + + out := testutils.CaptureStdout(t, func() { + err := runGetCmd(svc, "api-key", GetCmdOptions{json: true, reveal: true}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, "supersecretvalue") +} diff --git a/internal/pkg/cli/command/config/list.go b/internal/pkg/cli/command/config/list.go new file mode 100644 index 0000000..419d01a --- /dev/null +++ b/internal/pkg/cli/command/config/list.go @@ -0,0 +1,81 @@ +package config + +import ( + "fmt" + "os" + + "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/presenters" + "github.com/pinecone-io/cli/internal/pkg/utils/text" + "github.com/spf13/cobra" +) + +type ListCmdOptions struct { + reveal bool + json bool +} + +func NewListCmd() *cobra.Command { + options := ListCmdOptions{} + + cmd := &cobra.Command{ + Use: "list", + Short: "List all configuration settings and their current values", + Example: help.Examples(` + pc config list + pc config list --reveal + pc config list --json + `), + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + svc := newDefaultConfigService() + if err := runListCmd(svc, options); err != nil { + msg.FailJSON(options.json, "%s", err) + exit.ErrorMsg(err.Error()) + } + }, + } + + cmd.Flags().BoolVar(&options.reveal, "reveal", false, "Reveal the full value for sensitive settings like api-key") + cmd.Flags().BoolVarP(&options.json, "json", "j", false, "Output as JSON") + + return cmd +} + +func runListCmd(svc ConfigService, opts ListCmdOptions) error { + // --json output for the list command + type listOutput struct { + Key string `json:"key"` + Value string `json:"value"` + Description string `json:"description"` + } + + entries := svc.List() + + if opts.json { + jsonEntries := make([]listOutput, 0, len(entries)) + for _, e := range entries { + value := e.Value + if e.Sensitive && !opts.reveal { + value = presenters.MaskHeadTail(value, 4, 4) + } + jsonEntries = append(jsonEntries, listOutput{Key: e.Key, Value: value, Description: e.Description}) + } + fmt.Fprintln(os.Stdout, text.IndentJSON(jsonEntries)) + return nil + } + + w := presenters.NewTabWriter() + fmt.Fprintln(w, "KEY\tVALUE\tDESCRIPTION") + for _, e := range entries { + value := e.Value + if e.Sensitive && !opts.reveal { + value = presenters.MaskHeadTail(value, 4, 4) + } + fmt.Fprintf(w, "%s\t%s\t%s\n", e.Key, displayValue(value), e.Description) + } + w.Flush() + return nil +} diff --git a/internal/pkg/cli/command/config/list_test.go b/internal/pkg/cli/command/config/list_test.go new file mode 100644 index 0000000..423db53 --- /dev/null +++ b/internal/pkg/cli/command/config/list_test.go @@ -0,0 +1,87 @@ +package config + +import ( + "testing" + + "github.com/pinecone-io/cli/internal/pkg/cli/testutils" + "github.com/stretchr/testify/assert" +) + +func Test_runListCmd_TabularOutputIncludesHeader(t *testing.T) { + svc := &mockConfigService{listResult: []ConfigEntry{}} + + out := testutils.CaptureStdout(t, func() { + err := runListCmd(svc, ListCmdOptions{}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, "KEY") + assert.Contains(t, out, "VALUE") + assert.Contains(t, out, "DESCRIPTION") +} + +func Test_runListCmd_TabularOutputMasksSensitiveKey(t *testing.T) { + svc := &mockConfigService{ + listResult: []ConfigEntry{ + {Key: "api-key", Value: "sk-supersecret", Description: "API key", Sensitive: true}, + }, + } + + out := testutils.CaptureStdout(t, func() { + err := runListCmd(svc, ListCmdOptions{}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, "api-key") + assert.NotContains(t, out, "sk-supersecret") +} + +func Test_runListCmd_TabularOutputRevealsSensitiveKey(t *testing.T) { + svc := &mockConfigService{ + listResult: []ConfigEntry{ + {Key: "api-key", Value: "sk-supersecret", Sensitive: true}, + }, + } + + out := testutils.CaptureStdout(t, func() { + err := runListCmd(svc, ListCmdOptions{reveal: true}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, "sk-supersecret") +} + +func Test_runListCmd_JSONOutput(t *testing.T) { + svc := &mockConfigService{ + listResult: []ConfigEntry{ + {Key: "api-key", Value: "sk-supersecret", Description: "API key", Sensitive: true}, + {Key: "color", Value: "true", Description: "Color output", Sensitive: false}, + }, + } + + out := testutils.CaptureStdout(t, func() { + err := runListCmd(svc, ListCmdOptions{json: true}) + assert.NoError(t, err) + }) + + // Sensitive key should be masked in JSON output + assert.NotContains(t, out, "sk-supersecret") + // Non-sensitive values should appear + assert.Contains(t, out, `"color"`) + assert.Contains(t, out, `"true"`) +} + +func Test_runListCmd_JSONOutputRevealsSensitiveKey(t *testing.T) { + svc := &mockConfigService{ + listResult: []ConfigEntry{ + {Key: "api-key", Value: "sk-supersecret", Sensitive: true}, + }, + } + + out := testutils.CaptureStdout(t, func() { + err := runListCmd(svc, ListCmdOptions{json: true, reveal: true}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, "sk-supersecret") +} diff --git a/internal/pkg/cli/command/config/registry.go b/internal/pkg/cli/command/config/registry.go new file mode 100644 index 0000000..436138d --- /dev/null +++ b/internal/pkg/cli/command/config/registry.go @@ -0,0 +1,307 @@ +package config + +import ( + "context" + "errors" + "fmt" + "strings" + + conf "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" + "github.com/pinecone-io/cli/internal/pkg/utils/configuration/secrets" + "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/oauth" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/pinecone-io/cli/internal/pkg/utils/text" +) + +// ErrNoChange is returned by validateStr when the incoming value is equivalent +// to the current stored value and no write is needed. +var ErrNoChange = errors.New("no change") + +// keyDescriptor describes a single user-configurable setting. +type keyDescriptor struct { + Description string + LongDescription string // optional multi-paragraph detail shown by `pc config describe` + Sensitive bool + Hidden bool + ValidValues []string // non-nil: values shown in help; nil: any non-empty string accepted + defaultVal string // the value restored by Unset; must match what getStr returns at the default + getStr func() string + // validateStr normalises the incoming value and checks whether it differs from + // the current value. It is pure (no I/O) and must be called before persistStr. + // Returns ErrNoChange when the value is already current, or a validation error. + // If nil, the value is passed through to persistStr unchanged. + validateStr func(value string) (normalizedValue string, err error) + // onChange is called with the prospective new value before it is persisted. + // Returning an error aborts the operation; nothing is written to disk. + onChange func(ctx context.Context, oldVal, newVal string) ([]string, error) + // persistStr writes a normalised value returned by validateStr. It is only + // called after validateStr and onChange have both succeeded. + persistStr func(normalizedValue string) +} + +// configKeys represent the set of valid configuration keys. +// This is used by lookupKey to validate keys on config commands, +// and the order of keys in the list command. +var configKeys = []string{ + "api-key", + "color", + "environment", +} + +// configRegistry is a map of all config keys and their descriptors. +var configRegistry = map[string]keyDescriptor{ + "api-key": { + Description: "Default API key for authenticating with Pinecone", + LongDescription: help.Long(` + Configure the CLI to authenticate with Pinecone using an API key. + + When set, the API key takes priority over any target context established by + user login or service account credentials, and is used for all API calls. + + To clear the explicit API key, run 'pc config unset api-key'. + `), + Sensitive: true, + defaultVal: "", + getStr: func() string { + return secrets.DefaultAPIKey.Get() + }, + persistStr: func(value string) { + secrets.DefaultAPIKey.Set(value) + }, + }, + + "color": { + Description: "Enable or disable colored terminal output", + ValidValues: []string{"true", "false", "on", "off"}, + defaultVal: "true", + getStr: func() string { + return text.BoolToString(conf.Color.Get()) + }, + validateStr: func(value string) (string, error) { + switch strings.ToLower(value) { + case "true", "on": + return "true", nil + case "false", "off": + return "false", nil + default: + return "", fmt.Errorf("invalid value %q for color; must be one of: true, false, on, off", value) + } + }, + persistStr: func(value string) { + conf.Color.Set(value == "true") + }, + }, + + "environment": { + Description: "Pinecone environment to target (production or staging)", + LongDescription: help.Long(` + Select which Pinecone environment the CLI talks to. Most users should + leave this set to 'production'; 'staging' is intended for Pinecone + internal development. + + Changing the environment clears your existing authentication state: any + OAuth session is logged out, the default API key is cleared, and the + target organization and project are reset. You will need to re-authenticate + and re-target after switching. + `), + Hidden: true, + ValidValues: []string{"production", "staging"}, + defaultVal: "production", + getStr: func() string { + return conf.Environment.Get() + }, + validateStr: func(value string) (string, error) { + switch value { + case "production", "staging": + // canonical values + default: + return "", fmt.Errorf("invalid environment %q; must be one of: production, staging", value) + } + if conf.Environment.Get() == value { + return "", ErrNoChange + } + return value, nil + }, + persistStr: func(value string) { + conf.Environment.Set(value) + }, + onChange: func(ctx context.Context, _, _ string) ([]string, error) { + var lines []string + + // Check for existing OAuth sessions and login credentials and clear them when the environment is changed. + token, err := oauth.Token(ctx) + if err != nil { + return nil, fmt.Errorf("error retrieving oauth token: %w", err) + } + if token != nil && (token.AccessToken != "" || token.RefreshToken != "") { + oauth.Logout() + lines = append(lines, fmt.Sprintf("You have been logged out; to login again, run %s", style.Code("pc login"))) + } else { + lines = append(lines, fmt.Sprintf("To login, run %s", style.Code("pc login"))) + } + + if secrets.DefaultAPIKey.Get() != "" { + secrets.DefaultAPIKey.Clear() + lines = append(lines, fmt.Sprintf("API key cleared; to set a new API key, run %s", style.Code("pc config set api-key "))) + } else { + lines = append(lines, fmt.Sprintf("To set a new API key, run %s", style.Code("pc config set api-key "))) + } + + if state.TargetOrg.Get().Name != "" || state.TargetProj.Get().Name != "" { + state.TargetOrg.Clear() + state.TargetProj.Clear() + lines = append(lines, fmt.Sprintf("Target organization and project cleared; to set a new target, run %s", style.Code("pc target -o myorg -p myproj"))) + } + + return lines, nil + }, + }, +} + +// lookupKey returns the descriptor for name, or a descriptive error listing valid keys. +func lookupKey(name string) (keyDescriptor, error) { + desc, ok := configRegistry[name] + if !ok { + return keyDescriptor{}, fmt.Errorf("unknown config key %q; valid keys are: %s", name, strings.Join(configKeys, ", ")) + } + return desc, nil +} + +// visibleKeys returns the set of config keys that are surfaced to the user. +func visibleKeys() []string { + keys := make([]string, 0, len(configKeys)) + for _, key := range configKeys { + if !configRegistry[key].Hidden { + keys = append(keys, key) + } + } + return keys +} + +// displayValue formats a config value for human-readable output, substituting +// a placeholder when the value is empty. JSON output should use the raw value. +func displayValue(value string) string { + if value == "" { + return "" + } + return value +} + +// ConfigEntry holds the key, value, and metadata for a single config setting, +// used by the list command. +type ConfigEntry struct { + Key string + Value string + Description string + Sensitive bool +} + +// ConfigDescription holds full metadata for a config key, used by the describe command. +type ConfigDescription struct { + Key string + Value string + Description string + LongDescription string + Sensitive bool + ValidValues []string +} + +// ConfigService abstracts config registry operations for unit testing across +// the get, set, unset, list, and describe commands. +type ConfigService interface { + Get(key string) (value string, sensitive bool, err error) + Set(ctx context.Context, key, value string) (onChangeLines []string, err error) + Unset(ctx context.Context, key string) (onChangeLines []string, err error) + List() []ConfigEntry + Describe(key string) (ConfigDescription, error) +} + +type defaultConfigService struct{} + +func newDefaultConfigService() ConfigService { + return &defaultConfigService{} +} + +func (s *defaultConfigService) Get(key string) (string, bool, error) { + desc, err := lookupKey(key) + if err != nil { + return "", false, err + } + return desc.getStr(), desc.Sensitive, nil +} + +func (s *defaultConfigService) Set(ctx context.Context, key, value string) ([]string, error) { + desc, err := lookupKey(key) + if err != nil { + return nil, err + } + oldVal := desc.getStr() + normalizedVal := value + if desc.validateStr != nil { + if normalizedVal, err = desc.validateStr(value); err != nil { + return nil, err + } + } + // Run onChange before persisting so the config file is not written if onChange fails. + var lines []string + if desc.onChange != nil { + if lines, err = desc.onChange(ctx, oldVal, normalizedVal); err != nil { + return nil, err + } + } + desc.persistStr(normalizedVal) + return lines, nil +} + +func (s *defaultConfigService) Unset(ctx context.Context, key string) ([]string, error) { + desc, err := lookupKey(key) + if err != nil { + return nil, err + } + oldVal := desc.getStr() + newVal := desc.defaultVal + if oldVal == newVal { + return nil, nil + } + // Run onChange before persisting so the config file is not written if onChange fails. + var lines []string + if desc.onChange != nil { + if lines, err = desc.onChange(ctx, oldVal, newVal); err != nil { + return nil, err + } + } + desc.persistStr(newVal) + return lines, nil +} + +func (s *defaultConfigService) List() []ConfigEntry { + visible := visibleKeys() + entries := make([]ConfigEntry, 0, len(visible)) + for _, key := range visible { + desc := configRegistry[key] + entries = append(entries, ConfigEntry{ + Key: key, + Value: desc.getStr(), + Description: desc.Description, + Sensitive: desc.Sensitive, + }) + } + return entries +} + +func (s *defaultConfigService) Describe(key string) (ConfigDescription, error) { + desc, err := lookupKey(key) + if err != nil { + return ConfigDescription{}, err + } + return ConfigDescription{ + Key: key, + Value: desc.getStr(), + Description: desc.Description, + LongDescription: desc.LongDescription, + Sensitive: desc.Sensitive, + ValidValues: desc.ValidValues, + }, nil +} diff --git a/internal/pkg/cli/command/config/registry_test.go b/internal/pkg/cli/command/config/registry_test.go new file mode 100644 index 0000000..75d2d23 --- /dev/null +++ b/internal/pkg/cli/command/config/registry_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLookupKey_ValidKeys(t *testing.T) { + for _, key := range configKeys { + t.Run(key, func(t *testing.T) { + desc, err := lookupKey(key) + assert.NoError(t, err) + assert.NotEmpty(t, desc.Description) + }) + } +} + +func TestLookupKey_InvalidKey(t *testing.T) { + _, err := lookupKey("not-a-real-key") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not-a-real-key") + for _, key := range configKeys { + assert.Contains(t, err.Error(), key) + } +} + +func TestConfigKeysMatchRegistry(t *testing.T) { + for _, key := range configKeys { + _, err := lookupKey(key) + assert.NoError(t, err, "key %q is in configKeys but not in configRegistry", key) + } + assert.Equal(t, len(configKeys), len(configRegistry), + "configRegistry has keys not listed in configKeys") +} + +func TestVisibleKeysFiltersHiddenKeys(t *testing.T) { + visibleKeys := visibleKeys() + for key, desc := range configRegistry { + if desc.Hidden { + assert.NotContains(t, visibleKeys, key) + } else { + assert.Contains(t, visibleKeys, key) + } + } +} + +func TestDisplayValue_Empty(t *testing.T) { + assert.Equal(t, "", displayValue("")) +} + +func TestDisplayValue_NonEmpty(t *testing.T) { + assert.Equal(t, "production", displayValue("production")) +} diff --git a/internal/pkg/cli/command/config/set.go b/internal/pkg/cli/command/config/set.go new file mode 100644 index 0000000..87e14c2 --- /dev/null +++ b/internal/pkg/cli/command/config/set.go @@ -0,0 +1,89 @@ +package config + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/pinecone-io/cli/internal/pkg/utils/text" + "github.com/spf13/cobra" +) + +type SetCmdOptions struct { + json bool +} + +func NewSetCmd() *cobra.Command { + options := SetCmdOptions{} + + cmd := &cobra.Command{ + Use: "set ", + Short: "Set a configuration value", + Example: help.Examples(` + pc config set api-key pcsk_... + pc config set environment staging + pc config set color false + `), + Args: cobra.ExactArgs(2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return configKeys, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + }, + Run: func(cmd *cobra.Command, args []string) { + svc := newDefaultConfigService() + if err := runSetCmd(cmd.Context(), svc, args[0], args[1], options); err != nil { + msg.FailJSON(options.json, "%s", err) + exit.ErrorMsg(err.Error()) + } + }, + } + + cmd.Flags().BoolVarP(&options.json, "json", "j", false, "Output as JSON") + + return cmd +} + +func runSetCmd(ctx context.Context, svc ConfigService, keyName, value string, opts SetCmdOptions) error { + // --json output for the set command + type setOutput struct { + Key string `json:"key"` + Value string `json:"value"` + } + + // Fetch the current value up front so it can be shown in the ErrNoChange message. + currentValue, _, err := svc.Get(keyName) + if err != nil { + return err + } + + lines, err := svc.Set(ctx, keyName, value) + if err != nil { + if errors.Is(err, ErrNoChange) { + if opts.json { + fmt.Fprintln(os.Stdout, text.IndentJSON(setOutput{Key: keyName, Value: currentValue})) + return nil + } + msg.InfoMsg("%s is already set to %s", style.Emphasis(keyName), style.Emphasis(currentValue)) + return nil + } + return err + } + + if opts.json { + fmt.Fprintln(os.Stdout, text.IndentJSON(setOutput{Key: keyName, Value: value})) + return nil + } + + msg.SuccessMsg("%s updated", style.Emphasis(keyName)) + for _, line := range lines { + msg.InfoMsg("%s", line) + } + return nil +} diff --git a/internal/pkg/cli/command/config/set_api_key.go b/internal/pkg/cli/command/config/set_api_key.go index 95737a6..52e61fb 100644 --- a/internal/pkg/cli/command/config/set_api_key.go +++ b/internal/pkg/cli/command/config/set_api_key.go @@ -20,9 +20,11 @@ var ( func NewSetApiKeyCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "set-api-key", - Short: "Configure the CLI to authenticate with Pinecone using a default API key", - Long: setAPIKeyHelp, + Use: "set-api-key", + Short: "Configure the CLI to authenticate with Pinecone using a default API key", + Long: setAPIKeyHelp, + Deprecated: "use 'pc config set api-key ' instead", + Hidden: true, Example: help.Examples(` pc config set-api-key "api-key-value" `), diff --git a/internal/pkg/cli/command/config/set_color.go b/internal/pkg/cli/command/config/set_color.go index c63bb5c..28069d4 100644 --- a/internal/pkg/cli/command/config/set_color.go +++ b/internal/pkg/cli/command/config/set_color.go @@ -12,8 +12,10 @@ import ( func NewSetColorCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "set-color", - Short: "Configure whether the CLI prints output with color", + Use: "set-color", + Short: "Configure whether the CLI prints output with color", + Deprecated: "use 'pc config set color ' instead", + Hidden: true, Example: help.Examples(` pc config set-color true pc config set-color false diff --git a/internal/pkg/cli/command/config/set_environment.go b/internal/pkg/cli/command/config/set_environment.go index 6ccc80c..b992a75 100644 --- a/internal/pkg/cli/command/config/set_environment.go +++ b/internal/pkg/cli/command/config/set_environment.go @@ -15,13 +15,14 @@ import ( func NewSetEnvCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "set-environment ", - Short: "Configure the environment (production or staging)", + Use: "set-environment ", + Short: "Configure the environment (production or staging)", + Deprecated: "use 'pc config set environment ' instead", + Hidden: true, Example: help.Examples(` pc config set-environment "production" pc config set-environment "staging" `), - Hidden: false, Run: func(cmd *cobra.Command, args []string) { if len(args) == 0 { msg.FailMsg("Please provide a value for environment. Accepted values are %s, %s", style.Emphasis("production"), style.Emphasis("staging")) @@ -64,9 +65,9 @@ func NewSetEnvCmd() *cobra.Command { if secrets.DefaultAPIKey.Get() != "" { secrets.DefaultAPIKey.Clear() - msg.InfoMsg("API key cleared; to set a new API key, run %s", style.Code("pc config set-api-key")) + msg.InfoMsg("API key cleared; to set a new API key, run %s", style.Code("pc config set api-key ")) } else { - msg.InfoMsg("To set a new API key, run %s", style.Code("pc config set-api-key")) + msg.InfoMsg("To set a new API key, run %s", style.Code("pc config set api-key ")) } if state.TargetOrg.Get().Name != "" || state.TargetProj.Get().Name != "" { diff --git a/internal/pkg/cli/command/config/set_test.go b/internal/pkg/cli/command/config/set_test.go new file mode 100644 index 0000000..94b5541 --- /dev/null +++ b/internal/pkg/cli/command/config/set_test.go @@ -0,0 +1,93 @@ +package config + +import ( + "context" + "errors" + "testing" + + "github.com/pinecone-io/cli/internal/pkg/cli/testutils" + "github.com/stretchr/testify/assert" +) + +func Test_runSetCmd_ReturnsErrorOnUnknownKey(t *testing.T) { + svc := &mockConfigService{getErr: errors.New("unknown config key")} + + err := runSetCmd(context.Background(), svc, "bad-key", "value", SetCmdOptions{}) + + assert.Error(t, err) + assert.Empty(t, svc.lastSetKey) +} + +func Test_runSetCmd_ReturnsNilOnNoChange(t *testing.T) { + svc := &mockConfigService{ + getValue: "production", + setErr: ErrNoChange, + } + + err := runSetCmd(context.Background(), svc, "environment", "production", SetCmdOptions{}) + + assert.NoError(t, err) + assert.Equal(t, "environment", svc.lastSetKey) + assert.Equal(t, "production", svc.lastSetValue) +} + +func Test_runSetCmd_ReturnsErrorOnValidationFailure(t *testing.T) { + svc := &mockConfigService{ + getValue: "production", + setErr: errors.New("invalid value"), + } + + err := runSetCmd(context.Background(), svc, "environment", "invalid", SetCmdOptions{}) + + assert.Error(t, err) + assert.Equal(t, "environment", svc.lastSetKey) +} + +func Test_runSetCmd_Succeeds(t *testing.T) { + svc := &mockConfigService{getValue: "production"} + + err := runSetCmd(context.Background(), svc, "environment", "staging", SetCmdOptions{}) + + assert.NoError(t, err) + assert.Equal(t, "environment", svc.lastSetKey) + assert.Equal(t, "staging", svc.lastSetValue) +} + +func Test_runSetCmd_SucceedsWithOnChangeLines(t *testing.T) { + svc := &mockConfigService{ + getValue: "production", + setLines: []string{"You have been logged out", "API key cleared"}, + } + + err := runSetCmd(context.Background(), svc, "environment", "staging", SetCmdOptions{}) + + assert.NoError(t, err) + assert.Equal(t, "staging", svc.lastSetValue) +} + +func Test_runSetCmd_JSONOutput(t *testing.T) { + svc := &mockConfigService{getValue: "production"} + + out := testutils.CaptureStdout(t, func() { + err := runSetCmd(context.Background(), svc, "environment", "staging", SetCmdOptions{json: true}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, `"environment"`) + assert.Contains(t, out, `"staging"`) +} + +func Test_runSetCmd_JSONOutputOnNoChange(t *testing.T) { + svc := &mockConfigService{ + getValue: "production", + setErr: ErrNoChange, + } + + out := testutils.CaptureStdout(t, func() { + err := runSetCmd(context.Background(), svc, "environment", "production", SetCmdOptions{json: true}) + assert.NoError(t, err) + }) + + assert.Contains(t, out, `"environment"`) + assert.Contains(t, out, `"production"`) +} diff --git a/internal/pkg/cli/command/config/unset.go b/internal/pkg/cli/command/config/unset.go new file mode 100644 index 0000000..e45e190 --- /dev/null +++ b/internal/pkg/cli/command/config/unset.go @@ -0,0 +1,68 @@ +package config + +import ( + "context" + "fmt" + "os" + + "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/pinecone-io/cli/internal/pkg/utils/text" + "github.com/spf13/cobra" +) + +type UnsetCmdOptions struct { + json bool +} + +func NewUnsetCmd() *cobra.Command { + options := UnsetCmdOptions{} + + cmd := &cobra.Command{ + Use: "unset ", + Short: "Reset a configuration value to its default", + Example: help.Examples(` + pc config unset api-key + pc config unset color + `), + Args: cobra.ExactArgs(1), + ValidArgs: visibleKeys(), + Run: func(cmd *cobra.Command, args []string) { + svc := newDefaultConfigService() + if err := runUnsetCmd(cmd.Context(), svc, args[0], options); err != nil { + msg.FailJSON(options.json, "%s", err) + exit.ErrorMsg(err.Error()) + } + }, + } + + cmd.Flags().BoolVarP(&options.json, "json", "j", false, "Output as JSON") + + return cmd +} + +func runUnsetCmd(ctx context.Context, svc ConfigService, keyName string, opts UnsetCmdOptions) error { + // --json output for the unset command + type unsetOutput struct { + Key string `json:"key"` + Cleared bool `json:"cleared"` + } + + lines, err := svc.Unset(ctx, keyName) + if err != nil { + return err + } + + if opts.json { + fmt.Fprintln(os.Stdout, text.IndentJSON(unsetOutput{Key: keyName, Cleared: true})) + return nil + } + + msg.SuccessMsg("%s cleared", style.Emphasis(keyName)) + for _, line := range lines { + msg.InfoMsg("%s", line) + } + return nil +} diff --git a/internal/pkg/cli/command/config/unset_test.go b/internal/pkg/cli/command/config/unset_test.go new file mode 100644 index 0000000..bdf5134 --- /dev/null +++ b/internal/pkg/cli/command/config/unset_test.go @@ -0,0 +1,49 @@ +package config + +import ( + "context" + "errors" + "testing" + + "github.com/pinecone-io/cli/internal/pkg/cli/testutils" + "github.com/stretchr/testify/assert" +) + +func Test_runUnsetCmd_ReturnsErrorOnUnknownKey(t *testing.T) { + svc := &mockConfigService{unsetErr: errors.New("unknown config key")} + + err := runUnsetCmd(context.Background(), svc, "bad-key", UnsetCmdOptions{}) + + assert.Error(t, err) +} + +func Test_runUnsetCmd_Succeeds(t *testing.T) { + svc := &mockConfigService{} + + err := runUnsetCmd(context.Background(), svc, "api-key", UnsetCmdOptions{}) + + assert.NoError(t, err) + assert.Equal(t, "api-key", svc.lastUnsetKey) +} + +func Test_runUnsetCmd_SucceedsWithOnChangeLines(t *testing.T) { + svc := &mockConfigService{ + unsetLines: []string{"You have been logged out"}, + } + + err := runUnsetCmd(context.Background(), svc, "environment", UnsetCmdOptions{}) + + assert.NoError(t, err) + assert.Equal(t, "environment", svc.lastUnsetKey) +} + +func Test_runUnsetCmd_JSONOutput(t *testing.T) { + svc := &mockConfigService{} + + out := testutils.CaptureStdout(t, func() { + err := runUnsetCmd(context.Background(), svc, "api-key", UnsetCmdOptions{json: true}) + assert.NoError(t, err) + }) + + assert.JSONEq(t, `{"key":"api-key","cleared":true}`, out) +} diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index a05007c..481bb64 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -50,6 +50,11 @@ var skipAuthCommands = map[string]struct{}{ "pc target": {}, // handles its own auth after --show/--clear early returns "pc version": {}, "pc config": {}, + "pc config get": {}, + "pc config set": {}, + "pc config unset": {}, + "pc config list": {}, + "pc config describe": {}, "pc config get-api-key": {}, "pc config set-api-key": {}, "pc config set-color": {},