@@ -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
1720type releaseResponse struct {
1821 TagName string `json:"tag_name"`
22+ Body string `json:"body"`
1923}
2024
2125const repoURL = "https://github.com/ThreeDotsLabs/cli"
2226const 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 ("\n Or 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
64243func 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
97284func 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
108298func 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+
139341func fileExists (path string ) bool {
140342 _ , err := os .Stat (path )
141343 if err == nil {
0 commit comments