From 704a0ce11f585660d0d0401b7bdc180f9746b172 Mon Sep 17 00:00:00 2001 From: Yahan Xing Date: Thu, 9 Jul 2026 13:23:17 -0700 Subject: [PATCH 1/4] Add autoupdate config and detection utilities Introduce pkg/autoupdate with helpers to detect whether the CLI was installed via curl (~/.stripe/bin/) and whether auto-update is opted out (via STRIPE_NO_AUTO_UPDATE env or config.toml setting). This is part 1/3 of the auto-update feature for curl-installed binaries. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude --- pkg/autoupdate/config.go | 81 +++++++++++++++++++++++++++++++++++ pkg/autoupdate/config_test.go | 48 +++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 pkg/autoupdate/config.go create mode 100644 pkg/autoupdate/config_test.go diff --git a/pkg/autoupdate/config.go b/pkg/autoupdate/config.go new file mode 100644 index 00000000..2557ba97 --- /dev/null +++ b/pkg/autoupdate/config.go @@ -0,0 +1,81 @@ +// Package autoupdate implements automatic version updates for curl-installed Stripe CLI binaries. +package autoupdate + +import ( + "os" + "path/filepath" + "strings" + + "github.com/mitchellh/go-homedir" + "github.com/spf13/viper" +) + +// IsOptedOut reports whether the user has disabled auto-update. +func IsOptedOut() bool { + if os.Getenv("STRIPE_NO_AUTO_UPDATE") != "" { + return true + } + + configFolder := getConfigFolder() + configFile := filepath.Join(configFolder, "config.toml") + + v := viper.New() + v.SetConfigType("toml") + v.SetConfigFile(configFile) + + if err := v.ReadInConfig(); err != nil { + return false + } + + return !v.GetBool("settings.auto_update") && v.IsSet("settings.auto_update") +} + +// IsCurlInstall reports whether the current binary was installed via curl (lives in ~/.stripe/bin/). +func IsCurlInstall() bool { + if method := os.Getenv("STRIPE_INSTALL_METHOD"); method != "" { + return method == "curl" + } + + exe, err := os.Executable() + if err != nil { + return false + } + + home, err := homedir.Dir() + if err != nil { + return false + } + + stripeBinDir := filepath.Join(home, ".stripe", "bin") + exeLower := strings.ToLower(filepath.ToSlash(exe)) + expectedLower := strings.ToLower(filepath.ToSlash(stripeBinDir)) + + return strings.HasPrefix(exeLower, expectedLower) +} + +func getConfigFolder() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "stripe") + } + home, err := homedir.Dir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "stripe") +} + +// GetStateDirFn is the active implementation; tests can override it. +var GetStateDirFn = getStateDirDefault + +// GetStateDir returns the path to the autoupdate state directory. +func GetStateDir() string { + return GetStateDirFn() +} + +func getStateDirDefault() string { + home, err := homedir.Dir() + if err != nil { + return "" + } + return filepath.Join(home, ".stripe", "state") +} diff --git a/pkg/autoupdate/config_test.go b/pkg/autoupdate/config_test.go new file mode 100644 index 00000000..b6ca278b --- /dev/null +++ b/pkg/autoupdate/config_test.go @@ -0,0 +1,48 @@ +package autoupdate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsOptedOut_EnvVar(t *testing.T) { + t.Setenv("STRIPE_NO_AUTO_UPDATE", "1") + assert.True(t, IsOptedOut()) +} + +func TestIsOptedOut_NoConfig(t *testing.T) { + t.Setenv("STRIPE_NO_AUTO_UPDATE", "") + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + assert.False(t, IsOptedOut()) +} + +func TestIsOptedOut_ConfigSetFalse(t *testing.T) { + t.Setenv("STRIPE_NO_AUTO_UPDATE", "") + configDir := t.TempDir() + stripeDir := filepath.Join(configDir, "stripe") + os.MkdirAll(stripeDir, 0755) + os.WriteFile(filepath.Join(stripeDir, "config.toml"), []byte("[settings]\nauto_update = false\n"), 0644) + t.Setenv("XDG_CONFIG_HOME", configDir) + assert.True(t, IsOptedOut()) +} + +func TestIsOptedOut_ConfigSetTrue(t *testing.T) { + t.Setenv("STRIPE_NO_AUTO_UPDATE", "") + configDir := t.TempDir() + stripeDir := filepath.Join(configDir, "stripe") + os.MkdirAll(stripeDir, 0755) + os.WriteFile(filepath.Join(stripeDir, "config.toml"), []byte("[settings]\nauto_update = true\n"), 0644) + t.Setenv("XDG_CONFIG_HOME", configDir) + assert.False(t, IsOptedOut()) +} + +func TestIsCurlInstall_EnvOverride(t *testing.T) { + t.Setenv("STRIPE_INSTALL_METHOD", "curl") + assert.True(t, IsCurlInstall()) + + t.Setenv("STRIPE_INSTALL_METHOD", "homebrew") + assert.False(t, IsCurlInstall()) +} From 0b5e959fd3dac7ce7f2de6058710914d3ab298b3 Mon Sep 17 00:00:00 2001 From: Yahan Xing Date: Thu, 9 Jul 2026 13:53:56 -0700 Subject: [PATCH 2/4] Add background version check and update marker After each command completes, check for a newer CLI version (once per day max). If a minor/patch update is available, write a marker file to ~/.stripe/state/update-available. Major version changes are skipped. The marker will be consumed by the apply step (next PR in the stack). This is part 2/3 of the auto-update feature for curl-installed binaries. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude --- cmd/stripe/main.go | 4 + pkg/autoupdate/checker.go | 293 +++++++++++++++++++++++++++++++++ pkg/autoupdate/checker_test.go | 90 ++++++++++ 3 files changed, 387 insertions(+) create mode 100644 pkg/autoupdate/checker.go create mode 100644 pkg/autoupdate/checker_test.go diff --git a/cmd/stripe/main.go b/cmd/stripe/main.go index cada660f..3b9851d7 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 00000000..59e07c54 --- /dev/null +++ b/pkg/autoupdate/checker.go @@ -0,0 +1,293 @@ +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" + log "github.com/sirupsen/logrus" + + "github.com/stripe/stripe-cli/pkg/version" +) + +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 { + currentMajor := majorVersion(current) + latestMajor := majorVersion(latest) + return currentMajor != "" && latestMajor != "" && currentMajor != latestMajor +} + +func majorVersion(v string) string { + parts := strings.SplitN(v, ".", 2) + if len(parts) == 0 { + return "" + } + return parts[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) { + client := github.NewClient(nil) + release, _, err := client.Repositories.GetLatestRelease(context.Background(), "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 { + resp, err := http.Get(checksumURL) //nolint:gosec + 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 00000000..ed787d9c --- /dev/null +++ b/pkg/autoupdate/checker_test.go @@ -0,0 +1,90 @@ +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}, + } + + 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 TestMajorVersion(t *testing.T) { + assert.Equal(t, "1", majorVersion("1.23.0")) + assert.Equal(t, "2", majorVersion("2.0.0")) + assert.Equal(t, "0", majorVersion("0.1.0")) + assert.Equal(t, "", majorVersion("")) +} + +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, "")) +} From e68c82a70dad2f05475edda1c29bfd1d5bb8b941 Mon Sep 17 00:00:00 2001 From: Yahan Xing Date: Tue, 14 Jul 2026 13:43:00 -0700 Subject: [PATCH 3/4] Address review feedback on checker - Use hashicorp/go-version for major version comparison instead of hand-rolled string splitting (handles pre-release, non-semver gracefully) - Add 10s timeout to GitHub API call and checksum fetch to avoid blocking the user indefinitely on slow/broken networks - Remove dead majorVersion() helper and its tests Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude --- pkg/autoupdate/checker.go | 37 +++++++++++++++++++++++----------- pkg/autoupdate/checker_test.go | 9 ++------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/pkg/autoupdate/checker.go b/pkg/autoupdate/checker.go index 59e07c54..067dedcc 100644 --- a/pkg/autoupdate/checker.go +++ b/pkg/autoupdate/checker.go @@ -15,11 +15,14 @@ import ( "time" "github.com/google/go-github/v72/github" + goversion "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. @@ -70,17 +73,15 @@ func CheckForUpdate() { } func isMajorVersionChange(current, latest string) bool { - currentMajor := majorVersion(current) - latestMajor := majorVersion(latest) - return currentMajor != "" && latestMajor != "" && currentMajor != latestMajor -} - -func majorVersion(v string) string { - parts := strings.SplitN(v, ".", 2) - if len(parts) == 0 { - return "" + cur, err := goversion.NewVersion(current) + if err != nil { + return false } - return parts[0] + lat, err := goversion.NewVersion(latest) + if err != nil { + return false + } + return cur.Segments()[0] != lat.Segments()[0] } func shouldCheck() bool { @@ -114,8 +115,11 @@ func shouldCheck() bool { } 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(context.Background(), "stripe", "stripe-cli") + release, _, err := client.Repositories.GetLatestRelease(ctx, "stripe", "stripe-cli") if err != nil { log.Debug("autoupdate: failed to fetch latest release: ", err) return "", "", "" @@ -149,7 +153,16 @@ func fetchLatestRelease() (ver string, downloadURL string, checksum string) { } func fetchChecksumForAsset(checksumURL, assetName string) string { - resp, err := http.Get(checksumURL) //nolint:gosec + 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 "" diff --git a/pkg/autoupdate/checker_test.go b/pkg/autoupdate/checker_test.go index ed787d9c..86fa25c9 100644 --- a/pkg/autoupdate/checker_test.go +++ b/pkg/autoupdate/checker_test.go @@ -22,6 +22,8 @@ func TestIsMajorVersionChange(t *testing.T) { {"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 { @@ -31,13 +33,6 @@ func TestIsMajorVersionChange(t *testing.T) { } } -func TestMajorVersion(t *testing.T) { - assert.Equal(t, "1", majorVersion("1.23.0")) - assert.Equal(t, "2", majorVersion("2.0.0")) - assert.Equal(t, "0", majorVersion("0.1.0")) - assert.Equal(t, "", majorVersion("")) -} - func TestMarkerReadWrite(t *testing.T) { tmpDir := t.TempDir() original := GetStateDirFn From c536c4af94a736758e493f91aed0df0c76acc6fb Mon Sep 17 00:00:00 2001 From: Yahan Xing Date: Tue, 14 Jul 2026 13:57:03 -0700 Subject: [PATCH 4/4] Rename go-version import alias to semver Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude --- pkg/autoupdate/checker.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/autoupdate/checker.go b/pkg/autoupdate/checker.go index 067dedcc..d895a7ef 100644 --- a/pkg/autoupdate/checker.go +++ b/pkg/autoupdate/checker.go @@ -15,7 +15,7 @@ import ( "time" "github.com/google/go-github/v72/github" - goversion "github.com/hashicorp/go-version" + semver "github.com/hashicorp/go-version" log "github.com/sirupsen/logrus" "github.com/stripe/stripe-cli/pkg/version" @@ -73,11 +73,11 @@ func CheckForUpdate() { } func isMajorVersionChange(current, latest string) bool { - cur, err := goversion.NewVersion(current) + cur, err := semver.NewVersion(current) if err != nil { return false } - lat, err := goversion.NewVersion(latest) + lat, err := semver.NewVersion(latest) if err != nil { return false }