Skip to content

Commit 7944cc8

Browse files
Implement "stripe plugin config" command
1 parent 5d53b24 commit 7944cc8

4 files changed

Lines changed: 280 additions & 11 deletions

File tree

pkg/cmd/plugin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func newPluginCmd() *pluginCmd {
2525
pc.cmd.AddCommand(plugin.NewInstallCmd(&Config).Cmd)
2626
pc.cmd.AddCommand(plugin.NewUpgradeCmd(&Config).Cmd)
2727
pc.cmd.AddCommand(plugin.NewUninstallCmd(&Config).Cmd)
28+
pc.cmd.AddCommand(plugin.NewConfigCmd(&Config).Cmd)
2829

2930
return pc
3031
}

pkg/cmd/plugin/config.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package plugin
2+
3+
import (
4+
"fmt"
5+
"slices"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/stripe/stripe-cli/pkg/config"
10+
)
11+
12+
// ConfigCmd handles `stripe plugin config` for reading and writing plugin settings.
13+
type ConfigCmd struct {
14+
cfg *config.Config
15+
Cmd *cobra.Command
16+
17+
set bool
18+
unset string
19+
}
20+
21+
// NewConfigCmd creates the `stripe plugin config` command.
22+
func NewConfigCmd(cfg *config.Config) *ConfigCmd {
23+
cc := &ConfigCmd{cfg: cfg}
24+
25+
cc.Cmd = &cobra.Command{
26+
Hidden: true,
27+
Use: "config [plugin]",
28+
Short: "Read and write plugin configuration",
29+
Long: `Read and write configuration for plugins. Omit the plugin name to apply the setting globally.
30+
31+
Available fields:
32+
updates Controls automatic background updates for a plugin. When set to "off",
33+
the CLI will not check for or download newer versions automatically.
34+
Accepted values: on, off (default: off)`,
35+
Example: `stripe plugin config --set updates on
36+
stripe plugin config --unset updates
37+
stripe plugin config apps --set updates off
38+
stripe plugin config apps --unset updates`,
39+
RunE: cc.run,
40+
}
41+
42+
cc.Cmd.Flags().BoolVar(&cc.set, "set", false, "Set a config field to some value")
43+
cc.Cmd.Flags().StringVar(&cc.unset, "unset", "", "Unset a specific config field")
44+
45+
return cc
46+
}
47+
48+
func (cc *ConfigCmd) run(cmd *cobra.Command, args []string) error {
49+
switch {
50+
case cc.set && len(args) == 2:
51+
// stripe plugin config --set updates <value> (global)
52+
return cc.setUpdates("__global", args[0], args[1])
53+
case cc.set && len(args) == 3:
54+
// stripe plugin config <plugin> --set updates <value>
55+
return cc.setUpdates(args[0], args[1], args[2])
56+
case cc.unset != "" && len(args) == 0:
57+
// stripe plugin config --unset updates (global)
58+
return cc.cfg.DeleteConfigField(fmt.Sprintf("plugin_configs.__global.%s", cc.unset))
59+
case cc.unset != "" && len(args) == 1:
60+
// stripe plugin config <plugin> --unset updates
61+
pluginName := args[0]
62+
if !slices.Contains(cc.cfg.GetInstalledPlugins(), pluginName) {
63+
return fmt.Errorf("plugin %q is not installed", pluginName)
64+
}
65+
return cc.cfg.DeleteConfigField(fmt.Sprintf("plugin_configs.%s.%s", pluginName, cc.unset))
66+
default:
67+
return cmd.Help()
68+
}
69+
}
70+
71+
func (cc *ConfigCmd) setUpdates(scope, field, value string) error {
72+
if field != "updates" {
73+
return fmt.Errorf("unknown config field %q", field)
74+
}
75+
if value != "on" && value != "off" {
76+
return fmt.Errorf("invalid value %q for updates — must be \"on\" or \"off\"", value)
77+
}
78+
if scope != "__global" {
79+
if !slices.Contains(cc.cfg.GetInstalledPlugins(), scope) {
80+
return fmt.Errorf("plugin %q is not installed", scope)
81+
}
82+
}
83+
return cc.cfg.WriteConfigField(fmt.Sprintf("plugin_configs.%s.%s", scope, field), value)
84+
}

pkg/cmd/plugin/config_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package plugin
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"github.com/99designs/keyring"
8+
"github.com/spf13/viper"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/stripe/stripe-cli/pkg/config"
13+
)
14+
15+
func setupPluginConfigTest(t *testing.T) (*config.Config, func()) {
16+
t.Helper()
17+
18+
profilesFile := filepath.Join(t.TempDir(), "config.toml")
19+
cfg := &config.Config{
20+
Color: "auto",
21+
LogLevel: "info",
22+
ProfilesFile: profilesFile,
23+
Profile: config.Profile{ProfileName: "default"},
24+
}
25+
cfg.InitConfig()
26+
config.KeyRing = keyring.NewArrayKeyring(nil)
27+
28+
return cfg, func() {
29+
viper.Reset()
30+
}
31+
}
32+
33+
func newTestConfigCmd(cfg *config.Config) *ConfigCmd {
34+
cc := NewConfigCmd(cfg)
35+
return cc
36+
}
37+
38+
// -- global --set -----------------------------------------------------------
39+
40+
func TestGlobalSet_UpdatesOn(t *testing.T) {
41+
cfg, cleanup := setupPluginConfigTest(t)
42+
defer cleanup()
43+
44+
cc := newTestConfigCmd(cfg)
45+
cc.set = true
46+
47+
err := cc.run(cc.Cmd, []string{"updates", "on"})
48+
require.NoError(t, err)
49+
assert.Equal(t, "on", viper.GetString("plugin_configs.__global.updates"))
50+
}
51+
52+
func TestGlobalSet_UpdatesOff(t *testing.T) {
53+
cfg, cleanup := setupPluginConfigTest(t)
54+
defer cleanup()
55+
56+
cc := newTestConfigCmd(cfg)
57+
cc.set = true
58+
59+
err := cc.run(cc.Cmd, []string{"updates", "off"})
60+
require.NoError(t, err)
61+
assert.Equal(t, "off", viper.GetString("plugin_configs.__global.updates"))
62+
}
63+
64+
func TestGlobalSet_InvalidValue(t *testing.T) {
65+
cfg, cleanup := setupPluginConfigTest(t)
66+
defer cleanup()
67+
68+
cc := newTestConfigCmd(cfg)
69+
cc.set = true
70+
71+
err := cc.run(cc.Cmd, []string{"updates", "maybe"})
72+
require.Error(t, err)
73+
assert.Contains(t, err.Error(), "invalid value")
74+
}
75+
76+
func TestGlobalSet_UnknownField(t *testing.T) {
77+
cfg, cleanup := setupPluginConfigTest(t)
78+
defer cleanup()
79+
80+
cc := newTestConfigCmd(cfg)
81+
cc.set = true
82+
83+
err := cc.run(cc.Cmd, []string{"unknown_field", "on"})
84+
require.Error(t, err)
85+
assert.Contains(t, err.Error(), "unknown config field")
86+
}
87+
88+
// -- global --unset ---------------------------------------------------------
89+
90+
func TestGlobalUnset_Updates(t *testing.T) {
91+
cfg, cleanup := setupPluginConfigTest(t)
92+
defer cleanup()
93+
94+
require.NoError(t, cfg.WriteConfigField("plugin_configs.__global.updates", "off"))
95+
96+
cc := newTestConfigCmd(cfg)
97+
cc.unset = "updates"
98+
99+
err := cc.run(cc.Cmd, []string{})
100+
require.NoError(t, err)
101+
assert.False(t, viper.IsSet("plugin_configs.__global.updates"))
102+
}
103+
104+
// -- per-plugin --set -------------------------------------------------------
105+
106+
func TestPluginSet_UpdatesOff(t *testing.T) {
107+
cfg, cleanup := setupPluginConfigTest(t)
108+
defer cleanup()
109+
110+
require.NoError(t, cfg.WriteConfigField("installed_plugins", []string{"apps"}))
111+
112+
cc := newTestConfigCmd(cfg)
113+
cc.set = true
114+
115+
err := cc.run(cc.Cmd, []string{"apps", "updates", "off"})
116+
require.NoError(t, err)
117+
assert.Equal(t, "off", viper.GetString("plugin_configs.apps.updates"))
118+
}
119+
120+
func TestPluginSet_NotInstalled(t *testing.T) {
121+
cfg, cleanup := setupPluginConfigTest(t)
122+
defer cleanup()
123+
124+
cc := newTestConfigCmd(cfg)
125+
cc.set = true
126+
127+
err := cc.run(cc.Cmd, []string{"apps", "updates", "off"})
128+
require.Error(t, err)
129+
assert.Contains(t, err.Error(), "not installed")
130+
}
131+
132+
func TestPluginSet_InvalidValue(t *testing.T) {
133+
cfg, cleanup := setupPluginConfigTest(t)
134+
defer cleanup()
135+
136+
require.NoError(t, cfg.WriteConfigField("installed_plugins", []string{"apps"}))
137+
138+
cc := newTestConfigCmd(cfg)
139+
cc.set = true
140+
141+
err := cc.run(cc.Cmd, []string{"apps", "updates", "maybe"})
142+
require.Error(t, err)
143+
assert.Contains(t, err.Error(), "invalid value")
144+
}
145+
146+
// -- per-plugin --unset -----------------------------------------------------
147+
148+
func TestPluginUnset_Updates(t *testing.T) {
149+
cfg, cleanup := setupPluginConfigTest(t)
150+
defer cleanup()
151+
152+
require.NoError(t, cfg.WriteConfigField("installed_plugins", []string{"apps"}))
153+
require.NoError(t, cfg.WriteConfigField("plugin_configs.apps.updates", "off"))
154+
155+
cc := newTestConfigCmd(cfg)
156+
cc.unset = "updates"
157+
158+
err := cc.run(cc.Cmd, []string{"apps"})
159+
require.NoError(t, err)
160+
assert.False(t, viper.IsSet("plugin_configs.apps.updates"))
161+
}
162+
163+
func TestPluginUnset_NotInstalled(t *testing.T) {
164+
cfg, cleanup := setupPluginConfigTest(t)
165+
defer cleanup()
166+
167+
cc := newTestConfigCmd(cfg)
168+
cc.unset = "updates"
169+
170+
err := cc.run(cc.Cmd, []string{"apps"})
171+
require.Error(t, err)
172+
assert.Contains(t, err.Error(), "not installed")
173+
}

pkg/config/config.go

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type IConfig interface {
4444
SwitchProfile(targetProfileName string) error
4545
RemoveProfile(profileName string) error
4646
RemoveAllProfiles() error
47-
WriteConfigField(field string, value interface{}) error
47+
WriteConfigField(field string, value any) error
4848
GetInstalledPlugins() []string
4949
}
5050

@@ -227,7 +227,7 @@ func (c *Config) CopyProfile(source string, target string) error {
227227

228228
// Clone the profile map and update profile_name
229229
safeTarget := strings.ReplaceAll(target, ".", " ")
230-
existingMap := existing.(map[string]interface{})
230+
existingMap := existing.(map[string]any)
231231
newProfile := maps.Clone(existingMap)
232232
newProfile["profile_name"] = safeTarget
233233

@@ -242,7 +242,7 @@ func (c *Config) ListProfiles() error {
242242

243243
for _, value := range runtimeViper.AllSettings() {
244244
// TODO: there's probably a better way to e.g. hydrate a Profile and read from there?
245-
profile, isProfile := value.(map[string]interface{})
245+
profile, isProfile := value.(map[string]any)
246246
if isProfile {
247247
displayName, _ := profile["display_name"].(string)
248248
if !slices.Contains(profiles, displayName) {
@@ -341,7 +341,7 @@ func (c *Config) RemoveProfile(profileName string) error {
341341
if isProfile(value) {
342342
var profileNameAttr string
343343
switch v := value.(type) {
344-
case map[string]interface{}:
344+
case map[string]any:
345345
if pn, ok := v["profile_name"].(string); ok {
346346
profileNameAttr = pn
347347
}
@@ -397,9 +397,9 @@ func deleteLivemodeKey(key string, profile string) error {
397397
}
398398

399399
// isProfile identifies whether a value in the config pertains to a profile.
400-
func isProfile(value interface{}) bool {
400+
func isProfile(value any) bool {
401401
// TODO: ianjabour - ideally find a better way to identify projects in config
402-
_, ok := value.(map[string]interface{})
402+
_, ok := value.(map[string]any)
403403
if !ok {
404404
_, ok = value.(map[string]string)
405405
}
@@ -409,13 +409,24 @@ func isProfile(value interface{}) bool {
409409

410410
// WriteConfigField updates a configuration field and writes the updated
411411
// configuration to disk.
412-
func (c *Config) WriteConfigField(field string, value interface{}) error {
412+
func (c *Config) WriteConfigField(field string, value any) error {
413413
runtimeViper := viper.GetViper()
414414
runtimeViper.Set(field, value)
415415

416416
return runtimeViper.WriteConfig()
417417
}
418418

419+
// DeleteConfigField removes a top-level (non-profile-scoped) configuration
420+
// field and writes the updated configuration to disk.
421+
func (c *Config) DeleteConfigField(field string) error {
422+
v, err := removeKey(viper.GetViper(), field)
423+
if err != nil {
424+
return err
425+
}
426+
427+
return writeConfig(v)
428+
}
429+
419430
// writeConfig writes a viper instance to the config file and syncs the global viper.
420431
func writeConfig(runtimeViper *viper.Viper) error {
421432
profilesFile := viper.ConfigFileUsed()
@@ -479,24 +490,24 @@ func makePath(path string) error {
479490

480491
// taken from https://github.com/spf13/viper/blob/master/util.go#L199,
481492
// we need this to delete configs, remove when viper supprts unset natively
482-
func deepSearch(m map[string]interface{}, path []string) map[string]interface{} {
493+
func deepSearch(m map[string]any, path []string) map[string]any {
483494
for _, k := range path {
484495
m2, ok := m[k]
485496
if !ok {
486497
// intermediate key does not exist
487498
// => create it and continue from there
488-
m3 := make(map[string]interface{})
499+
m3 := make(map[string]any)
489500
m[k] = m3
490501
m = m3
491502

492503
continue
493504
}
494505

495-
m3, ok := m2.(map[string]interface{})
506+
m3, ok := m2.(map[string]any)
496507
if !ok {
497508
// intermediate key is a value
498509
// => replace with a new map
499-
m3 = make(map[string]interface{})
510+
m3 = make(map[string]any)
500511
m[k] = m3
501512
}
502513

0 commit comments

Comments
 (0)