Skip to content

Commit de6262f

Browse files
authored
proper-auto-update-prompt (#41)
1 parent d184fa7 commit de6262f

5 files changed

Lines changed: 354 additions & 29 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.24.11
44

55
require (
66
github.com/BurntSushi/toml v0.4.1
7+
github.com/Masterminds/semver/v3 v3.4.0
78
github.com/creativeprojects/go-selfupdate v1.5.2
89
github.com/fatih/color v1.16.0
910
github.com/golang/protobuf v1.5.4
@@ -26,7 +27,6 @@ require (
2627
require (
2728
code.gitea.io/sdk/gitea v0.22.1 // indirect
2829
github.com/42wim/httpsig v1.2.3 // indirect
29-
github.com/Masterminds/semver/v3 v3.4.0 // indirect
3030
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
3131
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
3232
github.com/davecgh/go-spew v1.1.1 // indirect

internal/selfupdate.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func (m InstallMethod) String() string {
5252
type UpdateOptions struct {
5353
SkipConfirm bool
5454
TargetVersion string // e.g., "v1.2.3", "master", or "" for latest
55+
ForceUpdate bool // skip "already on latest" check
5556
}
5657

5758
// DetectInstallMethod determines the installation method by examining the binary path.
@@ -207,26 +208,28 @@ func RunUpdate(ctx context.Context, currentVersion string, opts UpdateOptions) e
207208
return nil
208209
}
209210
logrus.WithField("latest", release.Version()).Debug("Latest release detected")
210-
if release.LessOrEqual(currentVersion) {
211+
if !opts.ForceUpdate && release.LessOrEqual(currentVersion) {
211212
fmt.Printf("You are already running the latest version (%s).\n", currentVersion)
212213
return nil
213214
}
214215
}
215216

216217
targetVersion := release.Version()
217218

218-
// Show update info with release notes BEFORE confirmation
219-
fmt.Printf("\nUpdate available: %s → %s\n", currentVersion, targetVersion)
220-
221-
if notes := release.ReleaseNotes; notes != "" {
222-
formatted := formatReleaseNotes(notes, 15)
223-
if formatted != "" {
224-
fmt.Println()
225-
fmt.Println("Release notes:")
226-
fmt.Println(formatted)
219+
// Show update info with release notes BEFORE confirmation (skip if caller already showed it)
220+
if !opts.SkipConfirm {
221+
fmt.Printf("\nUpdate available: %s → %s\n", currentVersion, targetVersion)
222+
223+
if notes := release.ReleaseNotes; notes != "" {
224+
formatted := formatReleaseNotes(notes, 15)
225+
if formatted != "" {
226+
fmt.Println()
227+
fmt.Println("Release notes:")
228+
fmt.Println(formatted)
229+
}
227230
}
231+
fmt.Println()
228232
}
229-
fmt.Println()
230233

231234
logrus.WithField("method", method.String()).Debug("Updating via install method")
232235

@@ -288,7 +291,6 @@ func updateViaCommand(ctx context.Context, tool string, opts UpdateOptions, curr
288291
}
289292

290293
fmt.Println(color.GreenString("\nSuccessfully updated to %s.", targetVer))
291-
292294
return nil
293295
}
294296

internal/update.go

Lines changed: 217 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,32 @@ import (
77
"net/http"
88
"os"
99
"path"
10+
"path/filepath"
11+
"runtime"
1012
"strings"
1113
"time"
1214

15+
semver "github.com/Masterminds/semver/v3"
1316
"github.com/fatih/color"
1417
"github.com/sirupsen/logrus"
1518
)
1619

1720
type releaseResponse struct {
1821
TagName string `json:"tag_name"`
22+
Body string `json:"body"`
1923
}
2024

2125
const repoURL = "https://github.com/ThreeDotsLabs/cli"
2226
const releasesURL = "https://api.github.com/repos/ThreeDotsLabs/cli/releases/latest"
27+
const updateCheckInterval = 30 * time.Minute
28+
const dismissalDuration = 30 * time.Minute
2329

24-
func CheckForUpdate(currentVersion string) {
30+
type latestRelease struct {
31+
Version string
32+
ReleaseNotes string
33+
}
34+
35+
func CheckForUpdate(currentVersion string, commandName string, forcePrompt bool) {
2536
if os.Getenv("TDL_NO_UPDATE_CHECK") != "" {
2637
logrus.Debug("Update check disabled via TDL_NO_UPDATE_CHECK")
2738
return
@@ -31,34 +42,202 @@ func CheckForUpdate(currentVersion string) {
3142
return
3243
}
3344

45+
isUpdateCommand := commandName == "update" || commandName == "u"
46+
3447
updateInfo, _ := getUpdateInfo()
3548

36-
if updateInfo.UpdateAvailable && updateInfo.CurrentVersion == currentVersion {
37-
printVersionNotice(updateInfo.CurrentVersion, updateInfo.AvailableVersion)
49+
// Fast path: cached update available — no API call needed
50+
if updateInfo.UpdateAvailable && isNewerVersion(updateInfo.AvailableVersion, currentVersion) {
51+
showUpdatePromptOrNotice(updateInfo, currentVersion, isUpdateCommand, forcePrompt)
3852
return
3953
}
4054

41-
if time.Since(updateInfo.LastChecked) < time.Hour {
55+
// Fast path: check interval not elapsed — return immediately
56+
if !forcePrompt && time.Since(updateInfo.LastChecked) < updateCheckInterval {
4257
return
4358
}
4459

45-
latestVersion := getLatestVersion()
60+
release := getLatestRelease()
61+
if release == nil {
62+
return
63+
}
4664

47-
if latestVersion != "" && latestVersion != currentVersion {
65+
isNewer := release.Version != "" && isNewerVersion(release.Version, currentVersion)
66+
isDifferent := release.Version != "" && release.Version != currentVersion
67+
68+
if isNewer || (forcePrompt && isDifferent) {
4869
updateInfo.CurrentVersion = currentVersion
49-
updateInfo.AvailableVersion = latestVersion
70+
updateInfo.AvailableVersion = release.Version
5071
updateInfo.UpdateAvailable = true
72+
updateInfo.ReleaseNotes = release.ReleaseNotes
73+
74+
updateInfo.LastChecked = time.Now()
75+
_ = storeUpdateInfo(updateInfo)
5176

52-
printVersionNotice(currentVersion, latestVersion)
77+
showUpdatePromptOrNotice(updateInfo, currentVersion, isUpdateCommand, forcePrompt)
5378
} else {
5479
updateInfo.CurrentVersion = currentVersion
5580
updateInfo.AvailableVersion = ""
5681
updateInfo.UpdateAvailable = false
82+
updateInfo.ReleaseNotes = ""
83+
// Clear stale dismissal since there's no pending update
84+
updateInfo.DismissedVersion = ""
85+
updateInfo.DismissedAt = time.Time{}
86+
87+
updateInfo.LastChecked = time.Now()
88+
_ = storeUpdateInfo(updateInfo)
5789
}
90+
}
5891

59-
updateInfo.LastChecked = time.Now()
92+
func showUpdatePromptOrNotice(updateInfo UpdateInfo, currentVersion string, isUpdateCommand bool, forcePrompt bool) {
93+
// If user is running "tdl update", skip — they're already updating
94+
if isUpdateCommand {
95+
return
96+
}
97+
98+
// Non-interactive terminal (CI, piped stdin) — passive notice only
99+
if !IsStdinTerminal() {
100+
printVersionNotice(currentVersion, updateInfo.AvailableVersion)
101+
return
102+
}
60103

61-
_ = storeUpdateInfo(updateInfo)
104+
if forcePrompt || shouldShowBlockingPrompt(updateInfo) {
105+
showBlockingUpdatePrompt(updateInfo, currentVersion)
106+
} else {
107+
printVersionNotice(currentVersion, updateInfo.AvailableVersion)
108+
}
109+
}
110+
111+
func shouldShowBlockingPrompt(info UpdateInfo) bool {
112+
// Never dismissed — show prompt
113+
if info.DismissedVersion == "" {
114+
return true
115+
}
116+
117+
// Dismissed a different version — new release, re-prompt
118+
if info.DismissedVersion != info.AvailableVersion {
119+
return true
120+
}
121+
122+
// Dismissed same version — only re-prompt after dismissal duration
123+
return time.Since(info.DismissedAt) > dismissalDuration
124+
}
125+
126+
func showBlockingUpdatePrompt(updateInfo UpdateInfo, currentVersion string) {
127+
c := color.New(color.FgHiYellow)
128+
_, _ = c.Printf("A new version of the CLI is available: %s \u2192 %s\n", currentVersion, updateInfo.AvailableVersion)
129+
_, _ = c.Printf("Some features may be missing or not work correctly.\n")
130+
131+
if updateInfo.ReleaseNotes != "" {
132+
formatted := formatReleaseNotes(updateInfo.ReleaseNotes, 15)
133+
if formatted != "" {
134+
fmt.Println()
135+
fmt.Println("Release notes:")
136+
fmt.Println(formatted)
137+
}
138+
}
139+
fmt.Println()
140+
141+
method := DetectInstallMethod()
142+
143+
// Check if binary requires elevated permissions (direct binary install)
144+
if method == InstallMethodDirectBinary || method == InstallMethodUnknown {
145+
binaryPath, err := os.Executable()
146+
if err == nil {
147+
binaryPath, _ = filepath.EvalSymlinks(binaryPath)
148+
}
149+
if err != nil || !canWriteBinary(binaryPath) {
150+
cmdName := os.Args[0]
151+
var updateCmd string
152+
if runtime.GOOS == "windows" {
153+
updateCmd = fmt.Sprintf("%s update", cmdName)
154+
fmt.Println("The binary requires elevated permissions to update.")
155+
fmt.Println("To update, re-open your terminal as Administrator and run:")
156+
} else {
157+
updateCmd = fmt.Sprintf("sudo %s update", cmdName)
158+
fmt.Printf("The binary at %s requires elevated permissions to update.\n", binaryPath)
159+
fmt.Println("To update, run:")
160+
}
161+
fmt.Println(" " + SprintCommand(updateCmd))
162+
fmt.Printf("\nOr download from: %s/releases/latest\n", repoURL)
163+
fmt.Println()
164+
165+
result := Prompt(
166+
Actions{
167+
{Shortcut: '\n', Action: "exit", ShortcutAliases: []rune{'\r'}},
168+
{Shortcut: 's', Action: "skip and continue"},
169+
},
170+
os.Stdin,
171+
os.Stdout,
172+
)
173+
174+
// Store dismissal regardless of choice
175+
updateInfo.DismissedVersion = updateInfo.AvailableVersion
176+
updateInfo.DismissedAt = time.Now()
177+
_ = storeUpdateInfo(updateInfo)
178+
179+
if result == '\n' {
180+
os.Exit(0)
181+
}
182+
fmt.Println()
183+
return
184+
}
185+
}
186+
187+
hint := updateCommandHint(method)
188+
action := "update now"
189+
if hint != "" {
190+
action = fmt.Sprintf("run %s", SprintCommand(hint))
191+
}
192+
193+
result := Prompt(
194+
Actions{
195+
{Shortcut: '\n', Action: action, ShortcutAliases: []rune{'\r'}},
196+
{Shortcut: 's', Action: "skip"},
197+
},
198+
os.Stdin,
199+
os.Stdout,
200+
)
201+
202+
if result == 's' {
203+
// User declined — record dismissal
204+
updateInfo.DismissedVersion = updateInfo.AvailableVersion
205+
updateInfo.DismissedAt = time.Now()
206+
_ = storeUpdateInfo(updateInfo)
207+
fmt.Println()
208+
return
209+
}
210+
211+
// User pressed ENTER — run update with SkipConfirm (no double confirmation)
212+
fmt.Println()
213+
ctx := context.Background()
214+
err := RunUpdate(ctx, currentVersion, UpdateOptions{SkipConfirm: true, ForceUpdate: true})
215+
if err != nil {
216+
fmt.Println(color.RedString("Update failed: %v", err))
217+
fmt.Println(color.HiBlackString("Continuing with current version..."))
218+
fmt.Println()
219+
return
220+
}
221+
222+
// Update succeeded — binary is replaced, must exit
223+
fmt.Println()
224+
fmt.Println("Please re-run your command.")
225+
os.Exit(0)
226+
}
227+
228+
func updateCommandHint(method InstallMethod) string {
229+
switch method {
230+
case InstallMethodHomebrew:
231+
return "brew upgrade tdl"
232+
case InstallMethodGoInstall:
233+
return "go install github.com/ThreeDotsLabs/cli/tdl@latest"
234+
case InstallMethodNix:
235+
return "nix profile upgrade --flake github:ThreeDotsLabs/cli"
236+
case InstallMethodScoop:
237+
return "scoop update tdl"
238+
default:
239+
return ""
240+
}
62241
}
63242

64243
func printVersionNotice(currentVersion string, availableVersion string) {
@@ -69,29 +248,37 @@ func printVersionNotice(currentVersion string, availableVersion string) {
69248
fmt.Println()
70249
}
71250

72-
func getLatestVersion() string {
251+
func getLatestRelease() *latestRelease {
73252
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
74253
defer cancel()
75254

76255
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesURL, nil)
77256
if err != nil {
78-
return ""
257+
return nil
79258
}
80259

81260
resp, err := http.DefaultClient.Do(req)
82261
if err != nil {
83-
return ""
262+
return nil
84263
}
85264
defer func() {
86265
_ = resp.Body.Close()
87266
}()
88267

89268
var release releaseResponse
90269
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
91-
return ""
270+
return nil
271+
}
272+
273+
version := strings.TrimLeft(release.TagName, "v")
274+
if version == "" {
275+
return nil
92276
}
93277

94-
return strings.TrimLeft(release.TagName, "v")
278+
return &latestRelease{
279+
Version: version,
280+
ReleaseNotes: release.Body,
281+
}
95282
}
96283

97284
func updateInfoPath() string {
@@ -103,6 +290,9 @@ type UpdateInfo struct {
103290
AvailableVersion string `json:"available_version"`
104291
UpdateAvailable bool `json:"update_available"`
105292
LastChecked time.Time `json:"last_checked"`
293+
ReleaseNotes string `json:"release_notes,omitempty"`
294+
DismissedVersion string `json:"dismissed_version,omitempty"`
295+
DismissedAt time.Time `json:"dismissed_at,omitempty"`
106296
}
107297

108298
func getUpdateInfo() (UpdateInfo, error) {
@@ -136,6 +326,18 @@ func storeUpdateInfo(info UpdateInfo) error {
136326
return nil
137327
}
138328

329+
func isNewerVersion(latest, current string) bool {
330+
latestV, err := semver.NewVersion(latest)
331+
if err != nil {
332+
return latest != current
333+
}
334+
currentV, err := semver.NewVersion(current)
335+
if err != nil {
336+
return latest != current
337+
}
338+
return latestV.GreaterThan(currentV)
339+
}
340+
139341
func fileExists(path string) bool {
140342
_, err := os.Stat(path)
141343
if err == nil {

0 commit comments

Comments
 (0)