Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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",
Copy link
Copy Markdown

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