diff --git a/cmd/stripe/main.go b/cmd/stripe/main.go index cada660fd..3b9851d7d 100644 --- a/cmd/stripe/main.go +++ b/cmd/stripe/main.go @@ -7,6 +7,7 @@ import ( "os" "time" + "github.com/stripe/stripe-cli/pkg/autoupdate" "github.com/stripe/stripe-cli/pkg/cmd" "github.com/stripe/stripe-cli/pkg/stripe" ) @@ -35,4 +36,7 @@ func main() { // Wait for all telemetry calls to finish before existing the process telemetryClient.Wait() } + + // Check for updates after command completes (once per day, synchronous) + autoupdate.CheckForUpdate() } diff --git a/pkg/autoupdate/checker.go b/pkg/autoupdate/checker.go new file mode 100644 index 000000000..d895a7ef0 --- /dev/null +++ b/pkg/autoupdate/checker.go @@ -0,0 +1,306 @@ +package autoupdate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/google/go-github/v72/github" + semver "github.com/hashicorp/go-version" + log "github.com/sirupsen/logrus" + + "github.com/stripe/stripe-cli/pkg/version" +) + +const httpTimeout = 10 * time.Second + +const checkInterval = 24 * time.Hour + +// UpdateMarker represents a staged update ready to be applied. +type UpdateMarker struct { + Version string + DownloadURL string + Checksum string +} + +// CheckForUpdate checks for a newer CLI version and writes a marker file +// if an update is available. Skips major version changes. This is called +// synchronously after command execution, rate-limited to once per day. +func CheckForUpdate() { + defer func() { + if r := recover(); r != nil { + log.Debugf("autoupdate check panicked: %v", r) + } + }() + + if !shouldCheck() { + return + } + + latest, url, checksum := fetchLatestRelease() + if latest == "" { + return + } + + current := strings.TrimPrefix(version.Version, "v") + latestClean := strings.TrimPrefix(latest, "v") + + if current == latestClean { + recordLastCheck() + return + } + + if isMajorVersionChange(current, latestClean) { + log.Debugf("autoupdate: skipping major version change %s → %s", current, latestClean) + recordLastCheck() + return + } + + WriteMarker(UpdateMarker{ + Version: latestClean, + DownloadURL: url, + Checksum: checksum, + }) +} + +func isMajorVersionChange(current, latest string) bool { + cur, err := semver.NewVersion(current) + if err != nil { + return false + } + lat, err := semver.NewVersion(latest) + if err != nil { + return false + } + return cur.Segments()[0] != lat.Segments()[0] +} + +func shouldCheck() bool { + if version.Version == "master" { + return false + } + if IsOptedOut() { + return false + } + if !IsCurlInstall() { + return false + } + + stateDir := GetStateDir() + if stateDir == "" { + return false + } + + lastCheckFile := filepath.Join(stateDir, "last_update_check") + data, err := os.ReadFile(lastCheckFile) + if err != nil { + return true + } + + ts, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64) + if err != nil { + return true + } + + return time.Since(time.Unix(ts, 0)) >= checkInterval +} + +func fetchLatestRelease() (ver string, downloadURL string, checksum string) { + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + client := github.NewClient(nil) + release, _, err := client.Repositories.GetLatestRelease(ctx, "stripe", "stripe-cli") + if err != nil { + log.Debug("autoupdate: failed to fetch latest release: ", err) + return "", "", "" + } + + ver = release.GetTagName() + assetName := binaryAssetName(strings.TrimPrefix(ver, "v")) + checksumAsset := checksumAssetName() + + var binaryURL, checksumURL string + for _, asset := range release.Assets { + name := asset.GetName() + if name == assetName { + binaryURL = asset.GetBrowserDownloadURL() + } + if name == checksumAsset { + checksumURL = asset.GetBrowserDownloadURL() + } + } + + if binaryURL == "" { + log.Debug("autoupdate: binary asset not found: ", assetName) + return "", "", "" + } + + if checksumURL != "" { + checksum = fetchChecksumForAsset(checksumURL, assetName) + } + + return ver, binaryURL, checksum +} + +func fetchChecksumForAsset(checksumURL, assetName string) string { + ctx, cancel := context.WithTimeout(context.Background(), httpTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil) + if err != nil { + log.Debug("autoupdate: failed to create checksum request: ", err) + return "" + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Debug("autoupdate: failed to fetch checksums: ", err) + return "" + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + for _, line := range strings.Split(string(body), "\n") { + parts := strings.Fields(line) + if len(parts) == 2 && parts[1] == assetName { + return parts[0] + } + } + return "" +} + +func binaryAssetName(ver string) string { + goos := runtime.GOOS + goarch := runtime.GOARCH + + var archName string + switch goarch { + case "amd64": + archName = "x86_64" + case "arm64": + archName = "arm64" + default: + archName = goarch + } + + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + + return fmt.Sprintf("stripe_%s_%s_%s.%s", ver, goos, archName, ext) +} + +func checksumAssetName() string { + switch runtime.GOOS { + case "darwin": + return "stripe-mac-checksums.txt" + case "linux": + return "stripe-linux-checksums.txt" + case "windows": + return "stripe-windows-checksums.txt" + default: + return "" + } +} + +// WriteMarker writes an update marker to the state directory. +func WriteMarker(m UpdateMarker) { + stateDir := GetStateDir() + if stateDir == "" { + return + } + + if err := os.MkdirAll(stateDir, 0755); err != nil { + return + } + + content := fmt.Sprintf("%s\n%s\n%s\n", m.Version, m.DownloadURL, m.Checksum) + markerPath := filepath.Join(stateDir, "update-available") + _ = os.WriteFile(markerPath, []byte(content), 0644) + + recordLastCheck() +} + +func recordLastCheck() { + stateDir := GetStateDir() + if stateDir == "" { + return + } + if err := os.MkdirAll(stateDir, 0755); err != nil { + return + } + now := strconv.FormatInt(time.Now().Unix(), 10) + _ = os.WriteFile(filepath.Join(stateDir, "last_update_check"), []byte(now), 0644) +} + +// ReadMarker reads a pending update marker, or returns nil if none exists. +func ReadMarker() *UpdateMarker { + stateDir := GetStateDir() + if stateDir == "" { + return nil + } + + data, err := os.ReadFile(filepath.Join(stateDir, "update-available")) + if err != nil { + return nil + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 2 { + return nil + } + + m := &UpdateMarker{ + Version: lines[0], + DownloadURL: lines[1], + } + if len(lines) >= 3 { + m.Checksum = lines[2] + } + return m +} + +// ClearMarker removes the pending update marker. +func ClearMarker() { + stateDir := GetStateDir() + if stateDir == "" { + return + } + _ = os.Remove(filepath.Join(stateDir, "update-available")) +} + +// VerifyChecksum verifies the SHA256 checksum of a file. +func VerifyChecksum(filePath, expected string) bool { + if expected == "" { + return true + } + + f, err := os.Open(filePath) + if err != nil { + return false + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return false + } + + actual := hex.EncodeToString(h.Sum(nil)) + return strings.EqualFold(actual, expected) +} diff --git a/pkg/autoupdate/checker_test.go b/pkg/autoupdate/checker_test.go new file mode 100644 index 000000000..86fa25c9d --- /dev/null +++ b/pkg/autoupdate/checker_test.go @@ -0,0 +1,85 @@ +package autoupdate + +import ( + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsMajorVersionChange(t *testing.T) { + tests := []struct { + current string + latest string + expected bool + }{ + {"1.23.0", "1.24.0", false}, + {"1.23.0", "1.23.1", false}, + {"1.99.0", "2.0.0", true}, + {"2.0.0", "1.99.0", true}, + {"1.0.0", "1.0.0", false}, + {"1.0.0-beta", "1.0.0", false}, + {"invalid", "1.0.0", false}, + } + + for _, tt := range tests { + t.Run(tt.current+"→"+tt.latest, func(t *testing.T) { + assert.Equal(t, tt.expected, isMajorVersionChange(tt.current, tt.latest)) + }) + } +} + +func TestMarkerReadWrite(t *testing.T) { + tmpDir := t.TempDir() + original := GetStateDirFn + defer func() { GetStateDirFn = original }() + GetStateDirFn = func() string { return tmpDir } + + m := UpdateMarker{ + Version: "1.24.0", + DownloadURL: "https://example.com/stripe.tar.gz", + Checksum: "abc123", + } + + WriteMarker(m) + + got := ReadMarker() + require.NotNil(t, got) + assert.Equal(t, "1.24.0", got.Version) + assert.Equal(t, "https://example.com/stripe.tar.gz", got.DownloadURL) + assert.Equal(t, "abc123", got.Checksum) + + ClearMarker() + assert.Nil(t, ReadMarker()) +} + +func TestRecordLastCheck(t *testing.T) { + tmpDir := t.TempDir() + original := GetStateDirFn + defer func() { GetStateDirFn = original }() + GetStateDirFn = func() string { return tmpDir } + + recordLastCheck() + + data, err := os.ReadFile(filepath.Join(tmpDir, "last_update_check")) + require.NoError(t, err) + + ts, err := strconv.ParseInt(string(data), 10, 64) + require.NoError(t, err) + assert.WithinDuration(t, time.Now(), time.Unix(ts, 0), 2*time.Second) +} + +func TestVerifyChecksum(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "testfile") + os.WriteFile(tmpFile, []byte("hello world\n"), 0644) + + // sha256 of "hello world\n" + assert.True(t, VerifyChecksum(tmpFile, "a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447")) + assert.False(t, VerifyChecksum(tmpFile, "0000000000000000000000000000000000000000000000000000000000000000")) + // Empty expected = skip verification + assert.True(t, VerifyChecksum(tmpFile, "")) +}