Skip to content

Commit 54869b4

Browse files
Implement "stripe plugin config" command
1 parent 9be32db commit 54869b4

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
@@ -46,7 +46,7 @@ type IConfig interface {
4646
RemoveAllProfiles() error
4747
RemoveAuthFields(profileName string) error
4848
RemoveAllAuthFields() error
49-
WriteConfigField(field string, value interface{}) error
49+
WriteConfigField(field string, value any) error
5050
GetInstalledPlugins() []string
5151
}
5252

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

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

@@ -244,7 +244,7 @@ func (c *Config) ListProfiles() error {
244244

245245
for _, value := range runtimeViper.AllSettings() {
246246
// TODO: there's probably a better way to e.g. hydrate a Profile and read from there?
247-
profile, isProfile := value.(map[string]interface{})
247+
profile, isProfile := value.(map[string]any)
248248
if isProfile {
249249
displayName, _ := profile["display_name"].(string)
250250
if !slices.Contains(profiles, displayName) {
@@ -343,7 +343,7 @@ func (c *Config) RemoveProfile(profileName string) error {
343343
if isProfile(value) {
344344
var profileNameAttr string
345345
switch v := value.(type) {
346-
case map[string]interface{}:
346+
case map[string]any:
347347
if pn, ok := v["profile_name"].(string); ok {
348348
profileNameAttr = pn
349349
}
@@ -442,9 +442,9 @@ func deleteLivemodeKey(key string, profile string) error {
442442
}
443443

444444
// isProfile identifies whether a value in the config pertains to a profile.
445-
func isProfile(value interface{}) bool {
445+
func isProfile(value any) bool {
446446
// TODO: ianjabour - ideally find a better way to identify projects in config
447-
_, ok := value.(map[string]interface{})
447+
_, ok := value.(map[string]any)
448448
if !ok {
449449
_, ok = value.(map[string]string)
450450
}
@@ -454,13 +454,24 @@ func isProfile(value interface{}) bool {
454454

455455
// WriteConfigField updates a configuration field and writes the updated
456456
// configuration to disk.
457-
func (c *Config) WriteConfigField(field string, value interface{}) error {
457+
func (c *Config) WriteConfigField(field string, value any) error {
458458
runtimeViper := viper.GetViper()
459459
runtimeViper.Set(field, value)
460460

461461
return runtimeViper.WriteConfig()
462462
}
463463

464+
// DeleteConfigField removes a top-level (non-profile-scoped) configuration
465+
// field and writes the updated configuration to disk.
466+
func (c *Config) DeleteConfigField(field string) error {
467+
v, err := removeKey(viper.GetViper(), field)
468+
if err != nil {
469+
return err
470+
}
471+
472+
return writeConfig(v)
473+
}
474+
464475
// writeConfig writes a viper instance to the config file and syncs the global viper.
465476
func writeConfig(runtimeViper *viper.Viper) error {
466477
profilesFile := viper.ConfigFileUsed()
@@ -524,24 +535,24 @@ func makePath(path string) error {
524535

525536
// taken from https://github.com/spf13/viper/blob/master/util.go#L199,
526537
// we need this to delete configs, remove when viper supprts unset natively
527-
func deepSearch(m map[string]interface{}, path []string) map[string]interface{} {
538+
func deepSearch(m map[string]any, path []string) map[string]any {
528539
for _, k := range path {
529540
m2, ok := m[k]
530541
if !ok {
531542
// intermediate key does not exist
532543
// => create it and continue from there
533-
m3 := make(map[string]interface{})
544+
m3 := make(map[string]any)
534545
m[k] = m3
535546
m = m3
536547

537548
continue
538549
}
539550

540-
m3, ok := m2.(map[string]interface{})
551+
m3, ok := m2.(map[string]any)
541552
if !ok {
542553
// intermediate key is a value
543554
// => replace with a new map
544-
m3 = make(map[string]interface{})
555+
m3 = make(map[string]any)
545556
m[k] = m3
546557
}
547558

0 commit comments

Comments
 (0)