Skip to content

Commit a614369

Browse files
Split auto-updater into two-phase check-then-apply flow
- Phase 1 (CheckForUpdate) fetches manifest and writes state file if newer version exists, with a short 3s timeout to avoid slowing down CLI startup - Phase 2 (ApplyUpdate) reads state file on next run and applies the update with a 30s timeout - Removes background goroutine and channel-based approach in favor of synchronous two-phase model - State file (update.json) is cleared before applying so broken updates don't retry forever - Adds UpdateState struct and read/write/clear helpers for on-disk persistence - Adds tests for state file lifecycle, version comparison, download + checksum verification, and no-op when state file is absent
1 parent 40ff0fa commit a614369

3 files changed

Lines changed: 335 additions & 42 deletions

File tree

cmd/deepsource/main.go

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -67,23 +67,32 @@ func mainRun() (exitCode int) {
6767
return run()
6868
}
6969

70-
type updateResult struct {
71-
version string
72-
err error
73-
}
74-
7570
func run() int {
7671
v.SetBuildInfo(version, Date, buildMode)
7772

78-
// Start background auto-update check
79-
var updateCh chan updateResult
73+
// Two-phase auto-update: apply pending update or check for new one
8074
if update.ShouldAutoUpdate() {
81-
updateCh = make(chan updateResult, 1)
82-
go func() {
75+
state, err := update.ReadUpdateState()
76+
if err != nil {
77+
debug.Log("update: %v", err)
78+
}
79+
80+
if state != nil {
81+
// Phase 2: a previous run found a newer version — apply it now
8382
client := &http.Client{Timeout: 30 * time.Second}
84-
newVer, err := update.Update(client)
85-
updateCh <- updateResult{version: newVer, err: err}
86-
}()
83+
newVer, err := update.ApplyUpdate(client)
84+
if err != nil {
85+
debug.Log("update: %v", err)
86+
} else if newVer != "" {
87+
fmt.Fprintf(os.Stderr, "%s\n", style.Yellow("Updated DeepSource CLI to v%s", newVer))
88+
}
89+
} else {
90+
// Phase 1: check manifest and write state file for next run
91+
client := &http.Client{Timeout: 3 * time.Second}
92+
if err := update.CheckForUpdate(client); err != nil {
93+
debug.Log("update: %v", err)
94+
}
95+
}
8796
}
8897

8998
exitCode := 0
@@ -101,19 +110,5 @@ func run() int {
101110
exitCode = 1
102111
}
103112

104-
// Wait for update result
105-
if updateCh != nil {
106-
select {
107-
case res := <-updateCh:
108-
if res.err != nil {
109-
debug.Log("update: %v", res.err)
110-
} else if res.version != "" {
111-
fmt.Fprintf(os.Stderr, "%s\n", style.Yellow("Updated DeepSource CLI to v%s", res.version))
112-
}
113-
case <-time.After(30 * time.Second):
114-
debug.Log("update: timed out waiting for result")
115-
}
116-
}
117-
118113
return exitCode
119114
}

internal/update/updater.go

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,57 +7,135 @@ import (
77
"compress/gzip"
88
"crypto/sha256"
99
"encoding/hex"
10+
"encoding/json"
11+
"errors"
1012
"fmt"
1113
"io"
1214
"net/http"
1315
"os"
1416
"path/filepath"
1517
"runtime"
1618
"strings"
19+
"time"
1720

1821
"github.com/deepsourcelabs/cli/buildinfo"
1922
"github.com/deepsourcelabs/cli/config"
2023
"github.com/deepsourcelabs/cli/internal/debug"
2124
)
2225

23-
// Update checks for a newer CLI version and replaces the current binary.
24-
// Returns the new version string if an update was applied, or "" if already
25-
// up to date. Errors are non-fatal — callers should log and move on.
26-
func Update(client *http.Client) (string, error) {
26+
// UpdateState is the on-disk state written by CheckForUpdate and consumed by ApplyUpdate.
27+
type UpdateState struct {
28+
Version string `json:"version"`
29+
ArchiveURL string `json:"archive_url"`
30+
SHA256 string `json:"sha256"`
31+
CheckedAt time.Time `json:"checked_at"`
32+
}
33+
34+
// updateStatePath returns the path to the update state file (~/.deepsource/update.json).
35+
func updateStatePath() string {
36+
home, _ := os.UserHomeDir()
37+
return filepath.Join(home, buildinfo.ConfigDirName, "update.json")
38+
}
39+
40+
// ReadUpdateState reads the update state file. Returns nil if the file does not exist.
41+
func ReadUpdateState() (*UpdateState, error) {
42+
data, err := os.ReadFile(updateStatePath())
43+
if err != nil {
44+
if errors.Is(err, os.ErrNotExist) {
45+
return nil, nil
46+
}
47+
return nil, fmt.Errorf("reading update state: %w", err)
48+
}
49+
var s UpdateState
50+
if err := json.Unmarshal(data, &s); err != nil {
51+
return nil, fmt.Errorf("parsing update state: %w", err)
52+
}
53+
return &s, nil
54+
}
55+
56+
func writeUpdateState(s *UpdateState) error {
57+
data, err := json.MarshalIndent(s, "", " ")
58+
if err != nil {
59+
return fmt.Errorf("marshaling update state: %w", err)
60+
}
61+
p := updateStatePath()
62+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
63+
return fmt.Errorf("creating config dir: %w", err)
64+
}
65+
if err := os.WriteFile(p, data, 0o644); err != nil {
66+
return fmt.Errorf("writing update state: %w", err)
67+
}
68+
return nil
69+
}
70+
71+
func clearUpdateState() {
72+
_ = os.Remove(updateStatePath())
73+
}
74+
75+
// CheckForUpdate fetches the manifest, compares versions, and writes a state
76+
// file if a newer version is available. This is meant to be fast (~100-200ms).
77+
func CheckForUpdate(client *http.Client) error {
2778
bi := buildinfo.GetBuildInfo()
2879
if bi == nil {
29-
return "", fmt.Errorf("build info not set")
80+
return fmt.Errorf("build info not set")
3081
}
3182

3283
manifest, err := FetchManifest(client)
3384
if err != nil {
34-
return "", err
85+
return err
3586
}
3687

3788
newer, err := IsNewer(bi.Version, manifest.Version)
3889
if err != nil {
39-
return "", err
90+
return err
4091
}
4192
if !newer {
4293
debug.Log("update: already up to date (current=%s, remote=%s)", bi.Version, manifest.Version)
43-
return "", nil
94+
return nil
4495
}
4596

4697
key := PlatformKey()
4798
platform, ok := manifest.Platforms[key]
4899
if !ok {
49-
return "", fmt.Errorf("no release for platform %s", key)
100+
return fmt.Errorf("no release for platform %s", key)
101+
}
102+
103+
state := &UpdateState{
104+
Version: manifest.Version,
105+
ArchiveURL: "https://cli.deepsource.com/" + platform.Archive,
106+
SHA256: platform.SHA256,
107+
CheckedAt: time.Now().UTC(),
108+
}
109+
110+
debug.Log("update: newer version %s available, writing state file", manifest.Version)
111+
return writeUpdateState(state)
112+
}
113+
114+
// ApplyUpdate reads the state file, downloads the archive, verifies, extracts,
115+
// and replaces the binary. Returns the new version string on success.
116+
// Clears the state file regardless of outcome so we don't retry broken updates forever.
117+
func ApplyUpdate(client *http.Client) (string, error) {
118+
state, err := ReadUpdateState()
119+
if err != nil {
120+
clearUpdateState()
121+
return "", err
122+
}
123+
if state == nil {
124+
return "", nil
50125
}
51126

52-
debug.Log("update: downloading %s", platform.Archive)
127+
// Clear state file up front so a failed update doesn't retry forever.
128+
// The next run will do a fresh CheckForUpdate instead.
129+
clearUpdateState()
130+
131+
debug.Log("update: applying update to v%s", state.Version)
53132

54-
archiveURL := "https://cli.deepsource.com/" + platform.Archive
55-
data, err := downloadFile(client, archiveURL)
133+
data, err := downloadFile(client, state.ArchiveURL)
56134
if err != nil {
57135
return "", err
58136
}
59137

60-
if err := verifyChecksum(data, platform.SHA256); err != nil {
138+
if err := verifyChecksum(data, state.SHA256); err != nil {
61139
return "", err
62140
}
63141

@@ -67,7 +145,7 @@ func Update(client *http.Client) (string, error) {
67145
}
68146

69147
var binaryData []byte
70-
if strings.HasSuffix(platform.Archive, ".zip") {
148+
if strings.HasSuffix(state.ArchiveURL, ".zip") {
71149
binaryData, err = extractFromZip(data, binaryName)
72150
} else {
73151
binaryData, err = extractFromTarGz(data, binaryName)
@@ -80,8 +158,8 @@ func Update(client *http.Client) (string, error) {
80158
return "", err
81159
}
82160

83-
debug.Log("update: updated to v%s", manifest.Version)
84-
return manifest.Version, nil
161+
debug.Log("update: updated to v%s", state.Version)
162+
return state.Version, nil
85163
}
86164

87165
// ShouldAutoUpdate reports whether the auto-updater should run.

0 commit comments

Comments
 (0)