Skip to content

Commit 659efaf

Browse files
committed
Reshape update notifier plan
1 parent 6086d46 commit 659efaf

File tree

10 files changed

+703
-12
lines changed

10 files changed

+703
-12
lines changed

docs/docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ title: Changelog
1414
* `[Added]` Support `env_file` in global config and commands. File names are expanded after `env` is resolved, and values loaded from env files override values from `env`.
1515
* `[Changed]` Migrate the LSP YAML parser from the CGO-based tree-sitter bindings to pure-Go [`gotreesitter`](https://github.com/odvcencio/gotreesitter), removing the C toolchain requirement from normal builds and release packaging.
1616
* `[Refactoring]` Move CLI startup flow from `cmd/lets/main.go` into `internal/cli/cli.go`, keeping `main.go` as a thin launcher.
17+
* `[Added]` Show background update notifications for interactive sessions, with Homebrew-aware guidance and `LETS_CHECK_UPDATE` opt-out.
1718

1819
## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59)
1920

docs/docs/env.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ title: Environment
1010
* `LETS_DEBUG` - enable debug messages
1111
* `LETS_CONFIG` - changes default `lets.yaml` file path (e.g. LETS_CONFIG=lets.my.yaml)
1212
* `LETS_CONFIG_DIR` - changes path to dir where `lets.yaml` file placed
13+
* `LETS_CHECK_UPDATE` - disables background update checks and notifications
1314
* `NO_COLOR` - disables colored output. See https://no-color.org/
1415

1516
### Environment variables available at command runtime

internal/cli/cli.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package cli
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"os"
78
"os/signal"
89
"strings"
910
"syscall"
11+
"time"
1012

1113
"github.com/lets-cli/lets/internal/cmd"
1214
"github.com/lets-cli/lets/internal/config"
@@ -17,10 +19,18 @@ import (
1719
"github.com/lets-cli/lets/internal/upgrade"
1820
"github.com/lets-cli/lets/internal/upgrade/registry"
1921
"github.com/lets-cli/lets/internal/workdir"
22+
"github.com/mattn/go-isatty"
2023
log "github.com/sirupsen/logrus"
2124
"github.com/spf13/cobra"
2225
)
2326

27+
const updateCheckTimeout = 3 * time.Second
28+
29+
type updateCheckResult struct {
30+
notifier *upgrade.UpdateNotifier
31+
notice *upgrade.UpdateNotice
32+
}
33+
2434
func Main(version string, buildDate string) int {
2535
ctx := getContext()
2636

@@ -118,6 +128,9 @@ func Main(version string, buildDate string) int {
118128
return 0
119129
}
120130

131+
updateCh, cancelUpdateCheck := maybeStartUpdateCheck(ctx, version, command)
132+
defer cancelUpdateCheck()
133+
121134
if err := rootCmd.ExecuteContext(ctx); err != nil {
122135
var depErr *executor.DependencyError
123136
if errors.As(err, &depErr) {
@@ -128,6 +141,8 @@ func Main(version string, buildDate string) int {
128141
return getExitCode(err, 1)
129142
}
130143

144+
printUpdateNotice(updateCh)
145+
131146
return 0
132147
}
133148

@@ -167,6 +182,82 @@ func failOnConfigError(root *cobra.Command, current *cobra.Command, rootFlags *f
167182
return (root.Flags().NFlag() == 0 && !rootCommands.Contains(current.Name())) && !rootFlags.help && !rootFlags.init
168183
}
169184

185+
func maybeStartUpdateCheck(
186+
ctx context.Context,
187+
version string,
188+
command *cobra.Command,
189+
) (<-chan updateCheckResult, context.CancelFunc) {
190+
if !shouldCheckForUpdate(command.Name(), isInteractiveStderr()) {
191+
log.Debugf("lets: update check disabled")
192+
return nil, func() {}
193+
}
194+
195+
log.Debugf("lets: start update check")
196+
197+
notifier, err := upgrade.NewUpdateNotifier(registry.NewGithubRegistry(ctx))
198+
if err != nil {
199+
return nil, func() {}
200+
}
201+
202+
ch := make(chan updateCheckResult, 1)
203+
checkCtx, cancel := context.WithTimeout(ctx, updateCheckTimeout)
204+
205+
go func() {
206+
notice, err := notifier.Check(checkCtx, version)
207+
if err != nil {
208+
upgrade.LogUpdateCheckError(err)
209+
}
210+
log.Debugf("lets: update check done")
211+
212+
ch <- updateCheckResult{
213+
notifier: notifier,
214+
notice: notice,
215+
}
216+
}()
217+
218+
return ch, cancel
219+
}
220+
221+
func printUpdateNotice(updateCh <-chan updateCheckResult) {
222+
if updateCh == nil {
223+
return
224+
}
225+
226+
select {
227+
case result := <-updateCh:
228+
if result.notice == nil {
229+
return
230+
}
231+
232+
if _, err := fmt.Fprintln(os.Stderr, result.notice.Message()); err != nil {
233+
return
234+
}
235+
236+
if err := result.notifier.MarkNotified(result.notice); err != nil {
237+
upgrade.LogUpdateCheckError(err)
238+
}
239+
default:
240+
}
241+
}
242+
243+
func shouldCheckForUpdate(commandName string, interactive bool) bool {
244+
if !interactive || os.Getenv("CI") != "" || os.Getenv("LETS_CHECK_UPDATE") != "" {
245+
return false
246+
}
247+
248+
switch commandName {
249+
case "completion", "help", "lsp", "self":
250+
return false
251+
default:
252+
return true
253+
}
254+
}
255+
256+
func isInteractiveStderr() bool {
257+
fd := os.Stderr.Fd()
258+
return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)
259+
}
260+
170261
type flags struct {
171262
config string
172263
debug int

internal/cli/cli_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package cli
2+
3+
import "testing"
4+
5+
func TestShouldCheckForUpdate(t *testing.T) {
6+
t.Run("should allow normal interactive commands", func(t *testing.T) {
7+
if !shouldCheckForUpdate("lets", true) {
8+
t.Fatal("expected update check to be enabled")
9+
}
10+
})
11+
12+
t.Run("should skip non interactive sessions", func(t *testing.T) {
13+
if shouldCheckForUpdate("lets", false) {
14+
t.Fatal("expected non-interactive session to skip update check")
15+
}
16+
})
17+
18+
t.Run("should skip when CI is set", func(t *testing.T) {
19+
t.Setenv("CI", "1")
20+
if shouldCheckForUpdate("lets", true) {
21+
t.Fatal("expected CI to skip update check")
22+
}
23+
})
24+
25+
t.Run("should skip when notifier disabled", func(t *testing.T) {
26+
t.Setenv("LETS_CHECK_UPDATE", "1")
27+
if shouldCheckForUpdate("lets", true) {
28+
t.Fatal("expected opt-out env to skip update check")
29+
}
30+
})
31+
32+
t.Run("should skip internal commands", func(t *testing.T) {
33+
for _, name := range []string{"completion", "help", "lsp", "self"} {
34+
if shouldCheckForUpdate(name, true) {
35+
t.Fatalf("expected %q to skip update check", name)
36+
}
37+
}
38+
})
39+
}

0 commit comments

Comments
 (0)