on main help sign in
feat: notify about new crush versions (#361)

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> Co-authored-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

commitafc8fd0be1763d68a6461df1a3b290589ebdd8ad authorRaphael Amorim <rapha850@gmail.com> dateTue 18 Nov 2025, 12:22 (1 year ago) parente53c733
8 files changed+209−9
internal/app/app.go+19 internal/pubsub/events.go+6 internal/tui/components/core/status/status.go+2−2 internal/tui/tui.go+11 internal/tui/util/util.go+1 internal/update/update.go+114 internal/update/update_test.go+53 internal/version/version.go+3−7
internal/app/app.go+19
@@ -33,6 +33,8 @@ import (
3333 "github.com/charmbracelet/crush/internal/term"
3434 "github.com/charmbracelet/crush/internal/tui/components/anim"
3535 "github.com/charmbracelet/crush/internal/tui/styles"
36+ "github.com/charmbracelet/crush/internal/update"
37+ "github.com/charmbracelet/crush/internal/version"
3638 "github.com/charmbracelet/x/ansi"
3739 "github.com/charmbracelet/x/exp/charmtone"
3840)
@@ -92,6 +94,9 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
9294 // Initialize LSP clients in the background.
9395 app.initLSPClients(ctx)
9496
97+ // Check for updates in the background.
98+ go app.checkForUpdates(ctx)
99+
95100 go func() {
96101 slog.Info("Initializing MCP clients")
97102 mcp.Initialize(ctx, app.Permissions, cfg)
@@ -390,3 +395,17 @@ func (app *App) Shutdown() {
390395 }
391396 }
392397}
398+
399+// checkForUpdates checks for available updates.
400+func (app *App) checkForUpdates(ctx context.Context) {
401+ checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
402+ defer cancel()
403+ info, err := update.Check(checkCtx, version.Version, update.Default)
404+ if err != nil || !info.Available() {
405+ return
406+ }
407+ app.events <- pubsub.UpdateAvailableMsg{
408+ CurrentVersion: info.Current,
409+ LatestVersion: info.Latest,
410+ }
411+}
internal/pubsub/events.go+6
@@ -26,3 +26,9 @@ type (
2626 Publish(EventType, T)
2727 }
2828)
29+
30+// UpdateAvailableMsg is sent when a new version is available.
31+type UpdateAvailableMsg struct {
32+ CurrentVersion string
33+ LatestVersion string
34+}
internal/tui/components/core/status/status.go+2−2
@@ -82,10 +82,10 @@ func (m *statusCmp) infoMsg() string {
8282 info := ansi.Truncate(m.info.Msg, widthLeft, "…")
8383 message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info)
8484 default:
85− infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!")
85+ infoType = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true).Render("HEY!")
8686 widthLeft := m.width - (lipgloss.Width(infoType) + 2)
8787 info := ansi.Truncate(m.info.Msg, widthLeft, "…")
88− message = t.S().Base.Background(t.Success).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info)
88+ message = t.S().Base.Background(t.GreenDark).Width(widthLeft+2).Foreground(t.BgSubtle).Padding(0, 1).Render(info)
8989 }
9090 return ansi.Truncate(infoType+message, m.width, "…")
9191}
internal/tui/tui.go+11
@@ -372,6 +372,17 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
372372 cmds = append(cmds, pageCmd)
373373 }
374374 return a, tea.Batch(cmds...)
375+ // Update Available
376+ case pubsub.UpdateAvailableMsg:
377+ // Show update notification in status bar
378+ statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion)
379+ s, statusCmd := a.status.Update(util.InfoMsg{
380+ Type: util.InfoTypeInfo,
381+ Msg: statusMsg,
382+ TTL: 30 * time.Second,
383+ })
384+ a.status = s.(status.StatusCmp)
385+ return a, statusCmd
375386 }
376387 s, _ := a.status.Update(msg)
377388 a.status = s.(status.StatusCmp)
internal/tui/util/util.go+1
@@ -35,6 +35,7 @@ type InfoType int
3535
3636const (
3737 InfoTypeInfo InfoType = iota
38+ InfoTypeSuccess
3839 InfoTypeWarn
3940 InfoTypeError
4041)
internal/update/update.gonew file+114
@@ -0,0 +1,114 @@
1+package update
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "strings"
10+ "time"
11+)
12+
13+const (
14+ githubAPIURL = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
15+ userAgent = "crush/1.0"
16+)
17+
18+// Default is the default [Client].
19+var Default Client = &github{}
20+
21+// Info contains information about an available update.
22+type Info struct {
23+ Current string
24+ Latest string
25+ URL string
26+}
27+
28+// Available returns true if there's an update available.
29+//
30+// If both current and latest are stable versions, returns true if versions are
31+// different.
32+// If current is a pre-release and latest isn't, returns true.
33+// If latest is a pre-release and current isn't, returns false.
34+func (i Info) Available() bool {
35+ cpr := strings.Contains(i.Current, "-")
36+ lpr := strings.Contains(i.Latest, "-")
37+ // current is pre release
38+ if cpr {
39+ // latest isn't a prerelease
40+ if !lpr {
41+ return true
42+ }
43+ }
44+ if lpr && !cpr {
45+ return false
46+ }
47+ return i.Current != i.Latest
48+}
49+
50+// Check checks if a new version is available.
51+func Check(ctx context.Context, current string, client Client) (Info, error) {
52+ info := Info{
53+ Current: current,
54+ Latest: current,
55+ }
56+
57+ if info.Current == "devel" || info.Current == "unknown" {
58+ return info, nil
59+ }
60+
61+ release, err := client.Latest(ctx)
62+ if err != nil {
63+ return info, fmt.Errorf("failed to fetch latest release: %w", err)
64+ }
65+
66+ info.Latest = strings.TrimPrefix(release.TagName, "v")
67+ info.URL = release.HTMLURL
68+ return info, nil
69+}
70+
71+// Release represents a GitHub release.
72+type Release struct {
73+ TagName string `json:"tag_name"`
74+ HTMLURL string `json:"html_url"`
75+}
76+
77+// Client is a client that can get the latest release.
78+type Client interface {
79+ Latest(ctx context.Context) (*Release, error)
80+}
81+
82+type github struct{}
83+
84+// Latest implements [Client].
85+func (c *github) Latest(ctx context.Context) (*Release, error) {
86+ client := &http.Client{
87+ Timeout: 30 * time.Second,
88+ }
89+
90+ req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil)
91+ if err != nil {
92+ return nil, err
93+ }
94+ req.Header.Set("User-Agent", userAgent)
95+ req.Header.Set("Accept", "application/vnd.github.v3+json")
96+
97+ resp, err := client.Do(req)
98+ if err != nil {
99+ return nil, err
100+ }
101+ defer resp.Body.Close()
102+
103+ if resp.StatusCode != http.StatusOK {
104+ body, _ := io.ReadAll(resp.Body)
105+ return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body))
106+ }
107+
108+ var release Release
109+ if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
110+ return nil, err
111+ }
112+
113+ return &release, nil
114+}
internal/update/update_test.gonew file+53
@@ -0,0 +1,53 @@
1+package update
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestCheckForUpdate_DevelopmentVersion(t *testing.T) {
11+ info, err := Check(t.Context(), "unknown", testClient{"v0.11.0"})
12+ require.NoError(t, err)
13+ require.NotNil(t, info)
14+ require.False(t, info.Available())
15+}
16+
17+func TestCheckForUpdate_Old(t *testing.T) {
18+ info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0"})
19+ require.NoError(t, err)
20+ require.NotNil(t, info)
21+ require.True(t, info.Available())
22+}
23+
24+func TestCheckForUpdate_Beta(t *testing.T) {
25+ t.Run("current is stable", func(t *testing.T) {
26+ info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0-beta.1"})
27+ require.NoError(t, err)
28+ require.NotNil(t, info)
29+ require.False(t, info.Available())
30+ })
31+ t.Run("current is also beta", func(t *testing.T) {
32+ info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0-beta.2"})
33+ require.NoError(t, err)
34+ require.NotNil(t, info)
35+ require.True(t, info.Available())
36+ })
37+ t.Run("current is beta, latest isn't", func(t *testing.T) {
38+ info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0"})
39+ require.NoError(t, err)
40+ require.NotNil(t, info)
41+ require.True(t, info.Available())
42+ })
43+}
44+
45+type testClient struct{ tag string }
46+
47+// Latest implements Client.
48+func (t testClient) Latest(ctx context.Context) (*Release, error) {
49+ return &Release{
50+ TagName: t.tag,
51+ HTMLURL: "https://example.org",
52+ }, nil
53+}
internal/version/version.go+3−7
@@ -4,7 +4,7 @@ import "runtime/debug"
44
55// Build-time parameters set via -ldflags
66
7−var Version = "unknown"
7+var Version = "devel"
88
99// A user may install crush using `go install github.com/charmbracelet/crush@latest`.
1010// without -ldflags, in which case the version above is unset. As a workaround
@@ -13,14 +13,10 @@ var Version = "unknown"
1313func init() {
1414 info, ok := debug.ReadBuildInfo()
1515 if !ok {
16− // < go v1.18
1716 return
1817 }
1918 mainVersion := info.Main.Version
20− if mainVersion == "" || mainVersion == "(devel)" {
21− // bin not built using `go install`
22− return
19+ if mainVersion != "" && mainVersion != "(devel)" {
20+ Version = mainVersion
2321 }
24− // bin built using `go install`
25− Version = mainVersion
2622}