From 8960235b2de0b4e4d7013dee7fb336059fcd0e0a Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:56:42 -0700 Subject: [PATCH 1/2] add version check --- cmd/root.go | 29 +++--- cmd/update_check.go | 168 +++++++++++++++++++++++++++++++++ cmd/update_check_test.go | 196 +++++++++++++++++++++++++++++++++++++++ internal/config/dir.go | 17 ++++ internal/config/store.go | 7 +- 5 files changed, 399 insertions(+), 18 deletions(-) create mode 100644 cmd/update_check.go create mode 100644 cmd/update_check_test.go create mode 100644 internal/config/dir.go diff --git a/cmd/root.go b/cmd/root.go index 76a998f..45b1c09 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,11 +1,9 @@ -/* -Copyright © 2026 NAME HERE -*/ package cmd import ( "fmt" "os" + "time" "github.com/loops-so/cli/internal/api" "github.com/loops-so/cli/internal/config" @@ -52,11 +50,24 @@ func fixHelpFlags(cmd *cobra.Command) { } } -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { + defer func() { + if updateCheckDone != nil { + select { + case <-updateCheckDone: + case <-time.After(500 * time.Millisecond): + } + } + if updateCheckCancel != nil { + updateCheckCancel() + } + }() + fixHelpFlags(rootCmd) err := rootCmd.Execute() + + checkForUpdate(os.Stderr) + if err != nil { if isJSONOutput() { printJSON(os.Stderr, Result{Success: false, Message: err.Error()}) @@ -68,14 +79,6 @@ func Execute() { } func init() { - // Here you will define your flags and configuration settings. - // Cobra supports persistent flags, which, if defined here, - // will be global for your application. - - // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cli.yaml)") - - // Cobra also supports local flags, which will only run - // when this action is called directly. rootCmd.PersistentFlags().VarP(&outputFormat, "output", "o", "Output format (text, json)") rootCmd.PersistentFlags().StringVarP(&teamFlag, "team", "t", "", "Team key name to use") rootCmd.PersistentFlags().BoolVar(&debugFlag, "debug", false, "Print API request details before sending") diff --git a/cmd/update_check.go b/cmd/update_check.go new file mode 100644 index 0000000..2b662af --- /dev/null +++ b/cmd/update_check.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/loops-so/cli/internal/config" +) + +var ( + updateCheckDone chan struct{} + updateCheckCancel context.CancelFunc +) + +const updateCheckInterval = 24 * time.Hour + +type updateCache struct { + LatestVersion string `json:"latest_version"` + CheckedAt time.Time `json:"checked_at"` +} + +func checkForUpdate(w io.Writer) { + if version == "dev" || isJSONOutput() { + return + } + + dir, err := config.ConfigDir() + if err != nil { + return + } + path := filepath.Join(dir, "update-check.json") + + cache, err := readUpdateCache(path) + if err != nil { + if debugFlag { + fmt.Fprintf(w, "[debug] update check: no cache (%v)\n", err) + } + } else { + if debugFlag { + age := time.Since(cache.CheckedAt).Truncate(time.Second) + fmt.Fprintf(w, "[debug] update check: cached latest=%s age=%s\n", cache.LatestVersion, age) + } + if isNewerVersion(cache.LatestVersion, version) { + 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()) + } + } + + if err != nil || time.Since(cache.CheckedAt) > updateCheckInterval { + if debugFlag { + fmt.Fprintf(w, "[debug] update check: fetching latest release in background\n") + } + ctx, cancel := context.WithCancel(context.Background()) + updateCheckCancel = cancel + updateCheckDone = make(chan struct{}) + go func() { + defer close(updateCheckDone) + fetchAndCacheLatestVersion(ctx, path) + }() + } +} + +func readUpdateCache(path string) (*updateCache, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var c updateCache + if err := json.Unmarshal(data, &c); err != nil { + return nil, err + } + return &c, nil +} + +func fetchAndCacheLatestVersion(ctx context.Context, path string) { + client := &http.Client{Timeout: 5 * time.Second} + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/repos/loops-so/cli/releases/latest", nil) + if err != nil { + return + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return + } + + latest := strings.TrimPrefix(release.TagName, "v") + if latest == "" { + return + } + + cache := updateCache{ + LatestVersion: latest, + CheckedAt: time.Now(), + } + data, err := json.Marshal(cache) + if err != nil { + return + } + + _ = os.MkdirAll(filepath.Dir(path), 0o700) + _ = os.WriteFile(path, data, 0o600) +} + +func upgradeCommand() string { + if isHomebrew() { + return "brew upgrade loops" + } + if runtime.GOOS == "windows" { + return `irm https://raw.githubusercontent.com/loops-so/cli/main/install.ps1 | iex` + } + return `curl -fsSL --proto '=https' --tlsv1.2 https://cli.loops.so | bash` +} + +func isHomebrew() bool { + exe, err := os.Executable() + if err != nil { + return false + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + return false + } + lower := strings.ToLower(resolved) + return strings.Contains(lower, "cellar") || strings.Contains(lower, "homebrew") || strings.Contains(lower, "linuxbrew") +} + +// isNewerVersion reports whether latest is newer than current (semver without v prefix). +func isNewerVersion(latest, current string) bool { + l := parseSemver(latest) + c := parseSemver(current) + for i := 0; i < 3; i++ { + if l[i] > c[i] { + return true + } + if l[i] < c[i] { + return false + } + } + return false +} + +func parseSemver(v string) [3]int { + v = strings.TrimPrefix(v, "v") + var parts [3]int + fmt.Sscanf(v, "%d.%d.%d", &parts[0], &parts[1], &parts[2]) + return parts +} diff --git a/cmd/update_check_test.go b/cmd/update_check_test.go new file mode 100644 index 0000000..f8b3db5 --- /dev/null +++ b/cmd/update_check_test.go @@ -0,0 +1,196 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + latest, current string + want bool + }{ + {"1.1.0", "1.0.0", true}, + {"2.0.0", "1.9.9", true}, + {"1.0.1", "1.0.0", true}, + {"1.0.0", "1.0.0", false}, + {"1.0.0", "1.0.1", false}, + {"1.0.0", "2.0.0", false}, + {"0.0.0", "0.0.0", false}, + } + for _, tt := range tests { + if got := isNewerVersion(tt.latest, tt.current); got != tt.want { + t.Errorf("isNewerVersion(%q, %q) = %v, want %v", tt.latest, tt.current, got, tt.want) + } + } +} + +func TestParseSemver(t *testing.T) { + tests := []struct { + input string + want [3]int + }{ + {"1.2.3", [3]int{1, 2, 3}}, + {"v1.2.3", [3]int{1, 2, 3}}, + {"0.0.0", [3]int{0, 0, 0}}, + {"invalid", [3]int{0, 0, 0}}, + } + for _, tt := range tests { + if got := parseSemver(tt.input); got != tt.want { + t.Errorf("parseSemver(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestUpdateCacheRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "update-check.json") + + now := time.Now().Truncate(time.Second) + cache := updateCache{LatestVersion: "1.5.0", CheckedAt: now} + data, err := json.Marshal(cache) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + + got, err := readUpdateCache(path) + if err != nil { + t.Fatal(err) + } + if got.LatestVersion != "1.5.0" { + t.Errorf("LatestVersion = %q, want %q", got.LatestVersion, "1.5.0") + } + if !got.CheckedAt.Equal(now) { + t.Errorf("CheckedAt = %v, want %v", got.CheckedAt, now) + } +} + +func TestCheckForUpdateShowsNotice(t *testing.T) { + dir := t.TempDir() + t.Setenv("LOOPS_CONFIG_DIR", dir) + + cache := updateCache{LatestVersion: "9.9.9", CheckedAt: time.Now()} + data, _ := json.Marshal(cache) + os.WriteFile(filepath.Join(dir, "update-check.json"), data, 0o600) + + old := version + version = "1.0.0" + t.Cleanup(func() { version = old }) + + oldFmt := outputFormat + outputFormat = "text" + t.Cleanup(func() { outputFormat = oldFmt }) + + var buf bytes.Buffer + checkForUpdate(&buf) + + out := buf.String() + if out == "" { + t.Fatal("expected update notice, got empty output") + } + if !bytes.Contains(buf.Bytes(), []byte("9.9.9")) { + t.Errorf("notice should mention latest version 9.9.9, got: %s", out) + } + if !bytes.Contains(buf.Bytes(), []byte("v1.0.0")) { + t.Errorf("notice should mention current version v1.0.0, got: %s", out) + } +} + +func TestCheckForUpdateSuppressedForJSON(t *testing.T) { + dir := t.TempDir() + t.Setenv("LOOPS_CONFIG_DIR", dir) + + cache := updateCache{LatestVersion: "9.9.9", CheckedAt: time.Now()} + data, _ := json.Marshal(cache) + os.WriteFile(filepath.Join(dir, "update-check.json"), data, 0o600) + + old := version + version = "1.0.0" + t.Cleanup(func() { version = old }) + + oldFmt := outputFormat + outputFormat = "json" + t.Cleanup(func() { outputFormat = oldFmt }) + + var buf bytes.Buffer + checkForUpdate(&buf) + + if buf.Len() != 0 { + t.Errorf("expected no output for JSON mode, got: %s", buf.String()) + } +} + +func TestCheckForUpdateSuppressedForDev(t *testing.T) { + dir := t.TempDir() + t.Setenv("LOOPS_CONFIG_DIR", dir) + + cache := updateCache{LatestVersion: "9.9.9", CheckedAt: time.Now()} + data, _ := json.Marshal(cache) + os.WriteFile(filepath.Join(dir, "update-check.json"), data, 0o600) + + old := version + version = "dev" + t.Cleanup(func() { version = old }) + + var buf bytes.Buffer + checkForUpdate(&buf) + + if buf.Len() != 0 { + t.Errorf("expected no output for dev build, got: %s", buf.String()) + } +} + +func TestCheckForUpdateUpToDate(t *testing.T) { + dir := t.TempDir() + t.Setenv("LOOPS_CONFIG_DIR", dir) + + cache := updateCache{LatestVersion: "1.0.0", CheckedAt: time.Now()} + data, _ := json.Marshal(cache) + os.WriteFile(filepath.Join(dir, "update-check.json"), data, 0o600) + + old := version + version = "1.0.0" + t.Cleanup(func() { version = old }) + + oldFmt := outputFormat + outputFormat = "text" + t.Cleanup(func() { outputFormat = oldFmt }) + + var buf bytes.Buffer + checkForUpdate(&buf) + + if buf.Len() != 0 { + t.Errorf("expected no output when up to date, got: %s", buf.String()) + } +} + +func TestFetchAndCacheLatestVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"tag_name": "v2.0.0"}) + })) + t.Cleanup(srv.Close) + + dir := t.TempDir() + path := filepath.Join(dir, "update-check.json") + + cache := updateCache{LatestVersion: "2.0.0", CheckedAt: time.Now()} + data, _ := json.Marshal(cache) + os.WriteFile(path, data, 0o600) + + got, err := readUpdateCache(path) + if err != nil { + t.Fatal(err) + } + if got.LatestVersion != "2.0.0" { + t.Errorf("got %q, want %q", got.LatestVersion, "2.0.0") + } +} diff --git a/internal/config/dir.go b/internal/config/dir.go new file mode 100644 index 0000000..61aa4cd --- /dev/null +++ b/internal/config/dir.go @@ -0,0 +1,17 @@ +package config + +import ( + "os" + "path/filepath" +) + +func ConfigDir() (string, error) { + if dir := os.Getenv("LOOPS_CONFIG_DIR"); dir != "" { + return dir, nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "loops"), nil +} diff --git a/internal/config/store.go b/internal/config/store.go index cc4aa00..77c952e 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -22,14 +22,11 @@ type KeyEntry struct { } func configFilePath() (string, error) { - if dir := os.Getenv("LOOPS_CONFIG_DIR"); dir != "" { - return filepath.Join(dir, "config.yml"), nil - } - dir, err := os.UserConfigDir() + dir, err := ConfigDir() if err != nil { return "", fmt.Errorf("could not determine config directory: %w", err) } - return filepath.Join(dir, "loops", "config.yml"), nil + return filepath.Join(dir, "config.yml"), nil } func LoadPersistentConfig() (*PersistentConfig, error) { From 3e83720aee068ab7475c2e16282af180d9f9bc1f Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:15:54 -0700 Subject: [PATCH 2/2] custom install script command to make sure we tell them to install to the same place --- cmd/update_check.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cmd/update_check.go b/cmd/update_check.go index 2b662af..1ac23c6 100644 --- a/cmd/update_check.go +++ b/cmd/update_check.go @@ -126,12 +126,27 @@ func upgradeCommand() string { if isHomebrew() { return "brew upgrade loops" } + installDir := binDir() if runtime.GOOS == "windows" { + if installDir != "" { + return fmt.Sprintf(`irm https://raw.githubusercontent.com/loops-so/cli/main/install.ps1 | iex -Args "-InstallDir '%s'"`, installDir) + } return `irm https://raw.githubusercontent.com/loops-so/cli/main/install.ps1 | iex` } + if installDir != "" { + return fmt.Sprintf(`curl -fsSL --proto '=https' --tlsv1.2 https://cli.loops.so | bash -s -- latest %s`, installDir) + } return `curl -fsSL --proto '=https' --tlsv1.2 https://cli.loops.so | bash` } +func binDir() string { + exe, err := os.Executable() + if err != nil { + return "" + } + return filepath.Dir(exe) +} + func isHomebrew() bool { exe, err := os.Executable() if err != nil {