package shell import ( "errors" "fmt" "time" "github.com/go-cmd/cmd" "go.wit.com/log" ) // this is a simplified interaction with the excellent // go-cmd/cmd package to work 'shell' like. // in all cases here, STDERR -> STDOUT // If you want the output from whatever you run // to be captured like it appears when you see it // on the command line, this is what this tries to do /* if r := shell.Run([]{"ping", "-c", "3", "localhost"}); r.Error == nil { if r.Exit == 0 { log.Println("ran ok") } else { log.Println("ran") } // all stdout/stderr captured in r.Stdout } */ // shortcut, sends a blank value for pwd // which means the exec Dir is not set // echos output (otherwise use RunQuiet) func Run(argv []string) cmd.Status { return PathRun("", argv) } // exec the cmd at a filepath. this does not change the working directory // sets the exec dir if it's not an empty string // combines stdout and stderr // echo's output (otherwise use PathRunQuiet() // this is basically the exact example from the go-cmd/cmd devs // where the have rocked out a proper smart read on both filehandles // https://dave.cheney.net/2013/04/30/curious-channels func PathRun(path string, argv []string) cmd.Status { return PathRunLog(path, argv, NOW) } // the actual wrapper around go-cmd/cmd // adds a log Flag so that echo to stdout can be enabled/disabled func PathRunLog(path string, argv []string, logf *log.LogFlag) cmd.Status { var save []string // combined stdout & stderr var arg0 string var args []string if logf == nil { logf = NOW } log.Log(logf, "shell.PathRunLog() Path =", path, "cmd =", argv) // Check if the slice has at least one element (the command name) if len(argv) == 0 { var s cmd.Status s.Error = errors.New("Error: Command slice is empty.") return s } if len(argv) == 1 { // Pass the first element as the command, and the rest as variadic arguments arg0 = argv[0] } else { arg0 = argv[0] args = argv[1:] } // Disable output buffering, enable streaming cmdOptions := cmd.Options{ Buffered: false, Streaming: true, } // Create Cmd with options envCmd := cmd.NewCmdOptions(cmdOptions, arg0, args...) if path != "" { // set the path for exec envCmd.Dir = path } // Print STDOUT and STDERR lines streaming from Cmd doneChan := make(chan struct{}) go func() { defer close(doneChan) // Done when both channels have been closed // https://dave.cheney.net/2013/04/30/curious-channels for envCmd.Stdout != nil || envCmd.Stderr != nil { select { case line, open := <-envCmd.Stdout: if !open { envCmd.Stdout = nil continue } save = append(save, line) log.Log(logf, line) // fmt.Println(line) case line, open := <-envCmd.Stderr: if !open { envCmd.Stderr = nil continue } save = append(save, line) log.Log(logf, line) // fmt.Println(line) } } }() // Run and wait for Cmd to return, discard Status <-envCmd.Start() // Wait for goroutine to print everything <-doneChan s := envCmd.Status() s.Stdout = save return s } // uses the 'log' package to disable echo to STDOUT // only echos if you enable the shell.INFO log flag func PathRunQuiet(pwd string, args []string) cmd.Status { return PathRunLog(pwd, args, INFO) } // send blank path == use current golang working directory func RunRealtime(args []string) cmd.Status { return PathRunRealtime("", args) } // echos twice a second if anything sends to STDOUT or STDERR // not great, but it's really just for watching things run in real time anyway // TODO: fix \r handling for things like git-clone so the terminal doesn't // have to do a \n newline each time. // TODO: add timeouts and status of things hanging around forever func PathRunRealtime(pwd string, args []string) cmd.Status { // Check if the slice has at least one element (the command name) if len(args) == 0 { var s cmd.Status s.Error = errors.New("Error: Command slice is empty.") return s } // Start a long-running process, capture stdout and stderr a, b := RemoveFirstElement(args) findCmd := cmd.NewCmd(a, b...) if pwd != "" { findCmd.Dir = pwd } statusChan := findCmd.Start() // non-blocking ticker := time.NewTicker(5 * time.Millisecond) // this is interesting, maybe useful, but wierd, but neat. interesting even // Print last line of stdout every 2s go func() { // loop very quickly, but only print the line if it changes var lastout string var lasterr string for range ticker.C { status := findCmd.Status() n := len(status.Stdout) if n != 0 { newline := status.Stdout[n-1] if lastout != newline { lastout = newline log.Info(lastout) } } n = len(status.Stderr) if n != 0 { newline := status.Stderr[n-1] if lasterr != newline { lasterr = newline log.Info(lasterr) } } if status.Complete { return } } }() // Stop command after 1 hour go func() { <-time.After(1 * time.Hour) findCmd.Stop() }() // Check if command is done select { case finalStatus := <-statusChan: log.Info("finalStatus =", finalStatus.Exit, finalStatus.Error) return finalStatus // done default: // no, still running } // Block waiting for command to exit, be stopped, or be killed finalStatus := <-statusChan return finalStatus } func blah(cmd []string) { r := Run(cmd) log.Info("cmd =", r.Cmd) log.Info("complete =", r.Complete) log.Info("exit =", r.Exit) log.Info("err =", r.Error) log.Info("len(stdout+stderr) =", len(r.Stdout)) } // run these to see confirm the sytem behaves as expected func RunTest() { blah([]string{"ping", "-c", "3", "localhost"}) blah([]string{"exit", "0"}) blah([]string{"exit", "-1"}) blah([]string{"true"}) blah([]string{"false"}) blah([]string{"grep", "root", "/etc/", "/proc/cmdline", "/usr/bin/chmod"}) blah([]string{"grep", "root", "/proc/cmdline"}) fmt.Sprint("blahdone") } // this is stuff from a long time ago that there must be a replacement for func RemoveFirstElement(slice []string) (string, []string) { if len(slice) == 0 { return "", slice // Return the original slice if it's empty } return slice[0], slice[1:] // Return the slice without the first element }