Skip to content
Merged
13 changes: 13 additions & 0 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ func main() {
model.InjectVar(version)
cmdService := model.NewCommandService()

// When the bolt storage engine is enabled, the daemon owns the bolt-backed
// command store for its lifetime (bbolt holds an exclusive file lock).
if cfg.Storage != nil && cfg.Storage.Engine == model.StorageEngineBolt {
store, err := model.NewCommandStore(cfg)
if err != nil {
slog.Error("Failed to open bolt command store", slog.Any("err", err))
} else {
daemon.InitCommandStore(store)
defer store.Close()
slog.Info("Bolt command store initialized")
}
}

pubsub := daemon.NewGoChannel(daemon.PubSubConfig{}, watermill.NewSlogLogger(slog.Default()))
msg, err := pubsub.Subscribe(context.Background(), daemon.PubSubTopic)

Expand Down
10 changes: 7 additions & 3 deletions commands/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,13 @@ func commandGC(c *cli.Context) error {
defer CloseLogger()
}

// Clean command files
if err := cleanCommandFiles(ctx); err != nil {
return err
// Clean command files. In bolt mode the daemon prunes synced commands from
// the DB after each sync, and the txt files are only fallback leftovers, so
// skip the txt compaction (post.txt may not even exist).
if cfg.Storage == nil || cfg.Storage.Engine != model.StorageEngineBolt {
if err := cleanCommandFiles(ctx); err != nil {
return err
}
}

// TODO: delete $HOME/.config/malamtime/ folder
Expand Down
78 changes: 15 additions & 63 deletions commands/ls.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ package commands
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"strconv"
"time"

"github.com/gookit/color"
"github.com/malamtime/cli/daemon"
"github.com/malamtime/cli/model"
"github.com/olekukonko/tablewriter"
"github.com/urfave/cli/v2"
Expand Down Expand Up @@ -54,66 +54,26 @@ func commandList(c *cli.Context) error {
color.Yellow.Println("⚠️ Note: Local data will be cleaned periodically for performance and disk efficiency. To view all of your commands, please run 'shelltime web'")
}

// Get post commands
postFileContent, _, err := model.GetPostCommands(ctx)
config, err := configService.ReadConfigFile(ctx)
if err != nil {
return err
}

// Get pre commands tree for reference
preFileTree, err := model.GetPreCommandsTree(ctx)
if err != nil {
return err
}

// Process commands
var commands []struct {
Command string `json:"command"`
Shell string `json:"shell"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Result int `json:"result"`
Username string `json:"username"`
Hostname string `json:"hostname"`
}

for _, line := range postFileContent {
postCommand := new(model.Command)
_, err := postCommand.FromLineBytes(line)
// In bolt mode the daemon owns the (exclusively locked) DB, so query it over
// the socket. Otherwise read the txt file store directly.
var commands []model.ListedCommand
useBolt := config.Storage != nil && config.Storage.Engine == model.StorageEngineBolt
if useBolt && daemon.IsSocketReady(ctx, config.SocketPath) {
resp, err := daemon.RequestListCommands(config.SocketPath, 2*time.Second)
if err != nil {
slog.Error("Failed to parse post command", slog.Any("err", err), slog.String("line", string(line)))
continue
return err
}

key := postCommand.GetUniqueKey()
preCommands, ok := preFileTree[key]
if !ok {
continue
}

closestPreCommand := postCommand.FindClosestCommand(preCommands, false)
startTime := postCommand.Time
if closestPreCommand != nil {
startTime = closestPreCommand.Time
commands = resp.Commands
} else {
commands, err = model.BuildListedCommands(ctx, model.NewFileStore())
if err != nil {
return err
}

commands = append(commands, struct {
Command string `json:"command"`
Shell string `json:"shell"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Result int `json:"result"`
Username string `json:"username"`
Hostname string `json:"hostname"`
}{
Command: postCommand.Command,
Shell: postCommand.Shell,
StartTime: startTime,
EndTime: postCommand.Time,
Result: postCommand.Result,
Username: postCommand.Username,
Hostname: postCommand.Hostname,
})
}

// Output based on format
Expand All @@ -132,15 +92,7 @@ func outputJSON(commands interface{}) error {
return nil
}

func outputTable(commands []struct {
Command string `json:"command"`
Shell string `json:"shell"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Result int `json:"result"`
Username string `json:"username"`
Hostname string `json:"hostname"`
}) error {
func outputTable(commands []model.ListedCommand) error {
w := tablewriter.NewWriter(os.Stdout)
w.Header([]string{"COMMAND", "SHELL", "START TIME", "END TIME", "DURATION(ms)", "STATUS", "USER", "HOST"})

Expand Down
42 changes: 42 additions & 0 deletions commands/ls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package commands

import (
"context"
"os"
"testing"
"time"

"github.com/malamtime/cli/model"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace/noop"
)

// TestLsCommandFileMode exercises `shelltime ls` against the txt file store
// (no storage config => file mode, daemon not consulted).
func TestLsCommandFileMode(t *testing.T) {
otel.SetTracerProvider(noop.NewTracerProvider())
SKIP_LOGGER_SETTINGS = true

t.Setenv("HOME", t.TempDir())
model.InitFolder("")
require.NoError(t, os.MkdirAll(os.ExpandEnv("$HOME/"+model.COMMAND_STORAGE_FOLDER), os.ModePerm))

store := model.NewFileStore()
now := time.Now()
cmd := model.Command{Shell: "bash", SessionID: 1, Command: "make", Username: "u", Hostname: "h", Time: now}
require.NoError(t, store.SavePre(context.Background(), cmd, now))
post := cmd
post.Time = now.Add(time.Second)
require.NoError(t, store.SavePost(context.Background(), post, 0, post.Time))

cs := model.NewMockConfigService(t)
cs.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{}, nil)
configService = cs

app := &cli.App{Name: "mtt", Commands: []*cli.Command{LsCommand}}
require.NoError(t, app.Run([]string{"mtt", "ls", "-f", "json"}))
require.NoError(t, app.Run([]string{"mtt", "ls", "-f", "table"}))
}
Loading
Loading