Skip to content

Commit b97cdeb

Browse files
authored
Add update notification prompt on command start (#136)
1 parent 2cad96e commit b97cdeb

8 files changed

Lines changed: 504 additions & 11 deletions

File tree

cmd/root.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/localstack/lstk/internal/runtime"
1818
"github.com/localstack/lstk/internal/telemetry"
1919
"github.com/localstack/lstk/internal/ui"
20+
"github.com/localstack/lstk/internal/update"
2021
"github.com/localstack/lstk/internal/version"
2122
"github.com/spf13/cobra"
2223
"github.com/spf13/pflag"
@@ -119,10 +120,19 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
119120
Telemetry: tel,
120121
}
121122

123+
notifyOpts := update.NotifyOptions{
124+
GitHubToken: cfg.GitHubToken,
125+
UpdatePrompt: appConfig.UpdatePrompt,
126+
PersistDisable: config.DisableUpdatePrompt,
127+
}
128+
122129
if isInteractiveMode(cfg) {
123-
return ui.Run(ctx, rt, version.Version(), opts)
130+
return ui.Run(ctx, rt, version.Version(), opts, notifyOpts)
124131
}
125-
return container.Start(ctx, rt, output.NewPlainSink(os.Stdout), opts, false)
132+
133+
sink := output.NewPlainSink(os.Stdout)
134+
update.NotifyUpdate(ctx, sink, update.NotifyOptions{GitHubToken: cfg.GitHubToken})
135+
return container.Start(ctx, rt, sink, opts, false)
126136
}
127137

128138
func runStart(ctx context.Context, cmdFlags *pflag.FlagSet, rt runtime.Runtime, cfg *env.Env, tel *telemetry.Client, logger log.Logger) error {

internal/config/config.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import (
1414
var defaultConfigTemplate string
1515

1616
type Config struct {
17-
Containers []ContainerConfig `mapstructure:"containers"`
18-
Env map[string]map[string]string `mapstructure:"env"`
17+
Containers []ContainerConfig `mapstructure:"containers"`
18+
Env map[string]map[string]string `mapstructure:"env"`
19+
UpdatePrompt bool `mapstructure:"update_prompt"`
1920
}
2021

2122
func setDefaults() {
@@ -26,6 +27,7 @@ func setDefaults() {
2627
"port": "4566",
2728
},
2829
})
30+
viper.SetDefault("update_prompt", true)
2931
}
3032

3133
func loadConfig(path string) error {
@@ -90,6 +92,15 @@ func resolvedConfigPath() string {
9092
return viper.ConfigFileUsed()
9193
}
9294

95+
func Set(key string, value any) error {
96+
viper.Set(key, value)
97+
return viper.WriteConfig()
98+
}
99+
100+
func DisableUpdatePrompt() error {
101+
return Set("update_prompt", false)
102+
}
103+
93104
func Get() (*Config, error) {
94105
var cfg Config
95106
if err := viper.Unmarshal(&cfg); err != nil {

internal/ui/run.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/localstack/lstk/internal/endpoint"
1111
"github.com/localstack/lstk/internal/output"
1212
"github.com/localstack/lstk/internal/runtime"
13+
"github.com/localstack/lstk/internal/update"
1314
"golang.org/x/term"
1415
)
1516

@@ -24,7 +25,7 @@ func (s programSender) Send(msg any) {
2425
s.p.Send(msg)
2526
}
2627

27-
func Run(parentCtx context.Context, rt runtime.Runtime, version string, opts container.StartOptions) error {
28+
func Run(parentCtx context.Context, rt runtime.Runtime, version string, opts container.StartOptions, notifyOpts update.NotifyOptions) error {
2829
ctx, cancel := context.WithCancel(parentCtx)
2930
defer cancel()
3031

@@ -45,7 +46,12 @@ func Run(parentCtx context.Context, rt runtime.Runtime, version string, opts con
4546
go func() {
4647
var err error
4748
defer func() { runErrCh <- err }()
48-
err = container.Start(ctx, rt, output.NewTUISink(programSender{p: p}), opts, true)
49+
sink := output.NewTUISink(programSender{p: p})
50+
if update.NotifyUpdate(ctx, sink, notifyOpts) {
51+
p.Send(runDoneMsg{})
52+
return
53+
}
54+
err = container.Start(ctx, rt, sink, opts, true)
4955
if err != nil {
5056
if errors.Is(err, context.Canceled) {
5157
return

internal/update/github.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@ import (
1111
goruntime "runtime"
1212
)
1313

14-
const (
15-
githubRepo = "localstack/lstk"
16-
latestReleaseURL = "https://api.github.com/repos/" + githubRepo + "/releases/latest"
17-
)
14+
const githubRepo = "localstack/lstk"
15+
16+
const latestReleaseURL = "https://api.github.com/repos/" + githubRepo + "/releases/latest"
1817

1918
type githubRelease struct {
2019
TagName string `json:"tag_name"`

internal/update/notify.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package update
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/localstack/lstk/internal/output"
9+
"github.com/localstack/lstk/internal/version"
10+
)
11+
12+
type versionFetcher func(ctx context.Context, token string) (string, error)
13+
14+
type NotifyOptions struct {
15+
GitHubToken string
16+
UpdatePrompt bool
17+
PersistDisable func() error
18+
}
19+
20+
const checkTimeout = 500 * time.Millisecond
21+
22+
func CheckQuietly(ctx context.Context, githubToken string) (current, latest string, available bool) {
23+
return checkQuietlyWithVersion(ctx, githubToken, version.Version(), fetchLatestVersion)
24+
}
25+
26+
func checkQuietlyWithVersion(ctx context.Context, githubToken string, currentVersion string, fetch versionFetcher) (current, latest string, available bool) {
27+
current = currentVersion
28+
if current == "dev" {
29+
return current, "", false
30+
}
31+
32+
ctx, cancel := context.WithTimeout(ctx, checkTimeout)
33+
defer cancel()
34+
35+
latestVer, err := fetch(ctx, githubToken)
36+
if err != nil {
37+
return current, "", false
38+
}
39+
40+
if normalizeVersion(current) == normalizeVersion(latestVer) {
41+
return current, latestVer, false
42+
}
43+
44+
return current, latestVer, true
45+
}
46+
47+
func NotifyUpdate(ctx context.Context, sink output.Sink, opts NotifyOptions) (exitAfter bool) {
48+
return notifyUpdateWithVersion(ctx, sink, opts, version.Version(), fetchLatestVersion)
49+
}
50+
51+
func notifyUpdateWithVersion(ctx context.Context, sink output.Sink, opts NotifyOptions, currentVersion string, fetch versionFetcher) (exitAfter bool) {
52+
current, latest, available := checkQuietlyWithVersion(ctx, opts.GitHubToken, currentVersion, fetch)
53+
if !available {
54+
return false
55+
}
56+
57+
if !opts.UpdatePrompt {
58+
output.EmitNote(sink, fmt.Sprintf("Update available: %s → %s (run lstk update)", current, latest))
59+
return false
60+
}
61+
62+
return promptAndUpdate(ctx, sink, opts.GitHubToken, current, latest, opts.PersistDisable)
63+
}
64+
65+
func promptAndUpdate(ctx context.Context, sink output.Sink, githubToken string, current, latest string, persistDisable func() error) (exitAfter bool) {
66+
output.EmitWarning(sink, fmt.Sprintf("Update available: %s → %s", current, latest))
67+
68+
responseCh := make(chan output.InputResponse, 1)
69+
output.EmitUserInputRequest(sink, output.UserInputRequestEvent{
70+
Prompt: "A new version is available",
71+
Options: []output.InputOption{
72+
{Key: "u", Label: "Update"},
73+
{Key: "s", Label: "SKIP"},
74+
{Key: "n", Label: "Never ask again"},
75+
},
76+
ResponseCh: responseCh,
77+
})
78+
79+
var resp output.InputResponse
80+
select {
81+
case resp = <-responseCh:
82+
case <-ctx.Done():
83+
return false
84+
}
85+
86+
if resp.Cancelled {
87+
return false
88+
}
89+
90+
switch resp.SelectedKey {
91+
case "u":
92+
if err := applyUpdate(ctx, sink, latest, githubToken); err != nil {
93+
output.EmitWarning(sink, fmt.Sprintf("Update failed: %v", err))
94+
return false
95+
}
96+
output.EmitSuccess(sink, fmt.Sprintf("Updated to %s — please re-run your command.", latest))
97+
return true
98+
case "n":
99+
if persistDisable != nil {
100+
if err := persistDisable(); err != nil {
101+
output.EmitWarning(sink, fmt.Sprintf("Failed to save preference: %v", err))
102+
}
103+
}
104+
return false
105+
default:
106+
return false
107+
}
108+
}

internal/update/notify_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package update
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/localstack/lstk/internal/output"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func newTestGitHubServer(t *testing.T, tagName string) *httptest.Server {
16+
t.Helper()
17+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
w.Header().Set("Content-Type", "application/json")
19+
resp := githubRelease{TagName: tagName}
20+
if err := json.NewEncoder(w).Encode(resp); err != nil {
21+
t.Fatal(err)
22+
}
23+
}))
24+
}
25+
26+
func testFetcher(serverURL string) versionFetcher {
27+
return func(ctx context.Context, token string) (string, error) {
28+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL, nil)
29+
if err != nil {
30+
return "", err
31+
}
32+
resp, err := http.DefaultClient.Do(req)
33+
if err != nil {
34+
return "", err
35+
}
36+
defer func() { _ = resp.Body.Close() }()
37+
var release githubRelease
38+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
39+
return "", err
40+
}
41+
return release.TagName, nil
42+
}
43+
}
44+
45+
func TestCheckQuietlyDevBuild(t *testing.T) {
46+
current, latest, available := CheckQuietly(context.Background(), "")
47+
assert.Equal(t, "dev", current)
48+
assert.Empty(t, latest)
49+
assert.False(t, available)
50+
}
51+
52+
func TestCheckQuietlyNetworkError(t *testing.T) {
53+
fetch := func(ctx context.Context, token string) (string, error) {
54+
return "", fmt.Errorf("connection refused")
55+
}
56+
57+
current, latest, available := checkQuietlyWithVersion(context.Background(), "", "1.0.0", fetch)
58+
assert.Equal(t, "1.0.0", current)
59+
assert.Empty(t, latest)
60+
assert.False(t, available)
61+
}
62+
63+
func TestCheckQuietlyUpdateAvailable(t *testing.T) {
64+
server := newTestGitHubServer(t, "v2.0.0")
65+
defer server.Close()
66+
67+
current, latest, available := checkQuietlyWithVersion(context.Background(), "", "1.0.0", testFetcher(server.URL))
68+
assert.Equal(t, "1.0.0", current)
69+
assert.Equal(t, "v2.0.0", latest)
70+
assert.True(t, available)
71+
}
72+
73+
func TestCheckQuietlyAlreadyUpToDate(t *testing.T) {
74+
server := newTestGitHubServer(t, "v1.0.0")
75+
defer server.Close()
76+
77+
current, latest, available := checkQuietlyWithVersion(context.Background(), "", "v1.0.0", testFetcher(server.URL))
78+
assert.Equal(t, "v1.0.0", current)
79+
assert.Equal(t, "v1.0.0", latest)
80+
assert.False(t, available)
81+
}
82+
83+
func TestNotifyUpdateNoUpdateAvailable(t *testing.T) {
84+
server := newTestGitHubServer(t, "v1.0.0")
85+
defer server.Close()
86+
87+
var events []any
88+
sink := output.SinkFunc(func(event any) { events = append(events, event) })
89+
90+
exit := notifyUpdateWithVersion(context.Background(), sink, NotifyOptions{UpdatePrompt: true}, "v1.0.0", testFetcher(server.URL))
91+
assert.False(t, exit)
92+
assert.Empty(t, events)
93+
}
94+
95+
func TestNotifyUpdatePromptDisabled(t *testing.T) {
96+
server := newTestGitHubServer(t, "v2.0.0")
97+
defer server.Close()
98+
99+
var events []any
100+
sink := output.SinkFunc(func(event any) { events = append(events, event) })
101+
102+
exit := notifyUpdateWithVersion(context.Background(), sink, NotifyOptions{}, "1.0.0", testFetcher(server.URL))
103+
assert.False(t, exit)
104+
assert.Len(t, events, 1)
105+
msg, ok := events[0].(output.MessageEvent)
106+
assert.True(t, ok)
107+
assert.Equal(t, output.SeverityNote, msg.Severity)
108+
assert.Contains(t, msg.Text, "Update available")
109+
}
110+
111+
func TestNotifyUpdatePromptSkip(t *testing.T) {
112+
server := newTestGitHubServer(t, "v2.0.0")
113+
defer server.Close()
114+
115+
var events []any
116+
sink := output.SinkFunc(func(event any) {
117+
events = append(events, event)
118+
if req, ok := event.(output.UserInputRequestEvent); ok {
119+
req.ResponseCh <- output.InputResponse{SelectedKey: "s"}
120+
}
121+
})
122+
123+
exit := notifyUpdateWithVersion(context.Background(), sink, NotifyOptions{UpdatePrompt: true}, "1.0.0", testFetcher(server.URL))
124+
assert.False(t, exit)
125+
}
126+
127+
func TestNotifyUpdatePromptNever(t *testing.T) {
128+
server := newTestGitHubServer(t, "v2.0.0")
129+
defer server.Close()
130+
131+
persistCalled := false
132+
133+
var events []any
134+
sink := output.SinkFunc(func(event any) {
135+
events = append(events, event)
136+
if req, ok := event.(output.UserInputRequestEvent); ok {
137+
req.ResponseCh <- output.InputResponse{SelectedKey: "n"}
138+
}
139+
})
140+
141+
exit := notifyUpdateWithVersion(context.Background(), sink, NotifyOptions{
142+
UpdatePrompt: true,
143+
PersistDisable: func() error {
144+
persistCalled = true
145+
return nil
146+
},
147+
}, "1.0.0", testFetcher(server.URL))
148+
assert.False(t, exit)
149+
assert.True(t, persistCalled)
150+
}
151+
152+
func TestNotifyUpdatePromptCancelled(t *testing.T) {
153+
server := newTestGitHubServer(t, "v2.0.0")
154+
defer server.Close()
155+
156+
var events []any
157+
sink := output.SinkFunc(func(event any) {
158+
events = append(events, event)
159+
if req, ok := event.(output.UserInputRequestEvent); ok {
160+
req.ResponseCh <- output.InputResponse{Cancelled: true}
161+
}
162+
})
163+
164+
exit := notifyUpdateWithVersion(context.Background(), sink, NotifyOptions{UpdatePrompt: true}, "1.0.0", testFetcher(server.URL))
165+
assert.False(t, exit)
166+
}

0 commit comments

Comments
 (0)