diff --git a/internal/tui/tui.go b/internal/tui/tui.go index bf6e0c9..3f1ff88 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -21,6 +21,7 @@ import ( "github.com/tailscale/aperture-cli/internal/clients" "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/menu" + "github.com/tailscale/aperture-cli/internal/updatecheck" ) type step int @@ -89,10 +90,17 @@ type model struct { bridgeLogCh chan string bridgeLogs []string bridgeCancel context.CancelFunc + + updateVersion string + updateURL string } func (m *model) Init() tea.Cmd { - return m.activateEndpointCmd(m.g.ActiveEndpoint()) + activate := m.activateEndpointCmd(m.g.ActiveEndpoint()) + if !updatecheck.ValidVersion(m.buildVersion) { + return activate + } + return tea.Batch(activate, checkForUpdateCmd()) } // preflightResult is emitted when the /api/providers check completes. @@ -113,6 +121,20 @@ type bridgeLogMsg string type bridgeLogDoneMsg struct{} type quitMsg struct{ Err error } +type updateCheckResult struct { + release updatecheck.Release + err error +} + +func checkForUpdateCmd() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + release, err := updatecheck.Latest(ctx, &http.Client{Timeout: 2 * time.Second}) + return updateCheckResult{release: release, err: err} + } +} + func runPreflight(host string) tea.Cmd { return func() tea.Msg { provs, err := fetchProviders(host) @@ -286,6 +308,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.bridgeLogCh = nil return m, nil + case updateCheckResult: + if msg.err == nil && updatecheck.IsNewer(m.buildVersion, msg.release.Version) { + m.updateVersion = msg.release.Version + m.updateURL = msg.release.URL + } + return m, nil + case quitMsg: if msg.Err != nil { m.errMsg = "Error shutting down bridges: " + msg.Err.Error() @@ -649,6 +678,14 @@ func (m *model) viewMenu() string { sb.WriteString("\n") sb.WriteString(dimStyle.Render("Aperture " + m.buildVersion)) sb.WriteString("\n") + if m.updateVersion != "" { + notice := "Update available: " + m.updateVersion + if m.updateURL != "" { + notice += " " + m.updateURL + } + sb.WriteString(greenStyle.Render(notice)) + sb.WriteString("\n") + } } return sb.String() } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 62efca9..12dd564 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -9,6 +9,7 @@ import ( "github.com/tailscale/aperture-cli/internal/clients" "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/menu" + "github.com/tailscale/aperture-cli/internal/updatecheck" ) // fakeClient is a minimal clients.Client for TUI tests. @@ -125,6 +126,47 @@ func TestRootMenu_NoQuickSelectWhenReplayNil(t *testing.T) { } } +func TestUpdateNoticeShownOnRootMenu(t *testing.T) { + withFakeClients(t, []clients.Client{&fakeClient{name: "A", installed: true}}) + m := &model{g: &config.Global{}, buildVersion: "v0.0.7", step: stepMenu} + m.resetStack(m.rootMenu()) + + m.Update(updateCheckResult{release: updatecheck.Release{ + Version: "v0.0.8", + URL: "https://github.com/tailscale/aperture-cli/releases/tag/v0.0.8", + }}) + + view := m.View() + if !strings.Contains(view, "Update available: v0.0.8") { + t.Fatalf("view missing update notice:\n%s", view) + } + if !strings.Contains(view, "releases/tag/v0.0.8") { + t.Fatalf("view missing release URL:\n%s", view) + } +} + +func TestUpdateNoticeHiddenForCurrentOrDevelopmentBuild(t *testing.T) { + withFakeClients(t, []clients.Client{&fakeClient{name: "A", installed: true}}) + tests := []struct { + name string + buildVersion string + }{ + {name: "current", buildVersion: "v0.0.8"}, + {name: "development", buildVersion: "B42"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &model{g: &config.Global{}, buildVersion: tt.buildVersion, step: stepMenu} + m.resetStack(m.rootMenu()) + m.Update(updateCheckResult{release: updatecheck.Release{Version: "v0.0.8"}}) + if view := m.View(); strings.Contains(view, "Update available") { + t.Fatalf("unexpected update notice:\n%s", view) + } + }) + } +} + func TestMenuEngine_PushPop(t *testing.T) { sub := &menu.Menu{ Title: "Sub", diff --git a/internal/updatecheck/updatecheck.go b/internal/updatecheck/updatecheck.go new file mode 100644 index 0000000..24d40ba --- /dev/null +++ b/internal/updatecheck/updatecheck.go @@ -0,0 +1,117 @@ +// Package updatecheck reports when a newer stable Aperture CLI release is available. +package updatecheck + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" +) + +const ( + latestReleaseURL = "https://api.github.com/repos/tailscale/aperture-cli/releases/latest" + maxResponseBytes = 1 << 20 +) + +var stableVersionPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`) + +// Release describes the latest stable GitHub release. +type Release struct { + Version string + URL string +} + +// Latest fetches the latest stable Aperture CLI release from GitHub. +func Latest(ctx context.Context, client *http.Client) (Release, error) { + if client == nil { + client = http.DefaultClient + } + return latestFromURL(ctx, client, latestReleaseURL) +} + +func latestFromURL(ctx context.Context, client *http.Client, endpoint string) (Release, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return Release{}, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("User-Agent", "aperture-cli") + + resp, err := client.Do(req) + if err != nil { + return Release{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return Release{}, fmt.Errorf("latest release request returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + return Release{}, err + } + if len(body) > maxResponseBytes { + return Release{}, fmt.Errorf("latest release response exceeds %d bytes", maxResponseBytes) + } + + var payload struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return Release{}, fmt.Errorf("decode latest release response: %w", err) + } + + release := Release{ + Version: strings.TrimSpace(payload.TagName), + URL: strings.TrimSpace(payload.HTMLURL), + } + if !ValidVersion(release.Version) { + return Release{}, fmt.Errorf("latest release has invalid version %q", release.Version) + } + return release, nil +} + +// ValidVersion reports whether version is a stable three component semantic version. +func ValidVersion(version string) bool { + return stableVersionPattern.MatchString(version) +} + +// IsNewer reports whether latest is a newer stable version than current. +func IsNewer(current, latest string) bool { + currentParts, ok := versionParts(current) + if !ok { + return false + } + latestParts, ok := versionParts(latest) + if !ok { + return false + } + for i := range currentParts { + if latestParts[i] != currentParts[i] { + return latestParts[i] > currentParts[i] + } + } + return false +} + +func versionParts(version string) ([3]uint64, bool) { + match := stableVersionPattern.FindStringSubmatch(version) + if match == nil { + return [3]uint64{}, false + } + var parts [3]uint64 + for i := range parts { + part, err := strconv.ParseUint(match[i+1], 10, 64) + if err != nil { + return [3]uint64{}, false + } + parts[i] = part + } + return parts, true +} diff --git a/internal/updatecheck/updatecheck_test.go b/internal/updatecheck/updatecheck_test.go new file mode 100644 index 0000000..6d87acf --- /dev/null +++ b/internal/updatecheck/updatecheck_test.go @@ -0,0 +1,88 @@ +package updatecheck + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestIsNewer(t *testing.T) { + tests := []struct { + name string + current string + latest string + want bool + }{ + {name: "patch", current: "v1.2.3", latest: "v1.2.4", want: true}, + {name: "minor", current: "v1.2.9", latest: "v1.3.0", want: true}, + {name: "major", current: "v1.9.9", latest: "v2.0.0", want: true}, + {name: "same", current: "v1.2.3", latest: "v1.2.3", want: false}, + {name: "older", current: "v1.2.3", latest: "v1.2.2", want: false}, + {name: "development build", current: "B42", latest: "v1.2.3", want: false}, + {name: "prerelease", current: "v1.2.3-beta.1", latest: "v1.2.3", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsNewer(tt.current, tt.latest); got != tt.want { + t.Fatalf("IsNewer(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want) + } + }) + } +} + +func TestLatestFromURL(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Accept"); got != "application/vnd.github+json" { + t.Errorf("Accept = %q", got) + } + if got := r.Header.Get("X-GitHub-Api-Version"); got != "2022-11-28" { + t.Errorf("X-GitHub-Api-Version = %q", got) + } + if got := r.Header.Get("User-Agent"); got != "aperture-cli" { + t.Errorf("User-Agent = %q", got) + } + _, _ = w.Write([]byte(`{"tag_name":"v0.0.8","html_url":"https://github.com/tailscale/aperture-cli/releases/tag/v0.0.8"}`)) + })) + t.Cleanup(server.Close) + + release, err := latestFromURL(context.Background(), server.Client(), server.URL) + if err != nil { + t.Fatal(err) + } + if release.Version != "v0.0.8" { + t.Errorf("Version = %q", release.Version) + } + if release.URL != "https://github.com/tailscale/aperture-cli/releases/tag/v0.0.8" { + t.Errorf("URL = %q", release.URL) + } +} + +func TestLatestFromURLRejectsInvalidResponses(t *testing.T) { + tests := []struct { + name string + status int + body string + }{ + {name: "status", status: http.StatusServiceUnavailable, body: `{}`}, + {name: "malformed JSON", status: http.StatusOK, body: `{`}, + {name: "invalid version", status: http.StatusOK, body: `{"tag_name":"latest"}`}, + {name: "oversized", status: http.StatusOK, body: strings.Repeat("x", maxResponseBytes+1)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + })) + t.Cleanup(server.Close) + + if _, err := latestFromURL(context.Background(), server.Client(), server.URL); err == nil { + t.Fatal("expected an error") + } + }) + } +}