summaryrefslogtreecommitdiff
path: root/cmd.go
blob: 02cc017c598dd49e3cbc0c462ace8c65efbc9590 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
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 RunQuiet(args []string) cmd.Status {
	return PathRunLog("", args, INFO)
}

// 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
}