Skip to content
Merged
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
24 changes: 23 additions & 1 deletion internal/command/presentation.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/lettermint/lettermint-cli/internal/api"
"github.com/lettermint/lettermint-cli/internal/presentation"
"github.com/lettermint/lettermint-cli/internal/update"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
Expand Down Expand Up @@ -128,12 +129,22 @@ func (a *app) configurePresentation(root *cobra.Command) {
if original := c.RunE; original != nil {
c.RunE = func(cmd *cobra.Command, args []string) error {
ui := a.ui(cmd)
check := a.checkUpdate(cmd, key)
defer check.Stop()
if check != nil && check.Cached != "" {
_ = ui.UpdateAvailable(a.version, check.Cached, update.Instructions(a.version, check.Cached))
}
// These commands produce raw bytes or help and must stay undecorated.
if key != "messages content" && !strings.HasPrefix(key, "completion") && key != "version" && cmd != root {
ui.StartProgress(cmd.Context(), "Running "+key)
}
defer ui.StopProgress()
return original(cmd, args)
err := original(cmd, args)
ui.StopProgress()
if latest := check.Finish(err == nil && key != "webhooks listen"); latest != "" {
_ = ui.UpdateAvailable(a.version, latest, update.Instructions(a.version, latest))
}
return err
}
}
for _, child := range c.Commands() {
Expand All @@ -143,6 +154,17 @@ func (a *app) configurePresentation(root *cobra.Command) {
wrap(root)
}

func (a *app) checkUpdate(c *cobra.Command, key string) *update.Check {
if a.noInput || !a.ui(c).UpdateNotifications() || c == c.Root() ||
key == "version" || key == "messages content" || strings.HasPrefix(key, "completion") {
return nil
}
if a.updates == nil {
a.updates = update.New()
}
return a.updates.Start(c.Context(), a.version)
}

func commandExample(key string) string {
switch key {
case "auth login":
Expand Down
2 changes: 2 additions & 0 deletions internal/command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/lettermint/lettermint-cli/internal/config"
"github.com/lettermint/lettermint-cli/internal/listener"
"github.com/lettermint/lettermint-cli/internal/presentation"
"github.com/lettermint/lettermint-cli/internal/update"
"github.com/lettermint/lettermint-cli/skills"
"github.com/spf13/cobra"
"io"
Expand All @@ -27,6 +28,7 @@ type app struct {
display presentation.Options
presenter *presentation.Presenter
scope presentation.Context
updates *update.Checker
}

func New(version, clientID string) *cobra.Command {
Expand Down
249 changes: 249 additions & 0 deletions internal/command/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
package command

import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"

"github.com/lettermint/lettermint-cli/internal/presentation"
"github.com/lettermint/lettermint-cli/internal/update"
"github.com/spf13/cobra"
)

func updateFixture(t *testing.T) (*update.Checker, *atomic.Int32) {
t.Helper()
requests := &atomic.Int32{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
_, _ = io.WriteString(w, `{"tag_name":"v1.2.0","assets":[
{"name":"lettermint_1.2.0_linux_amd64.tar.gz","state":"uploaded","size":100},
{"name":"install.sh","state":"uploaded","size":100},
{"name":"checksums.txt","state":"uploaded","size":100},
{"name":"provenance.jsonl","state":"uploaded","size":100}]}`)
}))
t.Cleanup(server.Close)
return &update.Checker{
CachePath: filepath.Join(t.TempDir(), "cache", "update.json"),
HTTP: server.Client(), Endpoint: server.URL,
Now: func() time.Time { return time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC) },
OS: "linux", Arch: "amd64",
}, requests
}

func updateRoot(a *app, run func(*cobra.Command, []string) error) *cobra.Command {
root := &cobra.Command{Use: "lettermint", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().BoolVar(&a.noInput, "no-input", false, "Do not prompt")
for _, key := range []string{"skills list", "version", "completion", "messages content", "webhooks listen"} {
parts := strings.Fields(key)
parent := root
if len(parts) == 2 {
parent = &cobra.Command{Use: parts[0]}
root.AddCommand(parent)
}
parent.AddCommand(&cobra.Command{Use: parts[len(parts)-1], Args: cobra.NoArgs, RunE: run})
}
a.configurePresentation(root)
return root
}

func updateApp(c *update.Checker, out, diagnostic io.Writer, options presentation.Options, stdoutTTY, stderrTTY bool) *app {
return &app{
version: "1.0.0", updates: c, display: options,
presenter: presentation.WithTerminals(out, diagnostic, options,
presentation.Terminal{TTY: stdoutTTY}, presentation.Terminal{TTY: stderrTTY}),
}
}

func waitForUpdate(t *testing.T, c *update.Checker) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
data, _ := os.ReadFile(c.CachePath)
var cached struct{ Latest string }
if json.Unmarshal(data, &cached) == nil && cached.Latest == "1.2.0" {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("update was not cached")
}

func TestFreshUpdateFollowsSuccessfulOutput(t *testing.T) {
c, requests := updateFixture(t)
var out, diagnostic bytes.Buffer
a := updateApp(c, &out, &diagnostic, presentation.Options{Plain: true}, true, true)
root := updateRoot(a, func(cmd *cobra.Command, _ []string) error {
waitForUpdate(t, c)
if diagnostic.Len() != 0 {
t.Fatal("background worker wrote output")
}
_, err := io.WriteString(cmd.OutOrStdout(), "result\n")
return err
})
root.SetOut(&out)
root.SetErr(&diagnostic)
root.SetArgs([]string{"skills", "list"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
want := "Update available: 1.0.0 -> 1.2.0\n" +
"https://github.com/lettermint/lettermint-cli/releases/tag/v1.2.0\n" +
strings.Join(update.Instructions(a.version, "1.2.0"), "\n") + "\n" +
"Installation guide: https://github.com/lettermint/lettermint-cli/blob/main/docs/installation.md\n"
if out.String() != "result\n" || diagnostic.String() != want || requests.Load() != 1 {
t.Fatalf("stdout=%q stderr=%q requests=%d", out.String(), diagnostic.String(), requests.Load())
}
}

func TestUpdateSuppression(t *testing.T) {
for _, tc := range []struct {
name string
args []string
options presentation.Options
stdoutTTY, stderrTTY bool
}{
{"json", []string{"skills", "list"}, presentation.Options{JSON: true}, true, true},
{"pipe", []string{"skills", "list"}, presentation.Options{}, false, true},
{"plain-pipe", []string{"skills", "list"}, presentation.Options{Plain: true}, false, true},
{"stderr-file", []string{"skills", "list"}, presentation.Options{}, true, false},
{"no-input", []string{"skills", "list", "--no-input"}, presentation.Options{}, true, true},
{"version", []string{"version"}, presentation.Options{}, true, true},
{"content", []string{"messages", "content"}, presentation.Options{}, true, true},
{"completion", []string{"completion"}, presentation.Options{}, true, true},
{"dynamic-completion", []string{"__complete", "skills", ""}, presentation.Options{}, true, true},
{"help", []string{"skills", "--help"}, presentation.Options{}, true, true},
{"help-command", []string{"help", "skills"}, presentation.Options{}, true, true},
{"root", nil, presentation.Options{}, true, true},
{"invalid-arguments", []string{"skills", "list", "extra"}, presentation.Options{}, true, true},
} {
t.Run(tc.name, func(t *testing.T) {
c, requests := updateFixture(t)
var out, diagnostic bytes.Buffer
a := updateApp(c, &out, &diagnostic, tc.options, tc.stdoutTTY, tc.stderrTTY)
ran := false
root := updateRoot(a, func(cmd *cobra.Command, _ []string) error {
ran = true
_, err := io.WriteString(cmd.OutOrStdout(), "{\"ok\":true}\n")
return err
})
root.SetOut(&out)
root.SetErr(&diagnostic)
root.SetArgs(tc.args)
err := root.Execute()
if (err != nil) != (tc.name == "invalid-arguments") {
t.Fatal(err)
}
if ran && out.String() != "{\"ok\":true}\n" {
t.Fatalf("result changed: %q", out.String())
}
if requests.Load() != 0 || strings.Contains(diagnostic.String(), "Update available") {
t.Fatal("suppressed command checked for an update")
}
if _, err := os.Stat(filepath.Dir(c.CachePath)); !os.IsNotExist(err) {
t.Fatal("suppressed command opened the cache")
}
})
}
}

func TestListenerAndFailedCommandDeferFreshAlert(t *testing.T) {
for _, listener := range []bool{false, true} {
t.Run(map[bool]string{false: "failure", true: "listener"}[listener], func(t *testing.T) {
c, requests := updateFixture(t)
var out, diagnostic bytes.Buffer
a := updateApp(c, &out, &diagnostic, presentation.Options{Plain: true}, true, true)
failure := errors.New("command failed")
root := updateRoot(a, func(cmd *cobra.Command, _ []string) error {
waitForUpdate(t, c)
_, _ = io.WriteString(cmd.OutOrStdout(), "event\n")
if diagnostic.Len() != 0 {
t.Fatal("background worker mixed an alert into command output")
}
if !listener {
return failure
}
return nil
})
root.SetOut(&out)
root.SetErr(&diagnostic)
args := []string{"skills", "list"}
if listener {
args = []string{"webhooks", "listen"}
}
root.SetArgs(args)
err := root.Execute()
if listener && err != nil || !listener && err != failure {
t.Fatalf("command error changed: %v", err)
}
if diagnostic.Len() != 0 || out.String() != "event\n" {
t.Fatalf("stdout=%q stderr=%q", out.String(), diagnostic.String())
}
// The next command must display the cached result before its own work.
next := updateRoot(a, func(*cobra.Command, []string) error {
if !strings.Contains(diagnostic.String(), "Update available: 1.0.0 -> 1.2.0") {
t.Fatal("cached alert was not displayed before command work")
}
return nil
})
next.SetArgs([]string{"skills", "list"})
if err := next.Execute(); err != nil {
t.Fatal(err)
}
if requests.Load() != 1 {
t.Fatal("cached alert made a new request")
}
})
}
}

func TestRequiredFlagsValidatedBeforeUpdateCheck(t *testing.T) {
c, requests := updateFixture(t)
var out, diagnostic bytes.Buffer
a := updateApp(c, &out, &diagnostic, presentation.Options{}, true, true)
root := updateRoot(a, func(*cobra.Command, []string) error {
t.Fatal("command ran without required flag")
return nil
})
cmd, _, err := root.Find([]string{"skills", "list"})
if err != nil {
t.Fatal(err)
}
cmd.Flags().String("required", "", "Required input")
if err := cmd.MarkFlagRequired("required"); err != nil {
t.Fatal(err)
}
root.SetArgs([]string{"skills", "list"})
if root.Execute() == nil || requests.Load() != 0 {
t.Fatal("missing required flag did not prevent the check")
}
if _, err := os.Stat(filepath.Dir(c.CachePath)); !os.IsNotExist(err) {
t.Fatal("opened cache before flag validation")
}
}

type failedNoticeWriter struct{}

func (failedNoticeWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }

func TestNoticeWriteFailureDoesNotFailCommand(t *testing.T) {
c, _ := updateFixture(t)
var out bytes.Buffer
a := updateApp(c, &out, failedNoticeWriter{}, presentation.Options{Plain: true}, true, true)
root := updateRoot(a, func(*cobra.Command, []string) error {
waitForUpdate(t, c)
return nil
})
root.SetArgs([]string{"skills", "list"})
if err := root.Execute(); err != nil {
t.Fatal("notice changed command result:", err)
}
}
18 changes: 18 additions & 0 deletions internal/presentation/presentation.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,24 @@ func (p *Presenter) Notice(text string) error {
return p.write(p.err, p.diagnostic, Text(text)+"\n")
}

func (p *Presenter) UpdateNotifications() bool {
return !p.JSON() && p.output.TTY && p.diagnostic.TTY
}

func (p *Presenter) UpdateAvailable(current, latest string, instructions []string) error {
if !p.UpdateNotifications() {
return nil
}
p.StopProgress()
text := fmt.Sprintf("%s %s -> %s\n", p.heading("Update available:"), Text(strings.TrimPrefix(current, "v")), Text(latest)) +
"https://github.com/lettermint/lettermint-cli/releases/tag/v" + Text(latest) + "\n"
for _, line := range instructions {
text += Text(line) + "\n"
}
text += "Installation guide: https://github.com/lettermint/lettermint-cli/blob/main/docs/installation.md\n"
return p.write(p.err, p.diagnostic, text)
}

func (p *Presenter) Prompt(text string) error {
p.StopProgress()
return p.write(p.err, p.diagnostic, Text(text)+" [y/N]: ")
Expand Down
43 changes: 43 additions & 0 deletions internal/presentation/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package presentation

import (
"bytes"
"strings"
"testing"

"github.com/charmbracelet/colorprofile"
)

func TestUpdateNoticeModes(t *testing.T) {
for _, tc := range []struct {
name string
options Options
profile colorprofile.Profile
color bool
}{
{"terminal", Options{}, colorprofile.TrueColor, true},
{"plain", Options{Plain: true, Color: "always"}, colorprofile.TrueColor, false},
{"never", Options{Color: "never"}, colorprofile.TrueColor, false},
{"no-color-terminal", Options{}, colorprofile.NoTTY, false},
{"forced-color", Options{Color: "always"}, colorprofile.NoTTY, true},
{"json", Options{JSON: true}, colorprofile.TrueColor, false},
} {
t.Run(tc.name, func(t *testing.T) {
var out, diagnostic bytes.Buffer
terminal := Terminal{TTY: true, Width: 80, Profile: tc.profile}
p := WithTerminals(&out, &diagnostic, tc.options, terminal, terminal)
if err := p.UpdateAvailable("v1.0.0", "1.2.0", []string{"Update with Homebrew:", " brew update && brew upgrade --cask lettermint"}); err != nil {
t.Fatal(err)
}
if out.Len() != 0 || strings.Contains(diagnostic.String(), "\x1b") != tc.color {
t.Fatalf("stdout=%q stderr=%q", out.String(), diagnostic.String())
}
if tc.options.JSON != (diagnostic.Len() == 0) {
t.Fatal("incorrect notice visibility")
}
if !tc.options.JSON && !strings.Contains(diagnostic.String(), " brew update && brew upgrade --cask lettermint\n") {
t.Fatal("missing update command")
}
})
}
}
Loading
Loading