diff --git a/cmd/cmd_run.go b/cmd/cmd_run.go new file mode 100644 index 0000000..1feb18b --- /dev/null +++ b/cmd/cmd_run.go @@ -0,0 +1,41 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + + "github.com/lba-studio/n-cli/pkg/notifier/marker" + "github.com/spf13/cobra" +) + +func NewRunCmd() *cobra.Command { + return &cobra.Command{ + Use: "run", + Aliases: []string{"r"}, + Args: cobra.MinimumNArgs(1), + Short: "Runs arbitrary shell commands through n-cli.", + Long: "Runs arbitrary shell commands. For example, `n-cli run echo Hello, world!` If you'd like to pass in flags to your shell command, use `n-cli run mycommand -- --flag1=true --flag2`.", + Run: func(cobraCmd *cobra.Command, args []string) { + command := args[0] + commandArgs := args[1:] + + cmd := exec.Command(command, commandArgs...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + defer func() { + os.Exit(cmd.ProcessState.ExitCode()) + }() + + m := marker.NewNotificationMarker(cmd) + defer m.Done() + + err := cmd.Run() + if err != nil { + fmt.Printf("n-cli run error: %s\n", err.Error()) + } + }, + } +} diff --git a/cmd/root.go b/cmd/root.go index 541a49a..04d1e0c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,6 +34,7 @@ func init() { NewWhereCmd(), NewInitCmd(), NewVersionCmd(), + NewRunCmd(), ) } diff --git a/pkg/formatter/number.go b/pkg/formatter/number.go new file mode 100644 index 0000000..6c2bd59 --- /dev/null +++ b/pkg/formatter/number.go @@ -0,0 +1,40 @@ +package formatter + +import ( + "strconv" +) + +func PrettyPrintInt64(num int64) string { + isNegative := false + if num < 0 { + isNegative = true + num = -num + } + numberTokens := make([]string, 0) + for { + numStr := strconv.FormatInt(num%1000, 10) + if num < 1000 { + numberTokens = append(numberTokens, numStr) + break + } + for len(numStr) < 3 { + numStr = "0" + numStr + } + numberTokens = append(numberTokens, numStr) + num = num / 1000 + } + // convert to string + out := "" + for _, token := range numberTokens { + if out == "" { + out = token + } else { + out = token + "," + out + } + } + if isNegative { + return "-" + out + } else { + return out + } +} diff --git a/pkg/formatter/number_test.go b/pkg/formatter/number_test.go new file mode 100644 index 0000000..1b00d88 --- /dev/null +++ b/pkg/formatter/number_test.go @@ -0,0 +1,30 @@ +package formatter + +import ( + "fmt" + "testing" +) + +func TestPrettyPrintInt64(t *testing.T) { + testCases := []struct { + input int64 + expected string + }{ + {input: 0, expected: "0"}, + {input: 123, expected: "123"}, + {input: 1000, expected: "1,000"}, + {input: 123456789, expected: "123,456,789"}, + {input: 9876543210, expected: "9,876,543,210"}, + {input: -123456789, expected: "-123,456,789"}, + {input: -9876543210, expected: "-9,876,543,210"}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("input=%d expected=%s", tc.input, tc.expected), func(t *testing.T) { + result := PrettyPrintInt64(tc.input) + if result != tc.expected { + t.Errorf("Expected: %s, but got: %s", tc.expected, result) + } + }) + } +} diff --git a/pkg/monitor/errors.go b/pkg/monitor/errors.go new file mode 100644 index 0000000..bc23150 --- /dev/null +++ b/pkg/monitor/errors.go @@ -0,0 +1,8 @@ +package monitor + +import "errors" + +var ( + ErrCallNotSupported = errors.New("this syscall / operation is unsupported") + ErrIsWindows = errors.New("not supported on Windows-based machines") +) diff --git a/pkg/monitor/is_windows_default.go b/pkg/monitor/is_windows_default.go new file mode 100644 index 0000000..d2f401c --- /dev/null +++ b/pkg/monitor/is_windows_default.go @@ -0,0 +1,7 @@ +//go:build !windows + +package monitor + +func IsWindows() bool { + return false +} diff --git a/pkg/monitor/is_windows_windows.go b/pkg/monitor/is_windows_windows.go new file mode 100644 index 0000000..c94abe1 --- /dev/null +++ b/pkg/monitor/is_windows_windows.go @@ -0,0 +1,6 @@ +package monitor + +// IsWindows returns a magical +func IsWindows() bool { + return true +} diff --git a/pkg/monitor/unix.go b/pkg/monitor/unix.go new file mode 100644 index 0000000..0746663 --- /dev/null +++ b/pkg/monitor/unix.go @@ -0,0 +1,24 @@ +//go:build !(linux && 386) && !windows + +package monitor + +import ( + "os/exec" + "syscall" +) + +// GetCPU returns the CPU time in nanoseconds +func GetCPU() (int64, error) { + usage := new(syscall.Rusage) + syscall.Getrusage(syscall.RUSAGE_CHILDREN, usage) + out := usage.Utime.Nano() + usage.Stime.Nano() + return out, nil +} + +func GetMemoryFromCmd(cmd *exec.Cmd) (int64, error) { + rusage, ok := cmd.ProcessState.SysUsage().(*syscall.Rusage) + if !ok { + return 0, ErrCallNotSupported + } + return rusage.Maxrss, nil +} diff --git a/pkg/monitor/unix_386.go b/pkg/monitor/unix_386.go new file mode 100644 index 0000000..8208e5b --- /dev/null +++ b/pkg/monitor/unix_386.go @@ -0,0 +1,24 @@ +//go:build linux && 386 + +package monitor + +import ( + "os/exec" + "syscall" +) + +// GetCPU returns the CPU time in nanoseconds +func GetCPU() (int64, error) { + usage := new(syscall.Rusage) + syscall.Getrusage(syscall.RUSAGE_CHILDREN, usage) + out := usage.Utime.Nano() + usage.Stime.Nano() + return int64(out), nil +} + +func GetMemoryFromCmd(cmd *exec.Cmd) (int64, error) { + rusage, ok := cmd.ProcessState.SysUsage().(*syscall.Rusage) + if !ok { + return 0, ErrCallNotSupported + } + return int64(rusage.Maxrss), nil +} diff --git a/pkg/monitor/windows.go b/pkg/monitor/windows.go new file mode 100644 index 0000000..f3173b0 --- /dev/null +++ b/pkg/monitor/windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package monitor + +import ( + "os/exec" +) + +// GetCPU returns the CPU time in nanoseconds +func GetCPU() (int64, error) { + // not supported (for now?) + return 0, ErrIsWindows +} + +func GetMemoryFromCmd(cmd *exec.Cmd) (int64, error) { + // not supported (for now?) + return 0, ErrIsWindows +} diff --git a/pkg/notifier/marker/marker.go b/pkg/notifier/marker/marker.go new file mode 100644 index 0000000..ba3ca4b --- /dev/null +++ b/pkg/notifier/marker/marker.go @@ -0,0 +1,93 @@ +package marker + +import ( + "fmt" + "os/exec" + "strings" + "time" + + "github.com/lba-studio/n-cli/pkg/formatter" + "github.com/lba-studio/n-cli/pkg/monitor" + "github.com/lba-studio/n-cli/pkg/notifier" +) + +type NotificationMarker interface { + Done() +} + +type NotificationMarkerImpl struct { + StartedFrom time.Time + Command *exec.Cmd +} + +func NewNotificationMarker(cmd *exec.Cmd) NotificationMarker { + return &NotificationMarkerImpl{ + StartedFrom: time.Now(), + Command: cmd, + } +} + +type printedMarkerInfo struct { + exitCode int + elapsed string + cpuTime string + memoryUsage int64 +} + +func (m *NotificationMarkerImpl) formatMessage(info printedMarkerInfo) string { + status := "COMPLETE" + if info.exitCode > 0 { + status = "FAILED" + } + prettyCommand := strings.Join(m.Command.Args, " ") + + infoStrings := []string{ + fmt.Sprintf("Command `%s` %s.", prettyCommand, status), + fmt.Sprintf("Elapsed: %s", info.elapsed), + } + if !monitor.IsWindows() { + infoStrings = append(infoStrings, + fmt.Sprintf("CPU Time: %s", info.cpuTime), + fmt.Sprintf("Memory Usage: %s", formatter.PrettyPrintInt64(info.memoryUsage)), + ) + } + + return strings.Join(infoStrings, "\n") +} + +func (m *NotificationMarkerImpl) Done() { + defer func() { + if r := recover(); r != nil { + fmt.Println("Panic encountered while processing process information. Skipping analytics.", r) + } + }() + elapsed := time.Since(m.StartedFrom) + cpuTimeNano, err := monitor.GetCPU() + if err != nil && err != monitor.ErrIsWindows { + fmt.Printf("Cannot get cpuTimeNano: %s\n", err.Error()) + return + } + cpuTime := time.Duration(time.Duration(cpuTimeNano) * time.Nanosecond) + + memoryUsage, err := monitor.GetMemoryFromCmd(m.Command) + if err != nil && err != monitor.ErrIsWindows { + fmt.Printf("Cannot get memoryUsage: %s\n", err.Error()) + return + } + + exitCode := m.Command.ProcessState.ExitCode() + if exitCode < 0 { + return + } + msg := m.formatMessage(printedMarkerInfo{ + memoryUsage: memoryUsage, + cpuTime: cpuTime.String(), + elapsed: elapsed.String(), + exitCode: exitCode, + }) + + err = notifier.Notify(msg) + if err != nil { + fmt.Printf("Error encountered when sending notification: %s\n", err.Error()) + } +}