chore: implement new logs

Kujtim Hoxha created

Change summary

.gitignore            |  2 
cmd/logs.go           | 86 +++++++++++++++++++++++++++++++++++++++++++++
cmd/root.go           |  3 +
go.mod                |  7 +++
go.sum                | 14 +++++++
pkg/config/resolve.go |  1 
pkg/log/log.go        | 32 ++++++++++++++++
7 files changed, 143 insertions(+), 2 deletions(-)

Detailed changes

.gitignore 🔗

@@ -43,7 +43,7 @@ Thumbs.db
 
 **/.crush/**
 
-crush
+/crush
 
 manpages/
 completions/

cmd/logs.go 🔗

@@ -0,0 +1,86 @@
+package cmd
+
+import (
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+	"time"
+
+	"github.com/charmbracelet/crush/pkg/config"
+	"github.com/charmbracelet/crush/pkg/env"
+	"github.com/charmbracelet/log/v2"
+	"github.com/nxadm/tail"
+	"github.com/spf13/cobra"
+)
+
+func init() {
+	rootCmd.AddCommand(logsCmd)
+}
+
+var logsCmd = &cobra.Command{
+	Use:   "logs",
+	Short: "View crush logs",
+	Long:  `View the logs generated by Crush. This command allows you to see the log output for debugging and monitoring purposes.`,
+	RunE: func(cmd *cobra.Command, args []string) error {
+		cwd, err := cmd.Flags().GetString("cwd")
+		if err != nil {
+			return fmt.Errorf("failed to get current working directory: %v", err)
+		}
+		cfg, err := config.Load(cwd, env.New())
+		if err != nil {
+			return fmt.Errorf("failed to load configuration: %v", err)
+		}
+		t, err := tail.TailFile(filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log"), tail.Config{Follow: true, ReOpen: true, Logger: tail.DiscardingLogger})
+		if err != nil {
+			return fmt.Errorf("failed to tail log file: %v", err)
+		}
+
+		// Print the text of each received line
+		for line := range t.Lines {
+			var data map[string]any
+			if err := json.Unmarshal([]byte(line.Text), &data); err != nil {
+				continue
+			}
+			msg := data["msg"]
+			level := data["level"]
+			otherData := []any{}
+			for k, v := range data {
+				switch k {
+				case "msg", "level", "time":
+					continue
+				case "source":
+					source, ok := v.(map[string]any)
+					if !ok {
+						continue
+					}
+					sourceFile := fmt.Sprintf("%s:%d", source["file"], int(source["line"].(float64)))
+					otherData = append(otherData, "source", sourceFile)
+
+				default:
+					otherData = append(otherData, k, v)
+				}
+			}
+			log.SetTimeFunction(func(_ time.Time) time.Time {
+				// parse the timestamp from the log line if available
+				t, err := time.Parse(time.RFC3339, data["time"].(string))
+				if err != nil {
+					return time.Now() // fallback to current time if parsing fails
+				}
+				return t
+			})
+			switch level {
+			case "INFO":
+				log.Info(msg, otherData...)
+			case "DEBUG":
+				log.Debug(msg, otherData...)
+			case "ERROR":
+				log.Error(msg, otherData...)
+			case "WARN":
+				log.Warn(msg, otherData...)
+			default:
+				log.Info(msg, otherData...)
+			}
+		}
+		return nil
+	},
+}

cmd/root.go 🔗

@@ -292,9 +292,10 @@ func Execute() {
 }
 
 func init() {
+	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
+
 	rootCmd.Flags().BoolP("help", "h", false, "Help")
 	rootCmd.Flags().BoolP("debug", "d", false, "Debug")
-	rootCmd.Flags().StringP("cwd", "c", "", "Current working directory")
 	rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode")
 
 	// Add format flag with validation logic

go.mod 🔗

@@ -41,9 +41,16 @@ require (
 )
 
 require (
+	github.com/charmbracelet/lipgloss v1.1.0 // indirect
+	github.com/charmbracelet/log v0.4.2 // indirect
+	github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 // indirect
 	github.com/joho/godotenv v1.5.1 // indirect
+	github.com/nxadm/tail v1.4.11 // indirect
 	github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c // indirect
 	github.com/spf13/cast v1.7.1 // indirect
+	golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
+	gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
+	gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
 )
 
 require (

go.sum 🔗

@@ -82,10 +82,16 @@ github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d
 github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe h1:i6ce4CcAlPpTj2ER69m1DBeLZ3RRcHnKExuwhKa3GfY=
 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe/go.mod h1:p3Q+aN4eQKeM5jhrmXPMgPrlKbmc59rWSnMsSA3udhk=
+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c h1:177KMz8zHRlEZJsWzafbKYh6OdjgvTspoH+UjaxgIXY=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1.0.20250523195325-2d1af06b557c/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71 h1:X0tsNa2UHCKNw+illiavosasVzqioRo32SRV35iwr2I=
 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2.0.20250703152125-8e1c474f8a71/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
+github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
+github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
+github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706 h1:WkwO6Ks3mSIGnGuSdKl9qDSyfbYK50z2wc2gGMggegE=
+github.com/charmbracelet/log/v2 v2.0.0-20250226163916-c379e29ff706/go.mod h1:mjJGp00cxcfvD5xdCa+bso251Jt4owrQvuimJtVmEmM=
 github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413 h1:L07QkDqRF274IZ2UJ/mCTL8DR95efU9BNWLYCDXEjvQ=
 github.com/charmbracelet/x/ansi v0.9.3-0.20250602153603-fb931ed90413/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
 github.com/charmbracelet/x/cellbuf v0.0.14-0.20250516160309-24eee56f89fa h1:lphz0Z3rsiOtMYiz8axkT24i9yFiueDhJbzyNUADmME=
@@ -120,6 +126,7 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
 github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
@@ -201,6 +208,8 @@ github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt
 github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
+github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
+github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
 github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP8=
 github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -322,6 +331,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -368,6 +378,10 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

pkg/config/resolve.go 🔗

@@ -25,6 +25,7 @@ type shellVariableResolver struct {
 
 func NewShellVariableResolver(env env.Env) VariableResolver {
 	return &shellVariableResolver{
+		env: env,
 		shell: shell.NewShell(
 			&shell.Options{
 				Env: env.Env(),

pkg/log/log.go 🔗

@@ -0,0 +1,32 @@
+package log
+
+import (
+	"log/slog"
+	"path/filepath"
+
+	"github.com/charmbracelet/crush/pkg/config"
+
+	"gopkg.in/natefinch/lumberjack.v2"
+)
+
+func Init(cfg *config.Config) {
+	logRotator := &lumberjack.Logger{
+		Filename:   filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log"),
+		MaxSize:    10,    // Max size in MB
+		MaxBackups: 0,     // Number of backups
+		MaxAge:     30,    // Days
+		Compress:   false, // Enable compression
+	}
+
+	level := slog.LevelInfo
+	if cfg.Options.Debug {
+		level = slog.LevelDebug
+	}
+
+	logger := slog.NewJSONHandler(logRotator, &slog.HandlerOptions{
+		Level:     level,
+		AddSource: true,
+	})
+
+	slog.SetDefault(slog.New(logger))
+}