From 84073486fb67b8f8df0a36594c8a09b22599a724 Mon Sep 17 00:00:00 2001 From: Yahan Xing Date: Tue, 7 Jul 2026 23:29:35 -0700 Subject: [PATCH 1/2] Add auto-update mechanism for curl-installed CLI When the CLI is installed via curl (lives in ~/.stripe/bin/), it now checks for newer versions once per day after command execution. If a minor/patch update is available, the next invocation downloads, verifies, and re-execs with the new binary. Major version changes are skipped. Users can opt out via STRIPE_NO_AUTO_UPDATE=1 or config.toml. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude --- cmd/stripe/main.go | 7 + pkg/autoupdate/autoupdate_test.go | 79 ++++++++ pkg/autoupdate/checker.go | 287 ++++++++++++++++++++++++++++++ pkg/autoupdate/config.go | 77 ++++++++ pkg/autoupdate/updater.go | 191 ++++++++++++++++++++ pkg/autoupdate/zip.go | 40 +++++ 6 files changed, 681 insertions(+) create mode 100644 pkg/autoupdate/autoupdate_test.go create mode 100644 pkg/autoupdate/checker.go create mode 100644 pkg/autoupdate/config.go create mode 100644 pkg/autoupdate/updater.go create mode 100644 pkg/autoupdate/zip.go diff --git a/cmd/stripe/main.go b/cmd/stripe/main.go index cada660fd..858259295 100644 --- a/cmd/stripe/main.go +++ b/cmd/stripe/main.go @@ -7,11 +7,15 @@ import ( "os" "time" + "github.com/stripe/stripe-cli/pkg/autoupdate" "github.com/stripe/stripe-cli/pkg/cmd" "github.com/stripe/stripe-cli/pkg/stripe" ) func main() { + // Apply pending update (downloads + re-execs if an update was staged) + autoupdate.ApplyIfPending() + ctx := context.Background() if stripe.TelemetryOptedOut(os.Getenv("STRIPE_CLI_TELEMETRY_OPTOUT")) || stripe.TelemetryOptedOut(os.Getenv("DO_NOT_TRACK")) { @@ -35,4 +39,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.CheckInBackground() } diff --git a/pkg/autoupdate/autoupdate_test.go b/pkg/autoupdate/autoupdate_test.go new file mode 100644 index 000000000..a0e921d70 --- /dev/null +++ b/pkg/autoupdate/autoupdate_test.go @@ -0,0 +1,79 @@ +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) +} diff --git a/pkg/autoupdate/checker.go b/pkg/autoupdate/checker.go new file mode 100644 index 000000000..5dfd0f2b9 --- /dev/null +++ b/pkg/autoupdate/checker.go @@ -0,0 +1,287 @@ +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 + +type updateMarker struct { + Version string + DownloadURL string + Checksum string +} + +// CheckInBackground checks for a newer CLI version and writes a marker file +// if an update is available. Skips major version changes. +func CheckInBackground() { + 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 { + 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() (version 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 "", "", "" + } + + version = release.GetTagName() + assetName := binaryAssetName(strings.TrimPrefix(version, "v")) + checksumAssetName := checksumAssetName() + + var binaryURL, checksumURL string + for _, asset := range release.Assets { + name := asset.GetName() + if name == assetName { + binaryURL = asset.GetBrowserDownloadURL() + } + if name == checksumAssetName { + checksumURL = asset.GetBrowserDownloadURL() + } + } + + if binaryURL == "" { + log.Debug("autoupdate: binary asset not found: ", assetName) + return "", "", "" + } + + if checksumURL != "" { + checksum = fetchChecksumForAsset(checksumURL, assetName) + } + + return version, 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 { + goos := runtime.GOOS + switch goos { + case "darwin": + return "stripe-mac-checksums.txt" + case "linux": + return "stripe-linux-checksums.txt" + case "windows": + return "stripe-windows-checksums.txt" + default: + return "" + } +} + +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) +} + +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 +} + +func clearMarker() { + stateDir := getStateDir() + if stateDir == "" { + return + } + _ = os.Remove(filepath.Join(stateDir, "update-available")) +} + +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/config.go b/pkg/autoupdate/config.go new file mode 100644 index 000000000..6dbcb30e9 --- /dev/null +++ b/pkg/autoupdate/config.go @@ -0,0 +1,77 @@ +package autoupdate + +import ( + "os" + "path/filepath" + "strings" + + "github.com/mitchellh/go-homedir" + "github.com/spf13/viper" +) + +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") == false && v.IsSet("settings.auto_update") +} + +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 override it. +var getStateDirFn = getStateDirDefault + +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/updater.go b/pkg/autoupdate/updater.go new file mode 100644 index 000000000..d6e09ff85 --- /dev/null +++ b/pkg/autoupdate/updater.go @@ -0,0 +1,191 @@ +package autoupdate + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + + log "github.com/sirupsen/logrus" + + "github.com/stripe/stripe-cli/pkg/version" +) + +// ApplyIfPending checks for a pending update marker and applies it. +// If an update is applied, it re-execs the current process with the new binary. +// This function only returns if no update was applied. +func ApplyIfPending() { + if version.Version == "master" { + return + } + if isOptedOut() { + return + } + if !isCurlInstall() { + return + } + + marker := readMarker() + if marker == nil { + return + } + + // Don't apply if we're already on this version + current := strings.TrimPrefix(version.Version, "v") + target := strings.TrimPrefix(marker.Version, "v") + if current == target { + clearMarker() + return + } + + exe, err := os.Executable() + if err != nil { + log.Debug("autoupdate: cannot determine executable path: ", err) + clearMarker() + return + } + + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + log.Debug("autoupdate: cannot resolve symlinks: ", err) + clearMarker() + return + } + + fmt.Fprintf(os.Stderr, "Automatically updating Stripe CLI from %s to %s.\n", current, target) + fmt.Fprintf(os.Stderr, "To disable auto-update, set STRIPE_NO_AUTO_UPDATE=1 or add auto_update = false to ~/.config/stripe/config.toml\n") + + if err := downloadAndReplace(marker, exe); err != nil { + fmt.Fprintf(os.Stderr, "Auto-update failed: %v. Continuing with current version.\n", err) + clearMarker() + return + } + + clearMarker() + fmt.Fprintf(os.Stderr, "Updated successfully ✓\n") + + // Re-exec with the updated binary + reexec(exe) +} + +func downloadAndReplace(marker *updateMarker, exePath string) error { + resp, err := http.Get(marker.DownloadURL) //nolint:gosec + if err != nil { + return fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download returned status %d", resp.StatusCode) + } + + // Download archive to a temp file + tmpArchive, err := os.CreateTemp(filepath.Dir(exePath), "stripe-update-archive-*") + if err != nil { + return fmt.Errorf("cannot create temp file: %w", err) + } + tmpArchivePath := tmpArchive.Name() + defer os.Remove(tmpArchivePath) + + if _, err := io.Copy(tmpArchive, resp.Body); err != nil { + tmpArchive.Close() + return fmt.Errorf("download interrupted: %w", err) + } + tmpArchive.Close() + + // Verify checksum of the archive + if marker.Checksum != "" && !verifyChecksum(tmpArchivePath, marker.Checksum) { + return fmt.Errorf("checksum verification failed") + } + + // Extract binary from archive + tmpBinary, err := os.CreateTemp(filepath.Dir(exePath), "stripe-update-*") + if err != nil { + return fmt.Errorf("cannot create temp binary: %w", err) + } + tmpBinaryPath := tmpBinary.Name() + tmpBinary.Close() + + if err := extractBinary(tmpArchivePath, tmpBinaryPath); err != nil { + os.Remove(tmpBinaryPath) + return fmt.Errorf("extraction failed: %w", err) + } + + // Make executable + if err := os.Chmod(tmpBinaryPath, 0755); err != nil { + os.Remove(tmpBinaryPath) + return fmt.Errorf("chmod failed: %w", err) + } + + // Atomic rename + if err := os.Rename(tmpBinaryPath, exePath); err != nil { + os.Remove(tmpBinaryPath) + return fmt.Errorf("cannot replace binary: %w", err) + } + + return nil +} + +func extractBinary(archivePath, destPath string) error { + if runtime.GOOS == "windows" { + return extractFromZip(archivePath, destPath) + } + return extractFromTarGz(archivePath, destPath) +} + +func extractFromTarGz(archivePath, destPath string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + if filepath.Base(hdr.Name) == "stripe" && hdr.Typeflag == tar.TypeReg { + out, err := os.Create(destPath) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + return out.Close() + } + } + return fmt.Errorf("stripe binary not found in archive") +} + +func extractFromZip(archivePath, destPath string) error { + // Windows zip extraction - using archive/zip + // Import is at the top level but only used on windows path + return extractFromZipImpl(archivePath, destPath) +} + +func reexec(exe string) { + err := syscall.Exec(exe, os.Args, os.Environ()) + if err != nil { + log.Debug("autoupdate: re-exec failed: ", err) + } +} diff --git a/pkg/autoupdate/zip.go b/pkg/autoupdate/zip.go new file mode 100644 index 000000000..993ae7e97 --- /dev/null +++ b/pkg/autoupdate/zip.go @@ -0,0 +1,40 @@ +package autoupdate + +import ( + "archive/zip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +func extractFromZipImpl(archivePath, destPath string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + name := filepath.Base(f.Name) + if strings.EqualFold(name, "stripe.exe") || strings.EqualFold(name, "stripe") { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + out, err := os.Create(destPath) + if err != nil { + return err + } + if _, err := io.Copy(out, rc); err != nil { + out.Close() + return err + } + return out.Close() + } + } + return fmt.Errorf("stripe binary not found in zip archive") +} From 37e0ef9549e4307037c752ef8f3780d744f02b90 Mon Sep 17 00:00:00 2001 From: Yahan Xing Date: Wed, 8 Jul 2026 10:40:35 -0700 Subject: [PATCH 2/2] Fix staticcheck lint errors in autoupdate package Add package doc comment and simplify bool comparison. Co-Authored-By: Claude Opus 4.6 Committed-By-Agent: claude --- pkg/autoupdate/config.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/autoupdate/config.go b/pkg/autoupdate/config.go index 6dbcb30e9..f25323243 100644 --- a/pkg/autoupdate/config.go +++ b/pkg/autoupdate/config.go @@ -1,3 +1,4 @@ +// Package autoupdate implements automatic version updates for curl-installed Stripe CLI binaries. package autoupdate import ( @@ -25,7 +26,7 @@ func isOptedOut() bool { return false } - return v.GetBool("settings.auto_update") == false && v.IsSet("settings.auto_update") + return !v.GetBool("settings.auto_update") && v.IsSet("settings.auto_update") } func isCurlInstall() bool {