Skip to content

Commit 1e9a2de

Browse files
authored
chore: check for updates (#48)
1 parent de78f40 commit 1e9a2de

5 files changed

Lines changed: 414 additions & 18 deletions

File tree

cmd/root.go

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
/*
2-
Copyright © 2026 NAME HERE <EMAIL ADDRESS>
3-
*/
41
package cmd
52

63
import (
74
"fmt"
85
"os"
6+
"time"
97

108
"github.com/loops-so/cli/internal/api"
119
"github.com/loops-so/cli/internal/config"
@@ -52,11 +50,24 @@ func fixHelpFlags(cmd *cobra.Command) {
5250
}
5351
}
5452

55-
// Execute adds all child commands to the root command and sets flags appropriately.
56-
// This is called by main.main(). It only needs to happen once to the rootCmd.
5753
func Execute() {
54+
defer func() {
55+
if updateCheckDone != nil {
56+
select {
57+
case <-updateCheckDone:
58+
case <-time.After(500 * time.Millisecond):
59+
}
60+
}
61+
if updateCheckCancel != nil {
62+
updateCheckCancel()
63+
}
64+
}()
65+
5866
fixHelpFlags(rootCmd)
5967
err := rootCmd.Execute()
68+
69+
checkForUpdate(os.Stderr)
70+
6071
if err != nil {
6172
if isJSONOutput() {
6273
printJSON(os.Stderr, Result{Success: false, Message: err.Error()})
@@ -68,14 +79,6 @@ func Execute() {
6879
}
6980

7081
func init() {
71-
// Here you will define your flags and configuration settings.
72-
// Cobra supports persistent flags, which, if defined here,
73-
// will be global for your application.
74-
75-
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cli.yaml)")
76-
77-
// Cobra also supports local flags, which will only run
78-
// when this action is called directly.
7982
rootCmd.PersistentFlags().VarP(&outputFormat, "output", "o", "Output format (text, json)")
8083
rootCmd.PersistentFlags().StringVarP(&teamFlag, "team", "t", "", "Team key name to use")
8184
rootCmd.PersistentFlags().BoolVar(&debugFlag, "debug", false, "Print API request details before sending")

cmd/update_check.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"os"
10+
"path/filepath"
11+
"runtime"
12+
"strings"
13+
"time"
14+
15+
"github.com/loops-so/cli/internal/config"
16+
)
17+
18+
var (
19+
updateCheckDone chan struct{}
20+
updateCheckCancel context.CancelFunc
21+
)
22+
23+
const updateCheckInterval = 24 * time.Hour
24+
25+
type updateCache struct {
26+
LatestVersion string `json:"latest_version"`
27+
CheckedAt time.Time `json:"checked_at"`
28+
}
29+
30+
func checkForUpdate(w io.Writer) {
31+
if version == "dev" || isJSONOutput() {
32+
return
33+
}
34+
35+
dir, err := config.ConfigDir()
36+
if err != nil {
37+
return
38+
}
39+
path := filepath.Join(dir, "update-check.json")
40+
41+
cache, err := readUpdateCache(path)
42+
if err != nil {
43+
if debugFlag {
44+
fmt.Fprintf(w, "[debug] update check: no cache (%v)\n", err)
45+
}
46+
} else {
47+
if debugFlag {
48+
age := time.Since(cache.CheckedAt).Truncate(time.Second)
49+
fmt.Fprintf(w, "[debug] update check: cached latest=%s age=%s\n", cache.LatestVersion, age)
50+
}
51+
if isNewerVersion(cache.LatestVersion, version) {
52+
fmt.Fprintf(w, "\nA new version of loops is available: v%s → v%s\nRun this to update:\n\n %s\n\n", version, cache.LatestVersion, upgradeCommand())
53+
}
54+
}
55+
56+
if err != nil || time.Since(cache.CheckedAt) > updateCheckInterval {
57+
if debugFlag {
58+
fmt.Fprintf(w, "[debug] update check: fetching latest release in background\n")
59+
}
60+
ctx, cancel := context.WithCancel(context.Background())
61+
updateCheckCancel = cancel
62+
updateCheckDone = make(chan struct{})
63+
go func() {
64+
defer close(updateCheckDone)
65+
fetchAndCacheLatestVersion(ctx, path)
66+
}()
67+
}
68+
}
69+
70+
func readUpdateCache(path string) (*updateCache, error) {
71+
data, err := os.ReadFile(path)
72+
if err != nil {
73+
return nil, err
74+
}
75+
var c updateCache
76+
if err := json.Unmarshal(data, &c); err != nil {
77+
return nil, err
78+
}
79+
return &c, nil
80+
}
81+
82+
func fetchAndCacheLatestVersion(ctx context.Context, path string) {
83+
client := &http.Client{Timeout: 5 * time.Second}
84+
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/repos/loops-so/cli/releases/latest", nil)
85+
if err != nil {
86+
return
87+
}
88+
req.Header.Set("Accept", "application/vnd.github+json")
89+
90+
resp, err := client.Do(req)
91+
if err != nil {
92+
return
93+
}
94+
defer resp.Body.Close()
95+
96+
if resp.StatusCode != http.StatusOK {
97+
return
98+
}
99+
100+
var release struct {
101+
TagName string `json:"tag_name"`
102+
}
103+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
104+
return
105+
}
106+
107+
latest := strings.TrimPrefix(release.TagName, "v")
108+
if latest == "" {
109+
return
110+
}
111+
112+
cache := updateCache{
113+
LatestVersion: latest,
114+
CheckedAt: time.Now(),
115+
}
116+
data, err := json.Marshal(cache)
117+
if err != nil {
118+
return
119+
}
120+
121+
_ = os.MkdirAll(filepath.Dir(path), 0o700)
122+
_ = os.WriteFile(path, data, 0o600)
123+
}
124+
125+
func upgradeCommand() string {
126+
if isHomebrew() {
127+
return "brew upgrade loops"
128+
}
129+
installDir := binDir()
130+
if runtime.GOOS == "windows" {
131+
if installDir != "" {
132+
return fmt.Sprintf(`irm https://raw.githubusercontent.com/loops-so/cli/main/install.ps1 | iex -Args "-InstallDir '%s'"`, installDir)
133+
}
134+
return `irm https://raw.githubusercontent.com/loops-so/cli/main/install.ps1 | iex`
135+
}
136+
if installDir != "" {
137+
return fmt.Sprintf(`curl -fsSL --proto '=https' --tlsv1.2 https://cli.loops.so | bash -s -- latest %s`, installDir)
138+
}
139+
return `curl -fsSL --proto '=https' --tlsv1.2 https://cli.loops.so | bash`
140+
}
141+
142+
func binDir() string {
143+
exe, err := os.Executable()
144+
if err != nil {
145+
return ""
146+
}
147+
return filepath.Dir(exe)
148+
}
149+
150+
func isHomebrew() bool {
151+
exe, err := os.Executable()
152+
if err != nil {
153+
return false
154+
}
155+
resolved, err := filepath.EvalSymlinks(exe)
156+
if err != nil {
157+
return false
158+
}
159+
lower := strings.ToLower(resolved)
160+
return strings.Contains(lower, "cellar") || strings.Contains(lower, "homebrew") || strings.Contains(lower, "linuxbrew")
161+
}
162+
163+
// isNewerVersion reports whether latest is newer than current (semver without v prefix).
164+
func isNewerVersion(latest, current string) bool {
165+
l := parseSemver(latest)
166+
c := parseSemver(current)
167+
for i := 0; i < 3; i++ {
168+
if l[i] > c[i] {
169+
return true
170+
}
171+
if l[i] < c[i] {
172+
return false
173+
}
174+
}
175+
return false
176+
}
177+
178+
func parseSemver(v string) [3]int {
179+
v = strings.TrimPrefix(v, "v")
180+
var parts [3]int
181+
fmt.Sscanf(v, "%d.%d.%d", &parts[0], &parts[1], &parts[2])
182+
return parts
183+
}

0 commit comments

Comments
 (0)