Skip to content

Commit b074ef0

Browse files
Add plugin install hints (#1515)
* Add plugin hints * Refactor and remove the access doc link * Update wording
1 parent 42d6b05 commit b074ef0

File tree

3 files changed

+383
-0
lines changed

3 files changed

+383
-0
lines changed

pkg/cmd/pluginhints/pluginhints.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Package pluginhints provides placeholder Cobra commands for known plugins
2+
// that are not yet installed, guiding users to install or request access.
3+
package pluginhints
4+
5+
import (
6+
"context"
7+
"fmt"
8+
"io"
9+
"os"
10+
11+
"github.com/spf13/afero"
12+
"github.com/spf13/cobra"
13+
14+
"github.com/stripe/stripe-cli/pkg/ansi"
15+
"github.com/stripe/stripe-cli/pkg/config"
16+
"github.com/stripe/stripe-cli/pkg/open"
17+
"github.com/stripe/stripe-cli/pkg/plugins"
18+
"github.com/stripe/stripe-cli/pkg/stripe"
19+
)
20+
21+
// AddHintCommands registers a hint command for each known plugin that is not
22+
// present in installedPluginSet.
23+
func AddHintCommands(rootCmd *cobra.Command, cfg *config.Config, installedPluginSet map[string]bool) {
24+
if !installedPluginSet["apps"] {
25+
rootCmd.AddCommand(
26+
newPluginHintCmd(cfg, "apps", "This plugin lets you build and manage Stripe Apps.").Command,
27+
)
28+
}
29+
if !installedPluginSet["generate"] {
30+
rootCmd.AddCommand(
31+
newPluginHintCmd(cfg, "generate", "This plugin creates skeleton files to get you started.", withPrivatePreview()).Command,
32+
)
33+
}
34+
if !installedPluginSet["projects"] {
35+
rootCmd.AddCommand(
36+
newPluginHintCmd(cfg, "projects", "This plugin scaffolds and manages Stripe integration projects.").Command,
37+
)
38+
}
39+
}
40+
41+
// pluginHintCmd is a placeholder Cobra command registered when a known plugin
42+
// is not installed. It either prompts the user to install the plugin (if
43+
// available) or explains that their account doesn't have access yet.
44+
type pluginHintCmd struct {
45+
*cobra.Command
46+
name string
47+
description string
48+
privatePreview bool
49+
50+
lookupFn func(ctx context.Context) error
51+
installFn func(ctx context.Context) error
52+
accountIDFn func() (string, error)
53+
openBrowserFn func(url string) error
54+
stdin io.Reader
55+
stdout io.Writer
56+
}
57+
58+
type option func(*pluginHintCmd)
59+
60+
func withPrivatePreview() option {
61+
return func(p *pluginHintCmd) {
62+
p.privatePreview = true
63+
}
64+
}
65+
66+
func newPluginHintCmd(cfg *config.Config, name, description string, opts ...option) *pluginHintCmd {
67+
fs := afero.NewOsFs()
68+
69+
p := &pluginHintCmd{
70+
name: name,
71+
description: description,
72+
lookupFn: func(ctx context.Context) error {
73+
if err := plugins.RefreshPluginManifest(ctx, cfg, fs, stripe.DefaultAPIBaseURL); err != nil {
74+
return err
75+
}
76+
_, err := plugins.LookUpPlugin(ctx, cfg, fs, name)
77+
return err
78+
},
79+
installFn: func(ctx context.Context) error {
80+
plugin, err := plugins.LookUpPlugin(ctx, cfg, fs, name)
81+
if err != nil {
82+
return err
83+
}
84+
version := plugin.LookUpLatestVersion()
85+
return plugin.Install(ctx, cfg, fs, version, stripe.DefaultAPIBaseURL)
86+
},
87+
accountIDFn: cfg.GetProfile().GetAccountID,
88+
openBrowserFn: open.Browser,
89+
stdin: os.Stdin,
90+
stdout: os.Stdout,
91+
}
92+
93+
for _, opt := range opts {
94+
opt(p)
95+
}
96+
97+
p.Command = &cobra.Command{
98+
Use: name,
99+
Hidden: true,
100+
// Accept unknown flags/args so they aren't rejected before we can show the hint
101+
FParseErrWhitelist: cobra.FParseErrWhitelist{UnknownFlags: true},
102+
RunE: p.run,
103+
}
104+
105+
return p
106+
}
107+
108+
func (p *pluginHintCmd) run(cmd *cobra.Command, args []string) error {
109+
ctx := cmd.Context()
110+
if ctx == nil {
111+
ctx = context.Background()
112+
}
113+
114+
if err := p.lookupFn(ctx); err == nil {
115+
return p.promptInstall(ctx)
116+
}
117+
118+
if p.privatePreview {
119+
return p.suggestNotAvailable()
120+
}
121+
122+
return nil
123+
}
124+
125+
func (p *pluginHintCmd) promptInstall(ctx context.Context) error {
126+
fmt.Fprintf(p.stdout, "The \"%s\" plugin is required to run this command.\n", p.name)
127+
fmt.Fprintf(p.stdout, "\n")
128+
fmt.Fprintf(p.stdout, "%s\n", p.description)
129+
fmt.Fprintf(p.stdout, "You can run 'stripe plugin install %s' or press Enter to install", p.name)
130+
131+
var input string
132+
fmt.Fscanln(p.stdin, &input)
133+
134+
if input != "" {
135+
return fmt.Errorf("installation canceled")
136+
}
137+
138+
if err := p.installFn(ctx); err != nil {
139+
return err
140+
}
141+
142+
color := ansi.Color(p.stdout)
143+
fmt.Fprintln(p.stdout, color.Green("✔ installation complete."))
144+
145+
return nil
146+
}
147+
148+
func (p *pluginHintCmd) suggestNotAvailable() error {
149+
accountID, err := p.accountIDFn()
150+
151+
if err != nil || accountID == "" {
152+
fmt.Fprintf(p.stdout, "The '%s' plugin is in private preview. Run 'stripe login' to verify your account has access.\n", p.name)
153+
os.Exit(1)
154+
return nil
155+
}
156+
157+
fmt.Fprintf(p.stdout, "The logged-in account %s does not have access to the private preview 'generate' plugin. Log into a different account with 'stripe login', or contact Stripe support.\n", accountID)
158+
fmt.Fprintf(p.stdout, "\n")
159+
fmt.Fprintf(p.stdout, "%s\n", p.description)
160+
os.Exit(1)
161+
162+
return nil
163+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package pluginhints
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"errors"
7+
"os"
8+
"os/exec"
9+
"strings"
10+
"testing"
11+
12+
"github.com/spf13/cobra"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// newTestCmd builds a pluginHintCmd with all side effects mocked out.
18+
func newTestCmd(name string, opts ...option) *pluginHintCmd {
19+
p := &pluginHintCmd{
20+
name: name,
21+
description: "Test description.",
22+
stdout: &bytes.Buffer{},
23+
stdin: strings.NewReader(""),
24+
}
25+
for _, opt := range opts {
26+
opt(p)
27+
}
28+
p.Command = &cobra.Command{Use: name, RunE: p.run}
29+
return p
30+
}
31+
32+
func (p *pluginHintCmd) output() string {
33+
return p.stdout.(*bytes.Buffer).String()
34+
}
35+
36+
// --- run ---
37+
38+
func TestRun_PluginFound_CallsPromptInstall(t *testing.T) {
39+
p := newTestCmd("generate", withPrivatePreview())
40+
installCalled := false
41+
p.lookupFn = func(ctx context.Context) error { return nil }
42+
p.installFn = func(ctx context.Context) error { installCalled = true; return nil }
43+
44+
err := p.run(p.Command, nil)
45+
46+
require.NoError(t, err)
47+
assert.True(t, installCalled)
48+
assert.Contains(t, p.output(), "The \"generate\" plugin is required")
49+
}
50+
51+
func TestRun_PluginNotFound_PrivatePreviewFalse_ReturnsNil(t *testing.T) {
52+
p := newTestCmd("apps")
53+
p.lookupFn = func(ctx context.Context) error { return errors.New("not found") }
54+
55+
err := p.run(p.Command, nil)
56+
57+
require.NoError(t, err)
58+
assert.Empty(t, p.output())
59+
}
60+
61+
func TestRun_PluginNotFound_PrivatePreviewTrue_ExitsWithOne(t *testing.T) {
62+
// Subprocess path: run the code that calls os.Exit(1).
63+
if os.Getenv("TEST_SUBPROCESS") == "1" {
64+
p := &pluginHintCmd{
65+
name: "generate",
66+
description: "Test description.",
67+
privatePreview: true,
68+
stdout: os.Stdout,
69+
stdin: strings.NewReader(""),
70+
}
71+
p.Command = &cobra.Command{Use: "generate", RunE: p.run}
72+
p.lookupFn = func(ctx context.Context) error { return errors.New("not found") }
73+
p.accountIDFn = func() (string, error) { return "acct_123", nil }
74+
p.run(p.Command, nil) //nolint:errcheck
75+
return
76+
}
77+
78+
var stdout bytes.Buffer
79+
cmd := exec.Command(os.Args[0], "-test.run=TestRun_PluginNotFound_PrivatePreviewTrue_ExitsWithOne")
80+
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1")
81+
cmd.Stdout = &stdout
82+
83+
err := cmd.Run()
84+
85+
var exitErr *exec.ExitError
86+
require.ErrorAs(t, err, &exitErr)
87+
assert.Equal(t, 1, exitErr.ExitCode())
88+
assert.Contains(t, stdout.String(), "private preview")
89+
assert.Contains(t, stdout.String(), "acct_123")
90+
}
91+
92+
// --- promptInstall ---
93+
94+
func TestPromptInstall_EnterKey_InstallsPlugin(t *testing.T) {
95+
p := newTestCmd("generate", withPrivatePreview())
96+
p.stdin = strings.NewReader("\n")
97+
installCalled := false
98+
p.installFn = func(ctx context.Context) error { installCalled = true; return nil }
99+
100+
err := p.promptInstall(context.Background())
101+
102+
require.NoError(t, err)
103+
assert.True(t, installCalled)
104+
assert.Contains(t, p.output(), "installation complete")
105+
}
106+
107+
func TestPromptInstall_OtherInput_CancelsInstall(t *testing.T) {
108+
p := newTestCmd("generate", withPrivatePreview())
109+
p.stdin = strings.NewReader("n\n")
110+
installCalled := false
111+
p.installFn = func(ctx context.Context) error { installCalled = true; return nil }
112+
113+
err := p.promptInstall(context.Background())
114+
115+
assert.EqualError(t, err, "installation canceled")
116+
assert.False(t, installCalled)
117+
}
118+
119+
func TestPromptInstall_InstallError_ReturnsError(t *testing.T) {
120+
p := newTestCmd("generate", withPrivatePreview())
121+
p.stdin = strings.NewReader("\n")
122+
p.installFn = func(ctx context.Context) error { return errors.New("install failed") }
123+
124+
err := p.promptInstall(context.Background())
125+
126+
assert.EqualError(t, err, "install failed")
127+
}
128+
129+
// --- suggestNotAvailable ---
130+
131+
func TestSuggestNotAvailable_NoAccountID_ExitsWithOne(t *testing.T) {
132+
if os.Getenv("TEST_SUBPROCESS") == "1" {
133+
p := &pluginHintCmd{
134+
name: "generate",
135+
description: "Test description.",
136+
privatePreview: true,
137+
stdout: os.Stdout,
138+
stdin: strings.NewReader(""),
139+
}
140+
p.Command = &cobra.Command{Use: "generate", RunE: p.run}
141+
p.accountIDFn = func() (string, error) { return "", nil }
142+
p.suggestNotAvailable() //nolint:errcheck
143+
return
144+
}
145+
146+
var stdout bytes.Buffer
147+
cmd := exec.Command(os.Args[0], "-test.run=TestSuggestNotAvailable_NoAccountID_ExitsWithOne")
148+
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1")
149+
cmd.Stdout = &stdout
150+
151+
err := cmd.Run()
152+
153+
var exitErr *exec.ExitError
154+
require.ErrorAs(t, err, &exitErr)
155+
assert.Equal(t, 1, exitErr.ExitCode())
156+
assert.Contains(t, stdout.String(), "stripe login")
157+
}
158+
159+
func TestSuggestNotAvailable_AccountIDError_ExitsWithOne(t *testing.T) {
160+
if os.Getenv("TEST_SUBPROCESS") == "1" {
161+
p := &pluginHintCmd{
162+
name: "generate",
163+
description: "Test description.",
164+
privatePreview: true,
165+
stdout: os.Stdout,
166+
stdin: strings.NewReader(""),
167+
}
168+
p.Command = &cobra.Command{Use: "generate", RunE: p.run}
169+
p.accountIDFn = func() (string, error) { return "", errors.New("not configured") }
170+
p.suggestNotAvailable() //nolint:errcheck
171+
return
172+
}
173+
174+
var stdout bytes.Buffer
175+
cmd := exec.Command(os.Args[0], "-test.run=TestSuggestNotAvailable_AccountIDError_ExitsWithOne")
176+
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1")
177+
cmd.Stdout = &stdout
178+
179+
err := cmd.Run()
180+
181+
var exitErr *exec.ExitError
182+
require.ErrorAs(t, err, &exitErr)
183+
assert.Equal(t, 1, exitErr.ExitCode())
184+
assert.Contains(t, stdout.String(), "stripe login")
185+
}
186+
187+
func TestSuggestNotAvailable_ShowsAccountID_ExitsWithOne(t *testing.T) {
188+
if os.Getenv("TEST_SUBPROCESS") == "1" {
189+
p := &pluginHintCmd{
190+
name: "generate",
191+
description: "Test description.",
192+
privatePreview: true,
193+
stdout: os.Stdout,
194+
stdin: strings.NewReader(""),
195+
}
196+
p.Command = &cobra.Command{Use: "generate", RunE: p.run}
197+
p.accountIDFn = func() (string, error) { return "acct_abc456", nil }
198+
p.suggestNotAvailable() //nolint:errcheck
199+
return
200+
}
201+
202+
var stdout bytes.Buffer
203+
cmd := exec.Command(os.Args[0], "-test.run=TestSuggestNotAvailable_ShowsAccountID_ExitsWithOne")
204+
cmd.Env = append(os.Environ(), "TEST_SUBPROCESS=1")
205+
cmd.Stdout = &stdout
206+
207+
err := cmd.Run()
208+
209+
var exitErr *exec.ExitError
210+
require.ErrorAs(t, err, &exitErr)
211+
assert.Equal(t, 1, exitErr.ExitCode())
212+
assert.Contains(t, stdout.String(), "acct_abc456")
213+
}

0 commit comments

Comments
 (0)