Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/stripe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand All @@ -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()
}
79 changes: 79 additions & 0 deletions pkg/autoupdate/autoupdate_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
287 changes: 287 additions & 0 deletions pkg/autoupdate/checker.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading