|
| 1 | +package commands |
| 2 | + |
| 3 | +import ( |
| 4 | + "encoding/json" |
| 5 | + "fmt" |
| 6 | + "log/slog" |
| 7 | + "os" |
| 8 | + "strconv" |
| 9 | + "time" |
| 10 | + |
| 11 | + "github.com/briandowns/spinner" |
| 12 | + "github.com/gookit/color" |
| 13 | + "github.com/malamtime/cli/model" |
| 14 | + "github.com/olekukonko/tablewriter" |
| 15 | + "github.com/urfave/cli/v2" |
| 16 | + "go.opentelemetry.io/otel/trace" |
| 17 | +) |
| 18 | + |
| 19 | +var GrepCommand *cli.Command = &cli.Command{ |
| 20 | + Name: "rg", |
| 21 | + Aliases: []string{"grep"}, |
| 22 | + Usage: "Search server-synced commands", |
| 23 | + ArgsUsage: "<search-text>", |
| 24 | + Flags: []cli.Flag{ |
| 25 | + &cli.StringFlag{ |
| 26 | + Name: "format", |
| 27 | + Aliases: []string{"f"}, |
| 28 | + Value: "table", |
| 29 | + Usage: "output format (table/json)", |
| 30 | + }, |
| 31 | + &cli.IntFlag{ |
| 32 | + Name: "limit", |
| 33 | + Aliases: []string{"l"}, |
| 34 | + Value: 50, |
| 35 | + Usage: "maximum number of results", |
| 36 | + }, |
| 37 | + &cli.IntFlag{ |
| 38 | + Name: "last-id", |
| 39 | + Value: 0, |
| 40 | + Usage: "start after this command ID (for pagination)", |
| 41 | + }, |
| 42 | + &cli.StringFlag{ |
| 43 | + Name: "shell", |
| 44 | + Aliases: []string{"s"}, |
| 45 | + Usage: "filter by shell (bash, zsh, fish)", |
| 46 | + }, |
| 47 | + &cli.StringFlag{ |
| 48 | + Name: "hostname", |
| 49 | + Aliases: []string{"H"}, |
| 50 | + Usage: "filter by hostname", |
| 51 | + }, |
| 52 | + &cli.StringFlag{ |
| 53 | + Name: "username", |
| 54 | + Aliases: []string{"u"}, |
| 55 | + Usage: "filter by username", |
| 56 | + }, |
| 57 | + &cli.IntFlag{ |
| 58 | + Name: "result", |
| 59 | + Aliases: []string{"r"}, |
| 60 | + Value: -1, |
| 61 | + Usage: "filter by exit code (-1 means any)", |
| 62 | + }, |
| 63 | + &cli.StringFlag{ |
| 64 | + Name: "main-command", |
| 65 | + Aliases: []string{"m"}, |
| 66 | + Usage: "filter by main command (e.g., git, npm)", |
| 67 | + }, |
| 68 | + &cli.StringFlag{ |
| 69 | + Name: "since", |
| 70 | + Usage: "filter commands since date (2024, 2024-01, or 2024-01-15)", |
| 71 | + }, |
| 72 | + &cli.StringFlag{ |
| 73 | + Name: "until", |
| 74 | + Usage: "filter commands until date (2024, 2024-01, or 2024-01-15)", |
| 75 | + }, |
| 76 | + }, |
| 77 | + Action: commandGrep, |
| 78 | + OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error { |
| 79 | + color.Red.Println(err.Error()) |
| 80 | + return nil |
| 81 | + }, |
| 82 | +} |
| 83 | + |
| 84 | +func commandGrep(c *cli.Context) error { |
| 85 | + ctx, span := commandTracer.Start(c.Context, "grep", trace.WithSpanKind(trace.SpanKindClient)) |
| 86 | + defer span.End() |
| 87 | + |
| 88 | + SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER)) |
| 89 | + |
| 90 | + // Validate format |
| 91 | + format := c.String("format") |
| 92 | + if format != "table" && format != "json" { |
| 93 | + return fmt.Errorf("unsupported format: %s. Use 'table' or 'json'", format) |
| 94 | + } |
| 95 | + |
| 96 | + // Get search text from args |
| 97 | + searchText := c.Args().First() |
| 98 | + slog.Debug("grep command args", |
| 99 | + slog.String("first", searchText), |
| 100 | + slog.Int("nArgs", c.NArg()), |
| 101 | + slog.Any("allArgs", c.Args().Slice())) |
| 102 | + if searchText == "" { |
| 103 | + return fmt.Errorf("search text is required. Usage: shelltime grep <search-text>") |
| 104 | + } |
| 105 | + |
| 106 | + // Read config to get endpoint and token |
| 107 | + cfg, err := configService.ReadConfigFile(ctx) |
| 108 | + if err != nil { |
| 109 | + return fmt.Errorf("failed to read config: %w", err) |
| 110 | + } |
| 111 | + |
| 112 | + if cfg.Token == "" { |
| 113 | + return fmt.Errorf("not authenticated. Please run 'shelltime auth' first") |
| 114 | + } |
| 115 | + |
| 116 | + endpoint := model.Endpoint{ |
| 117 | + APIEndpoint: cfg.APIEndpoint, |
| 118 | + Token: cfg.Token, |
| 119 | + } |
| 120 | + |
| 121 | + // Build filter |
| 122 | + filter, err := buildGrepFilter(c, searchText) |
| 123 | + if err != nil { |
| 124 | + return err |
| 125 | + } |
| 126 | + |
| 127 | + // Build pagination |
| 128 | + pagination := &model.SearchCommandsPagination{ |
| 129 | + LastID: c.Int("last-id"), |
| 130 | + Limit: c.Int("limit"), |
| 131 | + } |
| 132 | + |
| 133 | + slog.Debug("grep filter", |
| 134 | + slog.String("command", filter.Command), |
| 135 | + slog.Int("limit", pagination.Limit), |
| 136 | + slog.Int("lastId", pagination.LastID)) |
| 137 | + |
| 138 | + // Show loading spinner |
| 139 | + s := spinner.New(spinner.CharSets[35], 200*time.Millisecond) |
| 140 | + s.Suffix = " Searching commands..." |
| 141 | + s.Start() |
| 142 | + |
| 143 | + // Fetch commands from server |
| 144 | + result, err := model.FetchCommandsFromServer(ctx, endpoint, filter, pagination) |
| 145 | + s.Stop() |
| 146 | + if err != nil { |
| 147 | + if format == "json" { |
| 148 | + errOutput := struct { |
| 149 | + Error string `json:"error"` |
| 150 | + }{Error: err.Error()} |
| 151 | + jsonData, _ := json.MarshalIndent(errOutput, "", " ") |
| 152 | + fmt.Println(string(jsonData)) |
| 153 | + } else { |
| 154 | + color.Red.Printf("Error: %s\n", err.Error()) |
| 155 | + } |
| 156 | + return nil |
| 157 | + } |
| 158 | + |
| 159 | + slog.Debug("grep result", |
| 160 | + slog.Int("count", result.Count), |
| 161 | + slog.Int("edges", len(result.Edges))) |
| 162 | + |
| 163 | + if len(result.Edges) == 0 { |
| 164 | + color.Yellow.Println("No commands found matching your search") |
| 165 | + return nil |
| 166 | + } |
| 167 | + |
| 168 | + // Output based on format |
| 169 | + if format == "json" { |
| 170 | + return outputGrepJSON(result.Edges, result.Count) |
| 171 | + } |
| 172 | + return outputGrepTable(result.Edges, result.Count, c.Int("limit")) |
| 173 | +} |
| 174 | + |
| 175 | +func buildGrepFilter(c *cli.Context, searchText string) (*model.SearchCommandsFilter, error) { |
| 176 | + filter := &model.SearchCommandsFilter{ |
| 177 | + Shell: []string{}, |
| 178 | + MainCommand: []string{}, |
| 179 | + Hostname: []string{}, |
| 180 | + Username: []string{}, |
| 181 | + IP: []string{}, |
| 182 | + Result: []int{}, |
| 183 | + Time: []float64{}, |
| 184 | + SessionID: []float64{}, |
| 185 | + Command: searchText, |
| 186 | + } |
| 187 | + |
| 188 | + // Add optional filters if provided |
| 189 | + if shell := c.String("shell"); shell != "" { |
| 190 | + filter.Shell = []string{shell} |
| 191 | + } |
| 192 | + |
| 193 | + if hostname := c.String("hostname"); hostname != "" { |
| 194 | + filter.Hostname = []string{hostname} |
| 195 | + } |
| 196 | + |
| 197 | + if username := c.String("username"); username != "" { |
| 198 | + filter.Username = []string{username} |
| 199 | + } |
| 200 | + |
| 201 | + if result := c.Int("result"); result >= 0 { |
| 202 | + filter.Result = []int{result} |
| 203 | + } |
| 204 | + |
| 205 | + if mainCmd := c.String("main-command"); mainCmd != "" { |
| 206 | + filter.MainCommand = []string{mainCmd} |
| 207 | + } |
| 208 | + |
| 209 | + // Handle time filters with flexible date parsing |
| 210 | + var timeFilters []float64 |
| 211 | + if since := c.String("since"); since != "" { |
| 212 | + t, err := parseFlexibleDate(since, false) |
| 213 | + if err != nil { |
| 214 | + return nil, fmt.Errorf("invalid --since date: %w", err) |
| 215 | + } |
| 216 | + timeFilters = append(timeFilters, float64(t.UnixMilli())) |
| 217 | + } |
| 218 | + if until := c.String("until"); until != "" { |
| 219 | + t, err := parseFlexibleDate(until, true) |
| 220 | + if err != nil { |
| 221 | + return nil, fmt.Errorf("invalid --until date: %w", err) |
| 222 | + } |
| 223 | + timeFilters = append(timeFilters, float64(t.UnixMilli())) |
| 224 | + } |
| 225 | + if len(timeFilters) > 0 { |
| 226 | + filter.Time = timeFilters |
| 227 | + } |
| 228 | + |
| 229 | + return filter, nil |
| 230 | +} |
| 231 | + |
| 232 | +// parseFlexibleDate parses dates in formats: 2024, 2024-01, 2024-01-15 |
| 233 | +// If isEndOfPeriod is true, returns end of the period (for --until) |
| 234 | +func parseFlexibleDate(s string, isEndOfPeriod bool) (time.Time, error) { |
| 235 | + // Try year only: 2024 |
| 236 | + if t, err := time.Parse("2006", s); err == nil { |
| 237 | + if isEndOfPeriod { |
| 238 | + return time.Date(t.Year(), 12, 31, 23, 59, 59, 0, time.UTC), nil |
| 239 | + } |
| 240 | + return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, time.UTC), nil |
| 241 | + } |
| 242 | + |
| 243 | + // Try year-month: 2024-01 |
| 244 | + if t, err := time.Parse("2006-01", s); err == nil { |
| 245 | + if isEndOfPeriod { |
| 246 | + // End of month: go to next month, then subtract 1 second |
| 247 | + return t.AddDate(0, 1, 0).Add(-time.Second), nil |
| 248 | + } |
| 249 | + return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC), nil |
| 250 | + } |
| 251 | + |
| 252 | + // Try year-month-day: 2024-01-15 |
| 253 | + if t, err := time.Parse("2006-01-02", s); err == nil { |
| 254 | + if isEndOfPeriod { |
| 255 | + return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, time.UTC), nil |
| 256 | + } |
| 257 | + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC), nil |
| 258 | + } |
| 259 | + |
| 260 | + return time.Time{}, fmt.Errorf("use format: 2024, 2024-01, or 2024-01-15") |
| 261 | +} |
| 262 | + |
| 263 | +func outputGrepJSON(commands []model.SearchCommandEdge, totalCount int) error { |
| 264 | + output := struct { |
| 265 | + TotalCount int `json:"totalCount"` |
| 266 | + Commands []model.SearchCommandEdge `json:"commands"` |
| 267 | + }{ |
| 268 | + TotalCount: totalCount, |
| 269 | + Commands: commands, |
| 270 | + } |
| 271 | + |
| 272 | + jsonData, err := json.MarshalIndent(output, "", " ") |
| 273 | + if err != nil { |
| 274 | + return err |
| 275 | + } |
| 276 | + fmt.Println(string(jsonData)) |
| 277 | + return nil |
| 278 | +} |
| 279 | + |
| 280 | +func outputGrepTable(commands []model.SearchCommandEdge, totalCount, limit int) error { |
| 281 | + w := tablewriter.NewWriter(os.Stdout) |
| 282 | + w.Header([]string{"ID", "COMMAND", "SHELL", "TIME", "DURATION(ms)", "STATUS", "USER", "HOST"}) |
| 283 | + |
| 284 | + var lastID int |
| 285 | + for _, cmd := range commands { |
| 286 | + // Use originalCommand if encrypted and available |
| 287 | + displayCommand := cmd.Command |
| 288 | + if cmd.IsEncrypted && cmd.OriginalCommand != "" { |
| 289 | + displayCommand = cmd.OriginalCommand |
| 290 | + } |
| 291 | + |
| 292 | + // Convert milliseconds to time |
| 293 | + startTime := time.UnixMilli(int64(cmd.Time)) |
| 294 | + duration := int64(cmd.EndTime - cmd.Time) |
| 295 | + lastID = cmd.ID |
| 296 | + |
| 297 | + w.Append([]string{ |
| 298 | + strconv.Itoa(cmd.ID), |
| 299 | + displayCommand, |
| 300 | + cmd.Shell, |
| 301 | + startTime.Format(time.RFC3339), |
| 302 | + strconv.FormatInt(duration, 10), |
| 303 | + strconv.Itoa(cmd.Result), |
| 304 | + cmd.Username, |
| 305 | + cmd.Hostname, |
| 306 | + }) |
| 307 | + } |
| 308 | + |
| 309 | + w.Render() |
| 310 | + |
| 311 | + // Show result count summary |
| 312 | + showing := len(commands) |
| 313 | + if totalCount > showing { |
| 314 | + color.Gray.Printf("\nShowing %d of %d total results\n", showing, totalCount) |
| 315 | + color.Gray.Printf("Use --last-id %d to see more results\n", lastID) |
| 316 | + } |
| 317 | + |
| 318 | + return nil |
| 319 | +} |
0 commit comments