Skip to content

Commit 245b4be

Browse files
committed
Reshape update notifier plan
1 parent c73fc07 commit 245b4be

10 files changed

Lines changed: 700 additions & 12 deletions

File tree

docs/docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ title: Changelog
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.
1717
* `[Added]` Add `lets self doc` command to open the online documentation in a browser.
18+
* `[Added]` Show background update notifications for interactive sessions, with Homebrew-aware guidance and `LETS_CHECK_UPDATE` opt-out.
1819

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

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

@@ -186,6 +201,82 @@ func allowsMissingConfig(current *cobra.Command) bool {
186201
return false
187202
}
188203

204+
func maybeStartUpdateCheck(
205+
ctx context.Context,
206+
version string,
207+
command *cobra.Command,
208+
) (<-chan updateCheckResult, context.CancelFunc) {
209+
if !shouldCheckForUpdate(command.Name(), isInteractiveStderr()) {
210+
log.Debugf("lets: update check disabled")
211+
return nil, func() {}
212+
}
213+
214+
log.Debugf("lets: start update check")
215+
216+
notifier, err := upgrade.NewUpdateNotifier(registry.NewGithubRegistry(ctx))
217+
if err != nil {
218+
return nil, func() {}
219+
}
220+
221+
ch := make(chan updateCheckResult, 1)
222+
checkCtx, cancel := context.WithTimeout(ctx, updateCheckTimeout)
223+
224+
go func() {
225+
notice, err := notifier.Check(checkCtx, version)
226+
if err != nil {
227+
upgrade.LogUpdateCheckError(err)
228+
}
229+
log.Debugf("lets: update check done")
230+
231+
ch <- updateCheckResult{
232+
notifier: notifier,
233+
notice: notice,
234+
}
235+
}()
236+
237+
return ch, cancel
238+
}
239+
240+
func printUpdateNotice(updateCh <-chan updateCheckResult) {
241+
if updateCh == nil {
242+
return
243+
}
244+
245+
select {
246+
case result := <-updateCh:
247+
if result.notice == nil {
248+
return
249+
}
250+
251+
if _, err := fmt.Fprintln(os.Stderr, result.notice.Message()); err != nil {
252+
return
253+
}
254+
255+
if err := result.notifier.MarkNotified(result.notice); err != nil {
256+
upgrade.LogUpdateCheckError(err)
257+
}
258+
default:
259+
}
260+
}
261+
262+
func shouldCheckForUpdate(commandName string, interactive bool) bool {
263+
if !interactive || os.Getenv("CI") != "" || os.Getenv("LETS_CHECK_UPDATE") != "" {
264+
return false
265+
}
266+
267+
switch commandName {
268+
case "completion", "help", "lsp", "self":
269+
return false
270+
default:
271+
return true
272+
}
273+
}
274+
275+
func isInteractiveStderr() bool {
276+
fd := os.Stderr.Fd()
277+
return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)
278+
}
279+
189280
type flags struct {
190281
config string
191282
debug int

internal/cli/cli_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,39 @@ func TestAllowsMissingConfig(t *testing.T) {
5757
}
5858
})
5959
}
60+
61+
func TestShouldCheckForUpdate(t *testing.T) {
62+
t.Run("should allow normal interactive commands", func(t *testing.T) {
63+
if !shouldCheckForUpdate("lets", true) {
64+
t.Fatal("expected update check to be enabled")
65+
}
66+
})
67+
68+
t.Run("should skip non interactive sessions", func(t *testing.T) {
69+
if shouldCheckForUpdate("lets", false) {
70+
t.Fatal("expected non-interactive session to skip update check")
71+
}
72+
})
73+
74+
t.Run("should skip when CI is set", func(t *testing.T) {
75+
t.Setenv("CI", "1")
76+
if shouldCheckForUpdate("lets", true) {
77+
t.Fatal("expected CI to skip update check")
78+
}
79+
})
80+
81+
t.Run("should skip when notifier disabled", func(t *testing.T) {
82+
t.Setenv("LETS_CHECK_UPDATE", "1")
83+
if shouldCheckForUpdate("lets", true) {
84+
t.Fatal("expected opt-out env to skip update check")
85+
}
86+
})
87+
88+
t.Run("should skip internal commands", func(t *testing.T) {
89+
for _, name := range []string{"completion", "help", "lsp", "self"} {
90+
if shouldCheckForUpdate(name, true) {
91+
t.Fatalf("expected %q to skip update check", name)
92+
}
93+
}
94+
})
95+
}

0 commit comments

Comments
 (0)