Skip to content
Open
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.24.11

require (
github.com/BurntSushi/toml v0.4.1
github.com/Masterminds/semver/v3 v3.4.0
github.com/creativeprojects/go-selfupdate v1.5.2
github.com/fatih/color v1.16.0
github.com/golang/protobuf v1.5.4
Expand All @@ -26,7 +27,6 @@ require (
require (
code.gitea.io/sdk/gitea v0.22.1 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
Expand Down
26 changes: 14 additions & 12 deletions internal/selfupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func (m InstallMethod) String() string {
type UpdateOptions struct {
SkipConfirm bool
TargetVersion string // e.g., "v1.2.3", "master", or "" for latest
ForceUpdate bool // skip "already on latest" check
}

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

targetVersion := release.Version()

// Show update info with release notes BEFORE confirmation
fmt.Printf("\nUpdate available: %s → %s\n", currentVersion, targetVersion)

if notes := release.ReleaseNotes; notes != "" {
formatted := formatReleaseNotes(notes, 15)
if formatted != "" {
fmt.Println()
fmt.Println("Release notes:")
fmt.Println(formatted)
// Show update info with release notes BEFORE confirmation (skip if caller already showed it)
if !opts.SkipConfirm {
fmt.Printf("\nUpdate available: %s → %s\n", currentVersion, targetVersion)

if notes := release.ReleaseNotes; notes != "" {
formatted := formatReleaseNotes(notes, 15)
if formatted != "" {
fmt.Println()
fmt.Println("Release notes:")
fmt.Println(formatted)
}
}
fmt.Println()
}
fmt.Println()

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

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

fmt.Println(color.GreenString("\nSuccessfully updated to %s.", targetVer))

return nil
}

Expand Down
232 changes: 217 additions & 15 deletions internal/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,32 @@ import (
"net/http"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"time"

semver "github.com/Masterminds/semver/v3"
"github.com/fatih/color"
"github.com/sirupsen/logrus"
)

type releaseResponse struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
}

const repoURL = "https://github.com/ThreeDotsLabs/cli"
const releasesURL = "https://api.github.com/repos/ThreeDotsLabs/cli/releases/latest"
const updateCheckInterval = 30 * time.Minute
const dismissalDuration = 30 * time.Minute

func CheckForUpdate(currentVersion string) {
type latestRelease struct {
Version string
ReleaseNotes string
}

func CheckForUpdate(currentVersion string, commandName string, forcePrompt bool) {
if os.Getenv("TDL_NO_UPDATE_CHECK") != "" {
logrus.Debug("Update check disabled via TDL_NO_UPDATE_CHECK")
return
Expand All @@ -31,34 +42,202 @@ func CheckForUpdate(currentVersion string) {
return
}

isUpdateCommand := commandName == "update" || commandName == "u"

updateInfo, _ := getUpdateInfo()

if updateInfo.UpdateAvailable && updateInfo.CurrentVersion == currentVersion {
printVersionNotice(updateInfo.CurrentVersion, updateInfo.AvailableVersion)
// Fast path: cached update available — no API call needed
if updateInfo.UpdateAvailable && isNewerVersion(updateInfo.AvailableVersion, currentVersion) {
showUpdatePromptOrNotice(updateInfo, currentVersion, isUpdateCommand, forcePrompt)
return
}

if time.Since(updateInfo.LastChecked) < time.Hour {
// Fast path: check interval not elapsed — return immediately
if !forcePrompt && time.Since(updateInfo.LastChecked) < updateCheckInterval {
return
}

latestVersion := getLatestVersion()
release := getLatestRelease()
if release == nil {
return
}

if latestVersion != "" && latestVersion != currentVersion {
isNewer := release.Version != "" && isNewerVersion(release.Version, currentVersion)
isDifferent := release.Version != "" && release.Version != currentVersion

if isNewer || (forcePrompt && isDifferent) {
updateInfo.CurrentVersion = currentVersion
updateInfo.AvailableVersion = latestVersion
updateInfo.AvailableVersion = release.Version
updateInfo.UpdateAvailable = true
updateInfo.ReleaseNotes = release.ReleaseNotes

updateInfo.LastChecked = time.Now()
_ = storeUpdateInfo(updateInfo)

printVersionNotice(currentVersion, latestVersion)
showUpdatePromptOrNotice(updateInfo, currentVersion, isUpdateCommand, forcePrompt)
} else {
updateInfo.CurrentVersion = currentVersion
updateInfo.AvailableVersion = ""
updateInfo.UpdateAvailable = false
updateInfo.ReleaseNotes = ""
// Clear stale dismissal since there's no pending update
updateInfo.DismissedVersion = ""
updateInfo.DismissedAt = time.Time{}

updateInfo.LastChecked = time.Now()
_ = storeUpdateInfo(updateInfo)
}
}

updateInfo.LastChecked = time.Now()
func showUpdatePromptOrNotice(updateInfo UpdateInfo, currentVersion string, isUpdateCommand bool, forcePrompt bool) {
// If user is running "tdl update", skip — they're already updating
if isUpdateCommand {
return
}

// Non-interactive terminal (CI, piped stdin) — passive notice only
if !IsStdinTerminal() {
printVersionNotice(currentVersion, updateInfo.AvailableVersion)
return
}

_ = storeUpdateInfo(updateInfo)
if forcePrompt || shouldShowBlockingPrompt(updateInfo) {
showBlockingUpdatePrompt(updateInfo, currentVersion)
} else {
printVersionNotice(currentVersion, updateInfo.AvailableVersion)
}
}

func shouldShowBlockingPrompt(info UpdateInfo) bool {
// Never dismissed — show prompt
if info.DismissedVersion == "" {
return true
}

// Dismissed a different version — new release, re-prompt
if info.DismissedVersion != info.AvailableVersion {
return true
}

// Dismissed same version — only re-prompt after dismissal duration
return time.Since(info.DismissedAt) > dismissalDuration
}

func showBlockingUpdatePrompt(updateInfo UpdateInfo, currentVersion string) {
c := color.New(color.FgHiYellow)
_, _ = c.Printf("A new version of the CLI is available: %s \u2192 %s\n", currentVersion, updateInfo.AvailableVersion)
_, _ = c.Printf("Some features may be missing or not work correctly.\n")

if updateInfo.ReleaseNotes != "" {
formatted := formatReleaseNotes(updateInfo.ReleaseNotes, 15)
if formatted != "" {
fmt.Println()
fmt.Println("Release notes:")
fmt.Println(formatted)
}
}
fmt.Println()

method := DetectInstallMethod()

// Check if binary requires elevated permissions (direct binary install)
if method == InstallMethodDirectBinary || method == InstallMethodUnknown {
binaryPath, err := os.Executable()
if err == nil {
binaryPath, _ = filepath.EvalSymlinks(binaryPath)
}
if err != nil || !canWriteBinary(binaryPath) {
cmdName := os.Args[0]
var updateCmd string
if runtime.GOOS == "windows" {
updateCmd = fmt.Sprintf("%s update", cmdName)
fmt.Println("The binary requires elevated permissions to update.")
fmt.Println("To update, re-open your terminal as Administrator and run:")
} else {
updateCmd = fmt.Sprintf("sudo %s update", cmdName)
fmt.Printf("The binary at %s requires elevated permissions to update.\n", binaryPath)
fmt.Println("To update, run:")
}
fmt.Println(" " + SprintCommand(updateCmd))
fmt.Printf("\nOr download from: %s/releases/latest\n", repoURL)
fmt.Println()

result := Prompt(
Actions{
{Shortcut: '\n', Action: "exit", ShortcutAliases: []rune{'\r'}},
{Shortcut: 's', Action: "skip and continue"},
},
os.Stdin,
os.Stdout,
)

// Store dismissal regardless of choice
updateInfo.DismissedVersion = updateInfo.AvailableVersion
updateInfo.DismissedAt = time.Now()
_ = storeUpdateInfo(updateInfo)

if result == '\n' {
os.Exit(0)
}
fmt.Println()
return
}
}

hint := updateCommandHint(method)
action := "update now"
if hint != "" {
action = fmt.Sprintf("run %s", SprintCommand(hint))
}

result := Prompt(
Actions{
{Shortcut: '\n', Action: action, ShortcutAliases: []rune{'\r'}},
{Shortcut: 's', Action: "skip"},
},
os.Stdin,
os.Stdout,
)

if result == 's' {
// User declined — record dismissal
updateInfo.DismissedVersion = updateInfo.AvailableVersion
updateInfo.DismissedAt = time.Now()
_ = storeUpdateInfo(updateInfo)
fmt.Println()
return
}

// User pressed ENTER — run update with SkipConfirm (no double confirmation)
fmt.Println()
ctx := context.Background()
err := RunUpdate(ctx, currentVersion, UpdateOptions{SkipConfirm: true, ForceUpdate: true})
if err != nil {
fmt.Println(color.RedString("Update failed: %v", err))
fmt.Println(color.HiBlackString("Continuing with current version..."))
fmt.Println()
return
}

// Update succeeded — binary is replaced, must exit
fmt.Println()
fmt.Println("Please re-run your command.")
os.Exit(0)
}

func updateCommandHint(method InstallMethod) string {
switch method {
case InstallMethodHomebrew:
return "brew upgrade tdl"
case InstallMethodGoInstall:
return "go install github.com/ThreeDotsLabs/cli/tdl@latest"
case InstallMethodNix:
return "nix profile upgrade --flake github:ThreeDotsLabs/cli"
case InstallMethodScoop:
return "scoop update tdl"
default:
return ""
}
}

func printVersionNotice(currentVersion string, availableVersion string) {
Expand All @@ -69,29 +248,37 @@ func printVersionNotice(currentVersion string, availableVersion string) {
fmt.Println()
}

func getLatestVersion() string {
func getLatestRelease() *latestRelease {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesURL, nil)
if err != nil {
return ""
return nil
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return ""
return nil
}
defer func() {
_ = resp.Body.Close()
}()

var release releaseResponse
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return ""
return nil
}

version := strings.TrimLeft(release.TagName, "v")
if version == "" {
return nil
}

return strings.TrimLeft(release.TagName, "v")
return &latestRelease{
Version: version,
ReleaseNotes: release.Body,
}
}

func updateInfoPath() string {
Expand All @@ -103,6 +290,9 @@ type UpdateInfo struct {
AvailableVersion string `json:"available_version"`
UpdateAvailable bool `json:"update_available"`
LastChecked time.Time `json:"last_checked"`
ReleaseNotes string `json:"release_notes,omitempty"`
DismissedVersion string `json:"dismissed_version,omitempty"`
DismissedAt time.Time `json:"dismissed_at,omitempty"`
}

func getUpdateInfo() (UpdateInfo, error) {
Expand Down Expand Up @@ -136,6 +326,18 @@ func storeUpdateInfo(info UpdateInfo) error {
return nil
}

func isNewerVersion(latest, current string) bool {
latestV, err := semver.NewVersion(latest)
if err != nil {
return latest != current
}
currentV, err := semver.NewVersion(current)
if err != nil {
return latest != current
}
return latestV.GreaterThan(currentV)
}

func fileExists(path string) bool {
_, err := os.Stat(path)
if err == nil {
Expand Down
Loading