Skip to content

Commit 449ea38

Browse files
Add automatic plugin updates
Plugins can now be automatically updated in the background while the plugin runs. After the plugin exits, any update output is flushed to stderr so it never interleaves with plugin output. - Add `WithBackgroundUpdate` to run a plugin with concurrent update checking and installation - Add `GatedWriter` to buffer output until the gate is opened - Extend `isTerminal` in ansi to support any writer with an `Fd()` method, so the spinner works correctly with `GatedWriter` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Committed-By-Agent: claude
1 parent 63b0029 commit 449ea38

9 files changed

Lines changed: 875 additions & 19 deletions

File tree

pkg/ansi/ansi.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,13 +186,13 @@ func StrikeThrough(text string) string {
186186
// Private functions
187187
//
188188

189+
type fder interface{ Fd() uintptr }
190+
189191
func isTerminal(w io.Writer) bool {
190-
switch v := w.(type) {
191-
case *os.File:
192-
return term.IsTerminal(int(v.Fd()))
193-
default:
194-
return false
192+
if f, ok := w.(fder); ok {
193+
return term.IsTerminal(int(f.Fd()))
195194
}
195+
return false
196196
}
197197

198198
func isPlugin() bool {

pkg/cmd/plugin_cmds.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ import (
1212

1313
"github.com/stripe/stripe-cli/pkg/config"
1414
"github.com/stripe/stripe-cli/pkg/plugins"
15+
"github.com/stripe/stripe-cli/pkg/stripe"
1516
"github.com/stripe/stripe-cli/pkg/validators"
1617
)
1718

1819
type pluginTemplateCmd struct {
1920
cfg *config.Config
2021
cmd *cobra.Command
2122
fs afero.Fs
23+
baseURL string
2224
ParsedArgs []string
2325
}
2426

@@ -28,6 +30,7 @@ func newPluginTemplateCmd(config *config.Config, plugin *plugins.Plugin) *plugin
2830
ptc := &pluginTemplateCmd{}
2931
ptc.fs = afero.NewOsFs()
3032
ptc.cfg = config
33+
ptc.baseURL = stripe.DefaultAPIBaseURL
3134

3235
ptc.cmd = &cobra.Command{
3336
Use: plugin.Shortname,
@@ -110,8 +113,9 @@ func (ptc *pluginTemplateCmd) runPluginCmd(cmd *cobra.Command, args []string) er
110113
"prefix": "cmd.pluginCmd.runPluginCmd",
111114
}).Debug("Running plugin...")
112115

113-
err = plugin.Run(ctx, ptc.cfg, fs, ptc.ParsedArgs)
114-
plugins.CleanupAllClients()
116+
err = plugins.WithBackgroundUpdate(ctx, ptc.cfg, fs, ptc.baseURL, &plugin, os.Stderr, func() error {
117+
return plugin.Run(ctx, ptc.cfg, fs, ptc.ParsedArgs)
118+
})
115119

116120
if err != nil {
117121
if err == validators.ErrAPIKeyNotConfigured {

pkg/cmd/plugin_cmds_test.go

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
package cmd
22

33
import (
4+
"bytes"
45
"context"
6+
"errors"
7+
"io"
58
"os"
69
"strings"
10+
"sync"
711
"testing"
812

13+
"github.com/spf13/afero"
914
"github.com/stretchr/testify/assert"
1015
"github.com/stretchr/testify/require"
1116

17+
"github.com/stripe/stripe-cli/pkg/config"
1218
"github.com/stripe/stripe-cli/pkg/plugins"
1319
)
1420

15-
func createPluginCmd() *pluginTemplateCmd {
21+
func createPluginCmd(cfg *config.Config) *pluginTemplateCmd {
1622
plugin := plugins.Plugin{
1723
Shortname: "test",
1824
Shortdesc: "test your stuff",
@@ -26,7 +32,7 @@ func createPluginCmd() *pluginTemplateCmd {
2632
}},
2733
}
2834

29-
pluginCmd := newPluginTemplateCmd(&Config, &plugin)
35+
pluginCmd := newPluginTemplateCmd(cfg, &plugin)
3036

3137
return pluginCmd
3238
}
@@ -35,7 +41,8 @@ func createPluginCmd() *pluginTemplateCmd {
3541
// This is a complex dance between the CLI itself and the plugin, so the flags come from
3642
// two different sources as a result. This test is here to catch any non-obvious regressions
3743
func TestFlagsArePassedAsArgs(t *testing.T) {
38-
pluginCmd := createPluginCmd()
44+
cfg := &config.Config{}
45+
pluginCmd := createPluginCmd(cfg)
3946
rootCmd.AddCommand(pluginCmd.cmd)
4047

4148
Execute(context.Background())
@@ -133,6 +140,105 @@ func TestAddPluginSubcommandStubsSkipsEmptyName(t *testing.T) {
133140
assert.Equal(t, "valid", cmds[1].Name())
134141
}
135142

143+
// TestWithBackgroundUpdate_FnRuns verifies that fn is invoked and completes.
144+
func TestWithBackgroundUpdate_FnRuns(t *testing.T) {
145+
cfg := &config.Config{}
146+
fs := afero.NewMemMapFs()
147+
plugin := plugins.Plugin{Shortname: "test"}
148+
149+
called := false
150+
err := plugins.WithBackgroundUpdate(context.Background(), cfg, fs, "", &plugin, io.Discard, func() error {
151+
called = true
152+
return nil
153+
})
154+
155+
assert.NoError(t, err)
156+
assert.True(t, called)
157+
}
158+
159+
// TestWithBackgroundUpdate_PropagatesError verifies that an error returned by fn
160+
// is returned by WithBackgroundUpdate.
161+
func TestWithBackgroundUpdate_PropagatesError(t *testing.T) {
162+
cfg := &config.Config{}
163+
fs := afero.NewMemMapFs()
164+
plugin := plugins.Plugin{Shortname: "test"}
165+
166+
want := errors.New("plugin failed")
167+
err := plugins.WithBackgroundUpdate(context.Background(), cfg, fs, "", &plugin, io.Discard, func() error {
168+
return want
169+
})
170+
171+
assert.Equal(t, want, err)
172+
}
173+
174+
// TestWithBackgroundUpdate_UpdateOutputAppearsAfterFn verifies that any output
175+
// written by the background update goroutine is only flushed to the underlying
176+
// writer after fn returns — never interleaved with fn's execution.
177+
func TestWithBackgroundUpdate_UpdateOutputAppearsAfterFn(t *testing.T) {
178+
cfg := &config.Config{}
179+
fs := afero.NewMemMapFs()
180+
plugin := plugins.Plugin{Shortname: "test"}
181+
182+
var mu sync.Mutex
183+
var events []string
184+
185+
// recordWriter appends each Write as an event.
186+
out := &funcWriter{fn: func(p []byte) (int, error) {
187+
mu.Lock()
188+
events = append(events, "write:"+string(p))
189+
mu.Unlock()
190+
return len(p), nil
191+
}}
192+
193+
err := plugins.WithBackgroundUpdate(context.Background(), cfg, fs, "", &plugin, out, func() error {
194+
mu.Lock()
195+
events = append(events, "fn:done")
196+
mu.Unlock()
197+
return nil
198+
})
199+
200+
require.NoError(t, err)
201+
202+
mu.Lock()
203+
defer mu.Unlock()
204+
// If there were any writes (update output), they must come after fn:done.
205+
fnIdx := -1
206+
for i, e := range events {
207+
if e == "fn:done" {
208+
fnIdx = i
209+
}
210+
}
211+
// fn must have run
212+
require.GreaterOrEqual(t, fnIdx, 0, "fn:done not recorded")
213+
for i, e := range events {
214+
if strings.HasPrefix(e, "write:") {
215+
assert.Greater(t, i, fnIdx, "update write at index %d appeared before fn:done at index %d", i, fnIdx)
216+
}
217+
}
218+
}
219+
220+
// TestWithBackgroundUpdate_NilErrorOnSuccess verifies a nil error on clean fn exit.
221+
func TestWithBackgroundUpdate_NilErrorOnSuccess(t *testing.T) {
222+
cfg := &config.Config{}
223+
fs := afero.NewMemMapFs()
224+
plugin := plugins.Plugin{Shortname: "test"}
225+
226+
// Provide a non-nil out to ensure the writer path is exercised.
227+
var buf bytes.Buffer
228+
err := plugins.WithBackgroundUpdate(context.Background(), cfg, fs, "", &plugin, &buf, func() error {
229+
return nil
230+
})
231+
232+
assert.NoError(t, err)
233+
}
234+
235+
// funcWriter is a minimal io.Writer backed by a function, used in tests.
236+
type funcWriter struct {
237+
fn func([]byte) (int, error)
238+
}
239+
240+
func (f *funcWriter) Write(p []byte) (int, error) { return f.fn(p) }
241+
136242
func TestSubsliceAfter(t *testing.T) {
137243
tests := []struct {
138244
name string

pkg/gatedwriter/gatedwriter.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Package gatedwriter provides a buffered writer that can be opened and closed
2+
// to control the flow of data to the underlying writer.
3+
package gatedwriter
4+
5+
import (
6+
"bytes"
7+
"io"
8+
"os"
9+
"sync"
10+
)
11+
12+
// GatedWriter is an io.Writer that buffers writes until opened. Once Open is
13+
// called, buffered data is flushed and subsequent writes go directly to the
14+
// underlying writer. Closing the gate resumes buffering. Safe for concurrent use.
15+
type GatedWriter struct {
16+
mu sync.Mutex
17+
out io.Writer // underlying destination (must be non-nil)
18+
buf bytes.Buffer // buffered data while closed
19+
open bool // when true, writes go directly to out
20+
maxBuffer int64 // maximum number of bytes to buffer; 0 = defaultMaxBuffer
21+
}
22+
23+
var _ io.Writer = (*GatedWriter)(nil)
24+
25+
// defaultMaxBuffer is the maximum bytes to buffer while the gate is closed (100 KB).
26+
// Writes that would exceed this limit are dropped silently.
27+
const defaultMaxBuffer = 100 * 1024
28+
29+
// NewGatedWriter creates a closed GatedWriter that writes to out once opened.
30+
// maxBuffer caps the in-memory buffer in bytes; 0 uses defaultMaxBuffer.
31+
// If out is nil, io.Discard is used.
32+
func NewGatedWriter(out io.Writer, maxBuffer int64) *GatedWriter {
33+
if out == nil {
34+
out = io.Discard
35+
}
36+
if maxBuffer == 0 {
37+
maxBuffer = defaultMaxBuffer
38+
}
39+
return &GatedWriter{out: out, maxBuffer: maxBuffer}
40+
}
41+
42+
// Write buffers p while the gate is closed. Once open, it writes directly to
43+
// the underlying writer. Writes that would push the buffer past maxBuffer are
44+
// dropped silently.
45+
func (g *GatedWriter) Write(p []byte) (int, error) {
46+
g.mu.Lock()
47+
defer g.mu.Unlock()
48+
if g.open {
49+
return g.out.Write(p)
50+
}
51+
if g.maxBuffer > 0 && int64(g.buf.Len()+len(p)) > g.maxBuffer {
52+
return len(p), nil // drop silently
53+
}
54+
n, _ := g.buf.Write(p) // bytes.Buffer.Write never returns error
55+
return n, nil
56+
}
57+
58+
// Open flushes any buffered data to the underlying writer and opens the gate
59+
// so future writes go directly to out. Calling Open on an already-open writer
60+
// is a no-op.
61+
func (g *GatedWriter) Open() error {
62+
g.mu.Lock()
63+
defer g.mu.Unlock()
64+
if g.open {
65+
return nil
66+
}
67+
data := g.buf.Bytes()
68+
g.buf.Reset()
69+
g.open = true
70+
if len(data) > 0 {
71+
_, err := g.out.Write(data)
72+
if err != nil {
73+
return err
74+
}
75+
}
76+
return nil
77+
}
78+
79+
// Close closes the gate, causing subsequent writes to be buffered again.
80+
func (g *GatedWriter) Close() {
81+
g.mu.Lock()
82+
g.open = false
83+
g.mu.Unlock()
84+
}
85+
86+
// Fd returns the file descriptor of the underlying writer if it is an *os.File,
87+
// allowing callers to check whether the destination is a terminal. Returns
88+
// ^uintptr(0) if the underlying writer is not an *os.File.
89+
func (g *GatedWriter) Fd() uintptr {
90+
g.mu.Lock()
91+
out := g.out
92+
g.mu.Unlock()
93+
if f, ok := out.(*os.File); ok {
94+
return f.Fd()
95+
}
96+
return ^uintptr(0) // invalid fd
97+
}
98+
99+
// SetOut replaces the underlying writer. If w is nil, io.Discard is used.
100+
// Takes effect immediately for open writes; buffered data is still flushed
101+
// to the new writer when Open is called.
102+
func (g *GatedWriter) SetOut(w io.Writer) {
103+
if w == nil {
104+
w = io.Discard
105+
}
106+
g.mu.Lock()
107+
g.out = w
108+
g.mu.Unlock()
109+
}

0 commit comments

Comments
 (0)