Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ title: Changelog
* `[Refactoring]` Move CLI startup flow from `cmd/lets/main.go` into `internal/cli/cli.go`, keeping `main.go` as a thin launcher.
* `[Added]` Add `lets self doc` command to open the online documentation in a browser.
* `[Added]` Show background update notifications for interactive sessions, with Homebrew-aware guidance and `LETS_CHECK_UPDATE` opt-out.
* `[Changed]` Centralize the `lets:` log prefix in the formatter and render debug messages in blue.

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

Expand Down
22 changes: 11 additions & 11 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,19 @@ func Main(version string, buildDate string) int {

command, args, err := rootCmd.Traverse(os.Args[1:])
if err != nil {
log.Errorf("lets: traverse commands error: %s", err)
log.Errorf("traverse commands error: %s", err)
return getExitCode(err, 1)
}

rootFlags, err := parseRootFlags(args)
if err != nil {
log.Errorf("lets: parse flags error: %s", err)
log.Errorf("parse flags error: %s", err)
return 1
}

if rootFlags.version {
if err := cmd.PrintVersionMessage(rootCmd); err != nil {
log.Errorf("lets: print version error: %s", err)
log.Errorf("print version error: %s", err)
return 1
}

Expand All @@ -79,7 +79,7 @@ func Main(version string, buildDate string) int {
cfg, err := config.Load(rootFlags.config, configDir, version)
if err != nil {
if failOnConfigError(rootCmd, command, rootFlags) {
log.Errorf("lets: config error: %s", err)
log.Errorf("config error: %s", err)
return 1
}
}
Expand All @@ -96,7 +96,7 @@ func Main(version string, buildDate string) int {
}

if err != nil {
log.Errorf("lets: can not create lets.yaml: %s", err)
log.Errorf("can not create lets.yaml: %s", err)
return 1
}

Expand All @@ -110,7 +110,7 @@ func Main(version string, buildDate string) int {
}

if err != nil {
log.Errorf("lets: can not self-upgrade binary: %s", err)
log.Errorf("can not self-upgrade binary: %s", err)
return 1
}

Expand All @@ -121,7 +121,7 @@ func Main(version string, buildDate string) int {

if showUsage {
if err := cmd.PrintRootHelpMessage(rootCmd); err != nil {
log.Errorf("lets: print help error: %s", err)
log.Errorf("print help error: %s", err)
return 1
}

Expand All @@ -137,7 +137,7 @@ func Main(version string, buildDate string) int {
executor.PrintDependencyTree(depErr, os.Stderr)
}

log.Errorf("lets: %s", err.Error())
log.Errorf("%s", err.Error())

return getExitCode(err, 1)
}
Expand All @@ -161,7 +161,7 @@ func getContext() context.Context {

go func() {
sig := <-ch
log.Printf("lets: signal received: %s", sig)
log.Printf("signal received: %s", sig)
cancel()
}()

Expand Down Expand Up @@ -211,7 +211,7 @@ func maybeStartUpdateCheck(
return nil, func() {}
}

log.Debugf("lets: start update check")
log.Debugf("start update check")

notifier, err := upgrade.NewUpdateNotifier(registry.NewGithubRegistry())
if err != nil {
Expand All @@ -227,7 +227,7 @@ func maybeStartUpdateCheck(
upgrade.LogUpdateCheckError(err)
}

log.Debugf("lets: update check done")
log.Debugf("update check done")

ch <- updateCheckResult{
notifier: notifier,
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func (c *Command) GetEnv(cfg Config, builtinEnv map[string]string) (map[string]s

envFileEnv, err := envFiles.Load(cfg, filenameEnv)
if err != nil {
return nil, fmt.Errorf("lets: failed to resolve env_file for command '%s': %w", c.Name, err)
return nil, fmt.Errorf("failed to resolve env_file for command '%s': %w", c.Name, err)
}

resolvedEnv := envs.Dump()
Expand Down
3 changes: 1 addition & 2 deletions internal/config/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"path/filepath"

"github.com/fatih/color"
"github.com/lets-cli/lets/internal/config/path"
"github.com/lets-cli/lets/internal/util"
"github.com/lets-cli/lets/internal/workdir"
Expand Down Expand Up @@ -39,7 +38,7 @@ func FindConfig(configName string, configDir string) (PathInfo, error) {
return PathInfo{}, err
}

log.Debugf("%s", color.BlueString("lets: found %s config file in %s directory", configName, workDir))
log.Debugf("found %s config file in %s directory", configName, workDir)

configAbsPath := ""

Expand Down
24 changes: 18 additions & 6 deletions internal/logging/formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"strings"

"github.com/fatih/color"
log "github.com/sirupsen/logrus"
)

Expand All @@ -20,13 +21,28 @@ type Formatter struct{}
// Format implements the log.Formatter interface.
func (f *Formatter) Format(entry *log.Entry) ([]byte, error) {
buff := &bytes.Buffer{}
buff.WriteString(writeData(entry.Data))
buff.WriteString(entry.Message)
parts := []string{color.BlueString("lets:")}

if data := writeData(entry.Data); data != "" {
parts = append(parts, data)
}

parts = append(parts, formatMessage(entry))

buff.WriteString(strings.Join(parts, " "))
buff.WriteString("\n")

return buff.Bytes(), nil
}

func formatMessage(entry *log.Entry) string {
if entry.Level == log.DebugLevel {
return color.BlueString(entry.Message)
}

return entry.Message
}

func writeData(fields log.Fields) string {
var buff []string

Expand All @@ -39,9 +55,5 @@ func writeData(fields log.Fields) string {
}
}

if len(buff) > 0 {
buff = append(buff, "")
}

return strings.Join(buff, " ")
}
21 changes: 13 additions & 8 deletions internal/logging/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,20 @@ func InitLogging(

// ExecLogger is used in Executor.
// If adds command chain in message like this:
// lets: [foo=>bar] message.
// [foo=>bar] message.
type ExecLogger struct {
log *log.Logger
// command name
name string
// lets: [a=>b]
// [a=>b]
prefix string
cache map[string]*ExecLogger
}

func NewExecLogger() *ExecLogger {
return &ExecLogger{
log: log.StandardLogger(),
prefix: color.BlueString("lets:"),
cache: make(map[string]*ExecLogger),
log: log.StandardLogger(),
cache: make(map[string]*ExecLogger),
}
}

Expand All @@ -71,19 +70,25 @@ func (l *ExecLogger) Child(name string) *ExecLogger {
l.cache[name] = &ExecLogger{
log: l.log,
name: name,
prefix: color.BlueString("lets: %s", color.GreenString("[%s]", name)),
prefix: color.GreenString("[%s]", name),
cache: make(map[string]*ExecLogger),
}

return l.cache[name]
}

func (l *ExecLogger) Info(format string, a ...any) {
format = fmt.Sprintf("%s %s", l.prefix, color.BlueString(format))
if l.prefix != "" {
format = fmt.Sprintf("%s %s", l.prefix, format)
}

l.log.Logf(log.InfoLevel, format, a...)
}

func (l *ExecLogger) Debug(format string, a ...any) {
format = fmt.Sprintf("%s %s", l.prefix, color.BlueString(format))
if l.prefix != "" {
format = fmt.Sprintf("%s %s", l.prefix, format)
}

l.log.Logf(log.DebugLevel, format, a...)
}
32 changes: 30 additions & 2 deletions internal/logging/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"testing"

"github.com/fatih/color"
log "github.com/sirupsen/logrus"
)

Expand All @@ -16,18 +17,45 @@ func TestLoggingToStd(t *testing.T) {

var errBuff bytes.Buffer

prevNoColor := color.NoColor
color.NoColor = true
defer func() {
color.NoColor = prevNoColor
}()

InitLogging(&stdBuff, &errBuff)

log.Info(stdOutMsg)
log.Error(stdErrMsg)

// coz log adds line break for output
if stdBuff.String() != stdOutMsg+"\n" {
if stdBuff.String() != "lets: "+stdOutMsg+"\n" {
t.Errorf("stdBuff != stdOutMsg plz check your init stdWriter")
}

if errBuff.String() != stdErrMsg+"\n" {
if errBuff.String() != "lets: "+stdErrMsg+"\n" {
t.Errorf("errBuff != stdErrMsg plz check your init errWriter")
}
})
}

func TestFormatterColorsDebugMessages(t *testing.T) {
prevNoColor := color.NoColor
color.NoColor = false
defer func() {
color.NoColor = prevNoColor
}()

line, err := (&Formatter{}).Format(&log.Entry{
Level: log.DebugLevel,
Message: "debug message",
Comment on lines +42 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests to cover non-debug levels and messages with structured fields to fully exercise the new formatter behavior.

Since we only test the debug case without structured fields, please add table-driven tests that cover: (1) info/warn/error levels (non-colorized message), (2) entries with Data set (one or more fields) to verify spacing and ordering around lets:, fields, and the message, and (3) an empty Data case to ensure no extra spaces. This will better validate the new centralized prefixing/spacing logic.

Suggested implementation:

func TestFormatterColorsDebugMessages(t *testing.T) {
	prevNoColor := color.NoColor
	color.NoColor = false
	defer func() {
		color.NoColor = prevNoColor
	}()

	line, err := (&Formatter{}).Format(&log.Entry{
		Level:   log.DebugLevel,
		Message: "debug message",
	})
	if err != nil {
		t.Fatalf("Format() error = %v", err)
	}

	expected := color.BlueString("lets:") + " " + color.BlueString("debug message") + "\n"
	if string(line) != expected {
		t.Fatalf("unexpected debug line: %q", string(line))
	}
}

func TestFormatterFormatsLevelsAndFields(t *testing.T) {
	tests := []struct {
		name  string
		entry *log.Entry
	}{
		{
			name: "info_no_data",
			entry: &log.Entry{
				Level:   log.InfoLevel,
				Message: "info message",
				Data:    log.Fields{},
			},
		},
		{
			name: "warn_with_single_field",
			entry: &log.Entry{
				Level:   log.WarnLevel,
				Message: "warn message",
				Data: log.Fields{
					"foo": "bar",
				},
			},
		},
		{
			name: "error_with_multiple_fields",
			entry: &log.Entry{
				Level:   log.ErrorLevel,
				Message: "error message",
				Data: log.Fields{
					"alpha": "one",
					"beta":  "two",
				},
			},
		},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			lineBytes, err := (&Formatter{}).Format(tt.entry)
			if err != nil {
				t.Fatalf("Format() error = %v", err)
			}
			line := string(lineBytes)

			// All levels should be prefixed consistently.
			if !strings.HasPrefix(line, "lets: ") {
				t.Fatalf("line does not start with expected prefix: %q", line)
			}

			// Non-debug messages should not be colorized by the formatter under default settings.
			if strings.Contains(line, "\x1b[") {
				t.Fatalf("expected non-colorized output for non-debug levels, got: %q", line)
			}

			// Empty Data: ensure there are no extra spaces (only one space after the prefix).
			if len(tt.entry.Data) == 0 {
				expected := "lets: " + tt.entry.Message + "\n"
				if line != expected {
					t.Fatalf("unexpected formatted line for empty Data.\nexpected: %q\ngot:      %q", expected, line)
				}
				return
			}

			// With Data: verify ordering and spacing around prefix, fields, and message.
			prefixIdx := strings.Index(line, "lets:")
			if prefixIdx != 0 {
				t.Fatalf("prefix not at beginning of line: %q", line)
			}

			msgIdx := strings.LastIndex(line, tt.entry.Message)
			if msgIdx == -1 {
				t.Fatalf("message %q not found in line: %q", tt.entry.Message, line)
			}

			if msgIdx <= prefixIdx {
				t.Fatalf("message appears before prefix in line: %q", line)
			}

			// Ensure no double spaces in the formatted output.
			if strings.Contains(line, "  ") {
				t.Fatalf("unexpected double spaces in line: %q", line)
			}

			// Each field should appear between the prefix and the message.
			for key, rawVal := range tt.entry.Data {
				val, ok := rawVal.(string)
				if !ok {
					t.Fatalf("test setup error: expected string value for key %q, got %T", key, rawVal)
				}
				fieldStr := key + "=" + val

				fieldIdx := strings.Index(line, fieldStr)
				if fieldIdx == -1 {
					t.Fatalf("field %q not found in line: %q", fieldStr, line)
				}
				if !(fieldIdx > prefixIdx && fieldIdx < msgIdx) {
					t.Fatalf("field %q not positioned between prefix and message in line: %q", fieldStr, line)
				}
			}
		})
	}
}

To compile these tests, update the imports in internal/logging/log_test.go:

  1. Add the strings package to the import list:
    • import "strings"

Make sure no existing imports are removed, and keep the import block sorted/grouped according to your current conventions.

})
if err != nil {
t.Fatalf("Format() error = %v", err)
}

expected := color.BlueString("lets:") + " " + color.BlueString("debug message") + "\n"
if string(line) != expected {
t.Fatalf("unexpected debug line: %q", string(line))
}
}
6 changes: 3 additions & 3 deletions internal/upgrade/notifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type UpdateNotice struct {
func (n *UpdateNotice) Message() string {
return fmt.Sprintf(
"\n%s: %s -> %s\n%s",
color.YellowString("lets: new version been released"),
color.YellowString("new version been released"),
color.RedString(n.CurrentVersion),
color.GreenString(n.LatestVersion),
color.YellowString("Run '%s' or see https://lets-cli.org/docs/installation", n.command),
Expand Down Expand Up @@ -93,7 +93,7 @@ func (n *UpdateNotifier) Check(ctx context.Context, currentVersion string) (*Upd

now := n.now()
if now.Sub(state.CheckedAt) < updateCheckInterval {
log.Debugf("lets: skip update check: next check at %s", state.CheckedAt.Add(updateCheckInterval))
log.Debugf("skip update check: next check at %s", state.CheckedAt.Add(updateCheckInterval))
return n.noticeFromState(state, currentVersion, current, now), nil
}

Expand Down Expand Up @@ -251,5 +251,5 @@ func LogUpdateCheckError(err error) {
return
}

log.Debugf("lets: update notifier error: %s", err)
log.Debugf("update notifier error: %s", err)
}
Loading