├── .travis.yml ├── cmd ├── testdata │ └── example.txt ├── filter_test.go ├── cmd_test.go └── cmd.go ├── filter ├── interface.go ├── grep_test.go ├── grep.go └── sed.go ├── .gitignore ├── LICENSE └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.9 4 | - tip -------------------------------------------------------------------------------- /cmd/testdata/example.txt: -------------------------------------------------------------------------------- 1 | hello foo 2 | bar hello 3 | herro baz -------------------------------------------------------------------------------- /filter/interface.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import "io" 4 | 5 | type Filter interface { 6 | Apply(io.Writer, io.Reader) error 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | -------------------------------------------------------------------------------- /filter/grep_test.go: -------------------------------------------------------------------------------- 1 | package filter_test 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "testing" 7 | 8 | "github.com/lestrrat-go/scripting/filter" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestGrepBadPattern(t *testing.T) { 13 | g := filter.Grep(`(unterminated`) 14 | err := g.Apply(ioutil.Discard, &bytes.Buffer{}) 15 | if !assert.Error(t, err, "g.Apply should fail") { 16 | return 17 | } 18 | 19 | err2 := g.Apply(ioutil.Discard, &bytes.Buffer{}) 20 | if !assert.Equal(t, err, err2, "g.Apply should return same error") { 21 | return 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /cmd/filter_test.go: -------------------------------------------------------------------------------- 1 | package cmd_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lestrrat-go/scripting/cmd" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSed(t *testing.T) { 11 | r, err := cmd.New("cat", "testdata/example.txt"). 12 | CaptureStderr(true). 13 | CaptureStdout(true). 14 | Sed("hello", "こんにちわ"). 15 | Do(nil) 16 | if !assert.NoError(t, err, "command should succeed") { 17 | return 18 | } 19 | 20 | want := "こんにちわ foo\nbar こんにちわ\nherro baz\n" 21 | if !assert.Equal(t, want, r.OutputString(), "want %v, got %v", want, r.OutputString()) { 22 | return 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cmd/cmd_test.go: -------------------------------------------------------------------------------- 1 | package cmd_test 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/lestrrat-go/scripting/cmd" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestGrep(t *testing.T) { 14 | res, err := cmd.New("ls", "-l"). 15 | CaptureStdout(true). 16 | Grep(`_test\.go$`). 17 | Do(nil) 18 | if !assert.NoError(t, err, "ls -l should succeed") { 19 | return 20 | } 21 | 22 | // Each line should ONLY contain lines with _test.go$ 23 | scanner := bufio.NewScanner(res.Output()) 24 | for scanner.Scan() { 25 | txt := scanner.Text() 26 | t.Logf("got '%s'", txt) 27 | if !assert.True(t, strings.HasSuffix(txt, "_test.go"), "each line should contain _test.go") { 28 | return 29 | } 30 | } 31 | } 32 | 33 | func ExampleCommand() { 34 | _, err := cmd.New("ls", "-l"). 35 | CaptureStdout(true). 36 | Grep(`_test\.go$`). 37 | Do(nil) 38 | if err != nil { 39 | fmt.Printf("%s\n", err) 40 | } 41 | 42 | // OUTPUT: 43 | } 44 | -------------------------------------------------------------------------------- /filter/grep.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "regexp" 7 | "sync" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type grep struct { 13 | pattern string 14 | compiled *regexp.Regexp 15 | compileError error 16 | compileOnce sync.Once 17 | } 18 | 19 | func Grep(pattern string) Filter { 20 | return &grep{pattern: pattern} 21 | } 22 | 23 | func (g *grep) compilePattern() { 24 | re, err := regexp.Compile(g.pattern) 25 | if err != nil { 26 | g.compileError = errors.Wrapf(err, `failed to compile pattern '%s'`, g.pattern) 27 | } else { 28 | g.compiled = re 29 | } 30 | } 31 | 32 | func (g *grep) Apply(dst io.Writer, src io.Reader) error { 33 | g.compileOnce.Do(g.compilePattern) 34 | if g.compileError != nil { 35 | return g.compileError 36 | } 37 | 38 | re := g.compiled 39 | scanner := bufio.NewScanner(src) 40 | for scanner.Scan() { 41 | if txt := scanner.Text(); re.MatchString(txt) { 42 | io.WriteString(dst, txt) 43 | } 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /filter/sed.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "regexp" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | type sed struct { 13 | pattern string 14 | replace string 15 | } 16 | 17 | func Sed(pattern, replace string) Filter { 18 | return &sed{pattern: pattern, replace: replace} 19 | } 20 | 21 | func (s *sed) Apply(dst io.Writer, src io.Reader) error { 22 | re, err := regexp.Compile(s.pattern) 23 | if err != nil { 24 | return errors.Wrapf(err, `failed to compile pattern '%s'`, s.pattern) 25 | } 26 | 27 | scanner := bufio.NewScanner(src) 28 | for scanner.Scan() { 29 | text := scanner.Text() 30 | if m := re.FindAllStringIndex(text, -1); len(m) > 0 { 31 | var buf bytes.Buffer 32 | buf.WriteString(text[0:m[0][0]]) 33 | for i := range m { 34 | var e int 35 | if i == len(m)-1 { 36 | e = len(text) 37 | } else { 38 | e = m[i+1][0] 39 | } 40 | buf.WriteString(s.replace + text[m[i][1]:e]) 41 | } 42 | io.WriteString(dst, buf.String()+"\n") 43 | } else { 44 | io.WriteString(dst, text+"\n") 45 | } 46 | } 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 lestrrat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scripting 2 | 3 | Handy toolset when using Go as a shell script replacement 4 | 5 | [![Build Status](https://travis-ci.org/lestrrat-go/scripting.png?branch=master)](https://travis-ci.org/lestrrat-go/scripting) 6 | 7 | [![GoDoc](https://godoc.org/github.com/lestrrat-go/scripting?status.svg)](https://godoc.org/github.com/lestrrat-go/scripting) 8 | 9 | # CAVEAT EMPTOR 10 | 11 | The API is still very unstable. 12 | 13 | # DESCRIPTION 14 | 15 | Using Go to automate administrative tasks (e.g. deployment) is actually quite 16 | useful. Except, doing something as trivial as `execute a command, and then 17 | grep for a particular pattern` doesn't feel as easy as writing a real shell 18 | script. 19 | 20 | We can not just make everything magical like shell, but we can make certain 21 | operations a bit easier. That is what these libraries are for. 22 | 23 | # EXECUTING A SIMPLE COMMAND 24 | 25 | If you don't care to capture stdout or stderr, and all you care if the command 26 | runs successfully, you can use the shorthand `cmd.Exec` 27 | 28 | ```go 29 | cmd.Exec("make", "install") 30 | ``` 31 | 32 | # ADVANCED USAGE 33 | 34 | You can create a `cmd.Command` instance by using `cmd.New` 35 | 36 | ```go 37 | c := cmd.New("ls", "-l") 38 | ``` 39 | 40 | You can all `cmd.Do` to execute this command. Either pass nil, or the 41 | `context.Context` that you would like to use 42 | 43 | ```go 44 | res, err := c.Do(nil) 45 | ``` 46 | 47 | The first return value is an instance of `cmd.Result`. It will contain 48 | information on the result of executing the command, such as captured 49 | output. 50 | 51 | Output is not captured by default. You must explicitly state to do so. 52 | 53 | ```go 54 | c.CaptureStdout(true) 55 | c.CaptureStderr(true) 56 | res, _ := c.Do(nil) 57 | fmt.Println(res.OutputString()) 58 | ``` 59 | 60 | You can run certain filters on your output, such as `Grep` 61 | 62 | ```go 63 | c.Grep(`regular expression pattern`) 64 | ``` 65 | 66 | If you specify this before callin `Do()`, your result output will be filtered 67 | accordingly. 68 | 69 | Finally, this was getting really long. To avoid having to type everything in 70 | separate method calls, you can chain them all together 71 | 72 | ```go 73 | res, err := cmd.New("ls", "-l"). 74 | CaptureStdout(true). 75 | CaptureStderr(true). 76 | Grep(`regular expression pattern`). 77 | Do(nil) 78 | ``` 79 | 80 | For other options, please consult the godoc. 81 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "time" 11 | 12 | "github.com/briandowns/spinner" 13 | "github.com/lestrrat-go/scripting/filter" 14 | "github.com/pkg/errors" 15 | ) 16 | 17 | type Command struct { 18 | args []string 19 | bailOnErr bool 20 | captureStdout bool 21 | captureStderr bool 22 | filters []filter.Filter 23 | path string 24 | pipeToFile string 25 | spinner bool 26 | stdin io.Reader 27 | } 28 | 29 | type Result struct { 30 | output *bytes.Buffer 31 | } 32 | 33 | // JSON assumes that your accumulated output is a JSON string, and 34 | // attemps to decode it. 35 | func (r *Result) JSON(v interface{}) error { 36 | return json.NewDecoder(r.output).Decode(v) 37 | } 38 | 39 | func (r *Result) Output() *bytes.Buffer { 40 | return r.output 41 | } 42 | 43 | func (r *Result) OutputString() string { 44 | if r.output == nil { 45 | return "" 46 | } 47 | 48 | return r.output.String() 49 | } 50 | 51 | func Exec(path string, args ...string) error { 52 | _, err := New(path, args...).BailOnError(true).Do(nil) 53 | return err 54 | } 55 | 56 | // New creates a new Command instance. `path` (i.e. the command to 57 | // execute) is required. By default, `BailOnError` is true 58 | func New(path string, args ...string) *Command { 59 | return &Command{ 60 | bailOnErr: true, 61 | path: path, 62 | args: args, 63 | } 64 | } 65 | 66 | func (c *Command) BailOnError(b bool) *Command { 67 | c.bailOnErr = b 68 | return c 69 | } 70 | 71 | func (c *Command) CaptureStderr(b bool) *Command { 72 | c.captureStderr = b 73 | return c 74 | } 75 | 76 | func (c *Command) CaptureStdout(b bool) *Command { 77 | c.captureStdout = b 78 | return c 79 | } 80 | 81 | func (c *Command) PipeToFile(s string) *Command { 82 | c.pipeToFile = s 83 | return c 84 | } 85 | 86 | func (c *Command) Spinner(b bool) *Command { 87 | c.spinner = b 88 | return c 89 | } 90 | 91 | // Stdin sets a stdin to be piped to the command 92 | func (c *Command) Stdin(in io.Reader) *Command { 93 | c.stdin = in 94 | return c 95 | } 96 | 97 | // Grep adds a new filtering on the output of the command 98 | // after it has been executed. `pattern` is treated 99 | // as a regular expression. DO NOT forget to call one or both 100 | // of `CaptureStderr` or `CaptureStdout`, otherwise there will 101 | // be nothing to filter against. 102 | func (c *Command) Grep(pattern string) *Command { 103 | return c.Filter(filter.Grep(pattern)) 104 | } 105 | 106 | func (c *Command) Sed(pattern, replace string) *Command { 107 | return c.Filter(filter.Sed(pattern, replace)) 108 | } 109 | 110 | func (c *Command) Filter(f filter.Filter) *Command { 111 | c.filters = append(c.filters, f) 112 | return c 113 | } 114 | 115 | // Do executes the command, and applies other filters as necessary 116 | func (c *Command) Do(ctx context.Context) (*Result, error) { 117 | if ctx == nil { 118 | var cancel context.CancelFunc 119 | ctx, cancel = context.WithCancel(context.Background()) 120 | defer cancel() 121 | } 122 | 123 | // create an execution context, so we don't have to worry about 124 | // people mutating `c` after Do() is called, or if they call Do() 125 | // multiple times and stuff 126 | var ec execCtx 127 | ec.args = c.args 128 | ec.bailOnErr = c.bailOnErr 129 | ec.captureStdout = c.captureStdout 130 | ec.captureStderr = c.captureStderr 131 | ec.filters = c.filters 132 | ec.path = c.path 133 | ec.pipeToFile = c.pipeToFile 134 | ec.spinner = c.spinner 135 | ec.stdin = c.stdin 136 | 137 | return ec.Do(ctx) 138 | } 139 | 140 | type execCtx Command 141 | 142 | func (c *execCtx) Do(ctx context.Context) (*Result, error) { 143 | done := make(chan struct{}) 144 | 145 | cmd := exec.CommandContext(ctx, c.path, c.args...) 146 | var out *bytes.Buffer 147 | 148 | if c.captureStdout || c.captureStderr { 149 | out = &bytes.Buffer{} 150 | if c.captureStdout { 151 | cmd.Stdout = out 152 | } 153 | if c.captureStderr { 154 | cmd.Stderr = out 155 | } 156 | } 157 | 158 | if c.stdin != nil { 159 | cmd.Stdin = c.stdin 160 | } 161 | 162 | // Start a spinner 163 | if c.spinner { 164 | // XXX for now, this is hardcoded 165 | s := spinner.New(spinner.CharSets[34], 100*time.Millisecond) 166 | s.Start() 167 | go func() { 168 | defer s.Stop() 169 | select { 170 | case <-ctx.Done(): 171 | case <-done: 172 | } 173 | }() 174 | } 175 | 176 | if err := cmd.Run(); err != nil { 177 | if c.bailOnErr { 178 | return &Result{output: out}, errors.Wrap(err, `failed to execute command`) 179 | } 180 | } 181 | close(done) 182 | 183 | if out != nil { 184 | for _, f := range c.filters { 185 | var dst bytes.Buffer 186 | if err := f.Apply(&dst, out); err != nil { 187 | return nil, errors.Wrapf(err, `failed to apply filter %s`, f) 188 | } 189 | out = &dst 190 | } 191 | 192 | if fn := c.pipeToFile; fn != "" { 193 | f, err := os.Create(fn) 194 | if err != nil { 195 | return nil, errors.Wrapf(err, `failed to open file %s for writing`, f) 196 | } 197 | defer f.Close() 198 | 199 | if _, err := io.Copy(f, out); err != nil { 200 | return nil, errors.Wrapf(err, `failed to write to file %s`, f) 201 | } 202 | } 203 | } 204 | 205 | return &Result{ 206 | output: out, 207 | }, nil 208 | } 209 | --------------------------------------------------------------------------------