shell/cmd.go

238 lines
6.1 KiB
Go

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
}