├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── README.md ├── command.go ├── command_test.go ├── context.go ├── context_test.go ├── environment.go ├── environment_test.go ├── examples ├── command-checking │ └── command-checking.go ├── interview │ └── interview.go ├── printing │ └── printing.go ├── process-handling │ └── process_handling.go └── progress │ └── progress.go ├── file.go ├── file_test.go ├── filesystem.go ├── filesystem_test.go ├── go.mod ├── go.sum ├── helpers_test.go ├── interview └── interview.go ├── precommit.sh ├── print └── print.go ├── process.go ├── process_test.go ├── progress.go ├── progress_test.go ├── script.go ├── shutil.go └── test ├── bin ├── basic-output ├── bin ├── bin.go ├── echo ├── exit-code-error ├── make.sh └── sleep ├── dir ├── dir-2.txt ├── dir.txt └── subdir │ └── subdir-file ├── file.cfg └── file.sample /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Golang CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-go/ for more details 4 | version: 2 5 | 6 | jobs: 7 | build: 8 | docker: 9 | - image: circleci/golang:1.9 10 | working_directory: /go/src/github.com/jojomi/go-script 11 | steps: 12 | - checkout 13 | - run: go get -v -t -d ./... 14 | - run: go test -v ./... 15 | coverage: 16 | docker: 17 | - image: circleci/golang:1.9 18 | working_directory: /go/src/github.com/jojomi/go-script 19 | steps: 20 | - checkout 21 | - run: go get github.com/mattn/goveralls 22 | - run: go test -v -cover -race -coverprofile=/tmp/coverage.out && /go/bin/goveralls -coverprofile=/tmp/coverage.out -service=circle-ci -repotoken=$COVERALLS_TOKEN 23 | 24 | workflows: 25 | version: 2 26 | build_and_coverage: 27 | jobs: 28 | - build 29 | - coverage -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project specific 2 | cover.out 3 | vendor 4 | 5 | 6 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 7 | *.o 8 | *.a 9 | *.so 10 | 11 | # Folders 12 | _obj 13 | _test 14 | 15 | # Architecture specific extensions/prefixes 16 | *.[568vq] 17 | [568vq].out 18 | 19 | *.cgo1.go 20 | *.cgo2.c 21 | _cgo_defun.c 22 | _cgo_gotypes.go 23 | _cgo_export.* 24 | 25 | _testmain.go 26 | 27 | *.exe 28 | *.test 29 | *.prof 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-script 2 | 3 | Go library facilitating the creation of programs that resemble bash scripts. 4 | 5 | 6 | ## Rationale 7 | 8 | [Go](https://golang.org)'s advantages like static binding and a huge modern standard library do suggest its usage for little tools that used to be implemented as shell scripts. 9 | 10 | This library is intended as a wrapper for typical tasks shell scripts include and aimed at bringing the LOC size closer to unparalleled `bash` shortness. 11 | 12 | `go-script` uses several other libraries that enable you to create scripts with a good user feedback and user interface on the command line. 13 | 14 | This library strives for a good test coverage even though it is not always easy for user facing code like this. 15 | 16 | 17 | ## Methods 18 | 19 | [![GoDoc](https://godoc.org/github.com/jojomi/go-script?status.svg)](https://godoc.org/github.com/jojomi/go-script) 20 | ![![CircleCI](https://circleci.com/gh/jojomi/go-script.svg?style=svg)](https://circleci.com/gh/jojomi/go-script) 21 | [![Coverage Status](https://coveralls.io/repos/github/jojomi/go-script/badge.svg?branch=master)](https://coveralls.io/github/jojomi/go-script?branch=master) 22 | [![Go Report Card](https://goreportcard.com/badge/github.com/jojomi/go-script)](https://goreportcard.com/report/github.com/jojomi/go-script) 23 | 24 | The methods include helpers for [executing external commands](process.go) (including [environment variables](environment.go)), maintaining a [working directory](context.go), handling [files and directories](filesystem.go) (cp/mv), and evaluating [command output](process.go) (exit code, stdout/stderr). You can use methods for [requesting input](interaction.go) from users, print [progress bars and activity indicators](progress.go), and use helpers for [printing colorful or bold text](print.go). 25 | 26 | 27 | ## Usage 28 | 29 | ```go 30 | package main 31 | 32 | import ( 33 | "fmt" 34 | "github.com/jojomi/go-script" 35 | ) 36 | 37 | func main() { 38 | sc := script.NewContext() 39 | sc.MustCommandExist("date") 40 | sc.SetWorkingDir("/tmp") 41 | pr := sc.MustExecuteSilent("date", "-R") 42 | fmt.Print("The current date: ", pr.Output()) 43 | fmt.Println(pr.StateString()) 44 | } 45 | ``` 46 | 47 | More example can be found in the `examples` directory, execute them like this: 48 | 49 | `go run examples/command-checking/command-checking.go` 50 | 51 | 52 | ## Warning 53 | 54 | This library's API is not yet stable. Use at your own discretion. 55 | 56 | You should be prepared for future API changes of any kind. 57 | 58 | In doubt, fork 59 | away to keep a certain API status or use vendoring ([dep](https://github.com/golang/dep)) to keep your desired state. 60 | 61 | 62 | ## On The Shoulders or Giants 63 | 64 | ### Libraries Used in `go-script` 65 | 66 | * [go-isatty](github.com/mattn/go-isatty) to detect terminal capabilities 67 | * [survey](gopkg.in/AlecAivazis/survey.v1) for user interactions 68 | * [wow](github.com/gernest/wow) for activity indicators 69 | * [pb](gopkg.in/cheggaaa/pb.v1) for progress bars 70 | * [color](https://github.com/fatih/color) for printing colorful and bold output 71 | * [go-shutil](https://github.com/termie/go-shutil) (forked) for copying data 72 | 73 | * [afero](github.com/spf13/afero) for abstracting filesystem for easier testing 74 | 75 | ### Other Libraries 76 | 77 | Some libraries have proven highly useful in conjunction with `go-script`: 78 | 79 | * [termtables](https://github.com/apcera/termtables) 80 | 81 | More inspiration can be found at [awesome-go](https://github.com/avelino/awesome-go#command-line). 82 | 83 | 84 | ## Development 85 | 86 | Comments, issues, and of course pull requests are highly welcome. 87 | 88 | If you create a Merge Request, be sure to execute `./precommit.sh` beforehand. 89 | 90 | 91 | ## License 92 | 93 | see [LICENSE](LICENSE) 94 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | type Command interface { 8 | Binary() string 9 | Args() []string 10 | Add(input string) 11 | AddAll(input ...string) 12 | String() string 13 | } 14 | 15 | type LocalCommand struct { 16 | elements []string 17 | } 18 | 19 | func NewLocalCommand() *LocalCommand { 20 | l := LocalCommand{} 21 | return &l 22 | } 23 | 24 | func LocalCommandFrom(command string) *LocalCommand { 25 | c, args := SplitCommand(command) 26 | l := NewLocalCommand() 27 | l.Add(c) 28 | l.AddAll(args...) 29 | return l 30 | } 31 | 32 | func (l *LocalCommand) AddAll(input ...string) { 33 | for _, i := range input { 34 | l.Add(i) 35 | } 36 | } 37 | 38 | func (l *LocalCommand) Add(input string) { 39 | if l.elements == nil { 40 | l.elements = make([]string, 0) 41 | } 42 | l.elements = append(l.elements, input) 43 | } 44 | 45 | func (l *LocalCommand) Binary() string { 46 | if l.elements == nil || len(l.elements) == 0 { 47 | return "" 48 | } 49 | return l.elements[0] 50 | } 51 | 52 | func (l *LocalCommand) Args() []string { 53 | if l.elements == nil || len(l.elements) < 2 { 54 | return []string{} 55 | } 56 | return l.elements[1:] 57 | } 58 | 59 | func isWrapped(source, s string) bool { 60 | return strings.HasPrefix(source, s) && strings.HasSuffix(source, s) 61 | } 62 | 63 | func (l *LocalCommand) String() string { 64 | var b strings.Builder 65 | for i, e := range l.elements { 66 | if i > 0 { 67 | b.WriteString(" ") 68 | } 69 | // contains double quotes? escape them! 70 | if strings.Contains(e, `"`) { 71 | e = strings.ReplaceAll(e, `"`, `\"`) 72 | } 73 | // contains Whitespace? wrap with double quotes 74 | if strings.Contains(e, ` `) { 75 | if !isWrapped(e, `"`) && !isWrapped(e, `'`) { 76 | e = `"` + e + `"` 77 | } 78 | } 79 | b.WriteString(e) 80 | } 81 | return b.String() 82 | } 83 | 84 | // SplitCommand helper splits a string to command and arbitrarily many args. 85 | // Does handle bash-like escaping (\) and string delimiters " and '. 86 | func SplitCommand(input string) (command string, args []string) { 87 | quotes := []string{`"`, `'`} 88 | 89 | var ( 90 | ok bool 91 | length int 92 | value string 93 | index = 0 94 | ) 95 | args = make([]string, 0) 96 | 97 | outerloop: 98 | for { 99 | if index >= len(input) { 100 | break 101 | } 102 | 103 | ok, length, _ = parseWhitespace(input[index:]) 104 | if ok { 105 | index += length 106 | continue 107 | } 108 | 109 | for _, quote := range quotes { 110 | ok, length, value = parseQuoted(input[index:], quote, `\`+quote) 111 | if ok { 112 | if command == "" { 113 | command = value 114 | } else { 115 | args = append(args, value) 116 | } 117 | index += length 118 | continue outerloop 119 | } 120 | } 121 | 122 | ok, length, value = parseUnquoted(input[index:]) 123 | if ok { 124 | if command == "" { 125 | command = value 126 | } else { 127 | args = append(args, value) 128 | } 129 | index += length 130 | continue 131 | } 132 | } 133 | return 134 | } 135 | 136 | func parseQuoted(input, quoteString, escapeString string) (ok bool, length int, value string) { 137 | if !strings.HasPrefix(input, quoteString) { 138 | return 139 | } 140 | 141 | length = len(quoteString) 142 | for { 143 | if length >= len(input) { 144 | break 145 | } 146 | // escaped quoteString? (continue!) 147 | if strings.HasPrefix(input[length:], escapeString) { 148 | length += len(escapeString) 149 | value += quoteString 150 | } 151 | // quoteString (end!) 152 | if strings.HasPrefix(input[length:], quoteString) { 153 | length += len(quoteString) 154 | ok = true 155 | return 156 | } 157 | 158 | // otherwise inner content 159 | value += input[length : length+1] 160 | length++ 161 | } 162 | 163 | return ok, length, value 164 | } 165 | 166 | func parseUnquoted(input string) (ok bool, length int, value string) { 167 | length = 0 168 | for { 169 | if length >= len(input) { 170 | ok = true 171 | return 172 | } 173 | // whitespace (end!) // TODO all whitespace! 174 | if strings.HasPrefix(input[length:], " ") { 175 | length++ 176 | ok = true 177 | return 178 | } 179 | 180 | // otherwise inner content 181 | value += input[length : length+1] 182 | length++ 183 | } 184 | } 185 | 186 | func parseWhitespace(input string) (ok bool, length int, value string) { 187 | length = 0 188 | for { 189 | if length >= len(input) { 190 | break 191 | } 192 | // no whitespace (end!) // TODO all whitespace! 193 | if !strings.HasPrefix(input[length:], " ") { 194 | ok = length > 0 195 | return 196 | } 197 | 198 | // otherwise inner content (whitespace) 199 | value += input[length : length+1] 200 | length++ 201 | } 202 | 203 | return ok, length, value 204 | } 205 | -------------------------------------------------------------------------------- /command_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestLocalCommandAdd(t *testing.T) { 10 | c := NewLocalCommand() 11 | assert.Equal(t, "", c.Binary()) 12 | c.Add("ssh") 13 | assert.Equal(t, "ssh", c.Binary()) 14 | c.Add("myhost") 15 | assert.Equal(t, "ssh", c.Binary()) 16 | assert.Equal(t, 1, len(c.Args())) 17 | assert.Equal(t, "myhost", c.Args()[0]) 18 | } 19 | 20 | func TestLocalCommandAddAll(t *testing.T) { 21 | c := NewLocalCommand() 22 | assert.Equal(t, "", c.Binary()) 23 | c.AddAll("ssh", "myhost", "remotecommand") 24 | assert.Equal(t, "ssh", c.Binary()) 25 | assert.Equal(t, 2, len(c.Args())) 26 | assert.Equal(t, []string{"myhost", "remotecommand"}, c.Args()) 27 | } 28 | 29 | func TestLocalCommandString(t *testing.T) { 30 | tests := []struct { 31 | Elements []string 32 | ValidOutput string 33 | }{ 34 | { 35 | Elements: []string{"ls", "-al", "file"}, 36 | ValidOutput: `ls -al file`, 37 | }, 38 | { 39 | Elements: []string{"ls", `my file.txt`}, 40 | ValidOutput: `ls "my file.txt"`, 41 | }, 42 | { 43 | Elements: []string{"ls", `*.test`}, 44 | ValidOutput: `ls *.test`, 45 | }, 46 | { 47 | Elements: []string{"ls", `weird".file`}, 48 | ValidOutput: `ls weird\".file`, 49 | }, 50 | { 51 | Elements: []string{"ls", `'my custom file'`}, 52 | ValidOutput: `ls 'my custom file'`, 53 | }, 54 | } 55 | 56 | for _, test := range tests { 57 | c := NewLocalCommand() 58 | c.AddAll(test.Elements...) 59 | assert.Equal(t, test.ValidOutput, c.String()) 60 | } 61 | } 62 | 63 | func TestSplitCommand(t *testing.T) { 64 | tests := []struct { 65 | input string 66 | command string 67 | args []string 68 | }{ 69 | // simple cases 70 | {"ls -la", "ls", []string{"-la"}}, 71 | {"./bin exit-code-error second_ARG", "./bin", []string{"exit-code-error", "second_ARG"}}, 72 | // special cases 73 | {"", "", []string{}}, 74 | // quoting 75 | {`"quoted bin" "fir st" 'sec ond'`, "quoted bin", []string{"fir st", "sec ond"}}, 76 | {`bin -p "fir st" "sec ond"`, "bin", []string{"-p", "fir st", "sec ond"}}, 77 | {`"\"bin" 'par am"'`, "\"bin", []string{"par am\""}}, 78 | } 79 | 80 | for _, test := range tests { 81 | command, args := SplitCommand(test.input) 82 | assert.Equal(t, test.command, command) 83 | assert.Equal(t, test.args, args) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | isatty "github.com/mattn/go-isatty" 8 | "github.com/spf13/afero" 9 | ) 10 | 11 | // Context for script operations. A Context includes the working directory and provides 12 | // access the buffers and results of commands run in the Context. 13 | // Using different Contexts it is possible to handle multiple separate environments. 14 | type Context struct { 15 | workingDir string 16 | env map[string]string 17 | fs afero.Fs 18 | stdout io.Writer 19 | stderr io.Writer 20 | stdin io.Reader 21 | isTTY bool 22 | } 23 | 24 | // NewContext returns a pointer to a new Context. 25 | func NewContext() (context *Context) { 26 | // initialize Context 27 | context = &Context{ 28 | env: make(map[string]string, 0), 29 | fs: afero.NewOsFs(), 30 | stdout: os.Stdout, 31 | stderr: os.Stderr, 32 | stdin: os.Stdin, 33 | } 34 | 35 | cwd, err := os.Getwd() 36 | if err == nil { 37 | context.SetWorkingDir(cwd) 38 | } 39 | return 40 | } 41 | 42 | // SetWorkingDir changes the current working dir 43 | func (c *Context) SetWorkingDir(workingDir string) { 44 | c.workingDir = workingDir 45 | } 46 | 47 | // WorkingDir retrieves the current working dir 48 | func (c *Context) WorkingDir() string { 49 | return c.workingDir 50 | } 51 | 52 | // SetWorkingDirTemp changes the current working dir to a temporary directory, returning an error in case something went wrong 53 | func (c *Context) SetWorkingDirTemp() error { 54 | dir, err := c.TempDir() 55 | if err != nil { 56 | return err 57 | } 58 | c.SetWorkingDir(dir) 59 | return nil 60 | } 61 | 62 | // IsUserRoot checks if a user is root priviledged (Linux and Mac only? Windows?) 63 | func (c *Context) IsUserRoot() bool { 64 | return os.Geteuid() == 0 65 | } 66 | 67 | // IsTerminal returns if this program is run inside an interactive terminal 68 | func (c Context) IsTerminal() bool { 69 | return !(os.Getenv("TERM") == "dumb" || (!isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()))) 70 | } 71 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestWorkingDir(t *testing.T) { 11 | workingDir := "/tmp/working-dir" 12 | sc := NewContext() 13 | sc.SetWorkingDir(workingDir) 14 | assert.Equal(t, workingDir, sc.WorkingDir(), fmt.Sprintf("Expected working directory not set (should be %s)", workingDir)) 15 | } 16 | 17 | func TestIsUserRoot(t *testing.T) { 18 | sc := NewContext() 19 | assert.False(t, sc.IsUserRoot()) 20 | } 21 | 22 | func TestSetWorkingDirTemp(t *testing.T) { 23 | assert := assert.New(t) 24 | 25 | sc := NewContext() 26 | err := sc.SetWorkingDirTemp() 27 | assert.Nil(err) 28 | wd1 := sc.WorkingDir() 29 | err = sc.SetWorkingDirTemp() 30 | assert.Nil(err) 31 | wd2 := sc.WorkingDir() 32 | 33 | assert.NotEqual(wd1, wd2) 34 | } 35 | -------------------------------------------------------------------------------- /environment.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // SetEnv sets a certain environment variable for this context 9 | func (c *Context) SetEnv(key, value string) { 10 | c.env[key] = value 11 | } 12 | 13 | func (c *Context) GetCustomEnvValue(key string) string { 14 | return c.env[key] 15 | } 16 | 17 | func (c *Context) GetCustomEnv() []string { 18 | env := make([]string, 0) 19 | for key, value := range c.env { 20 | env = append(env, fmt.Sprintf("%s=%s", key, value)) 21 | } 22 | return env 23 | } 24 | 25 | func (c *Context) GetFullEnv() []string { 26 | env := os.Environ() 27 | env = append(env, c.GetCustomEnv()...) 28 | return env 29 | } 30 | -------------------------------------------------------------------------------- /environment_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestEnvironment(t *testing.T) { 10 | envKey := "MY_ENV_KEY" 11 | envValue := "environment value" 12 | envKeyValue := envKey + "=" + envValue 13 | sc := NewContext() 14 | 15 | assert.False(t, inStringArray(sc.GetFullEnv(), envKeyValue)) 16 | assert.Empty(t, sc.GetCustomEnvValue(envKey)) 17 | assert.Empty(t, sc.GetCustomEnv()) 18 | sc.SetEnv(envKey, envValue) 19 | assert.True(t, inStringArray(sc.GetCustomEnv(), envKeyValue)) 20 | assert.True(t, inStringArray(sc.GetFullEnv(), envKeyValue)) 21 | } 22 | 23 | func inStringArray(haystack []string, needle string) bool { 24 | for _, f := range haystack { 25 | if f == needle { 26 | return true 27 | } 28 | } 29 | return false 30 | } 31 | -------------------------------------------------------------------------------- /examples/command-checking/command-checking.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | script "github.com/jojomi/go-script/v2" 7 | ) 8 | 9 | func main() { 10 | // make sure panics are printed in a human friendly way 11 | defer script.RecoverFunc() 12 | 13 | sc := script.NewContext() 14 | if sc.CommandExists("ls") { 15 | fmt.Println("ls found, listing files and directories should be possible.") 16 | } 17 | if !sc.CommandExists("customjava") { 18 | fmt.Println("No customjava found, continuing still.") 19 | } 20 | sc.MustCommandExist("custompython") 21 | fmt.Println("custompython found, ready to go!") 22 | } 23 | -------------------------------------------------------------------------------- /examples/interview/interview.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/jojomi/go-script/v2/interview" 8 | "github.com/jojomi/go-script/v2/print" 9 | ) 10 | 11 | func main() { 12 | shouldContinue, err := interview.Confirm("Should we continue?", true) 13 | if !shouldContinue { 14 | os.Exit(1) 15 | } 16 | 17 | shouldReallyContinue, err := interview.ConfirmNoDefault("Should we continue?") 18 | if !shouldReallyContinue { 19 | os.Exit(2) 20 | } 21 | 22 | level, err := interview.ChooseOneString("What is your expertise level?", []string{"Novice", "Learner", "Professional"}) 23 | if err != nil { 24 | return 25 | } 26 | switch level { 27 | case "Novice": 28 | print.Errorln(level) 29 | case "Learner": 30 | fmt.Println(level) 31 | case "Professional": 32 | print.Successln(level) 33 | } 34 | 35 | sports, err := interview.ChooseMultiStrings("What sport do you watch on tv?", []string{"Football", "Basketball", "Soccer"}) 36 | if err != nil { 37 | return 38 | } 39 | fmt.Println("Your tv sports:", sports) 40 | } 41 | -------------------------------------------------------------------------------- /examples/printing/printing.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jojomi/go-script/v2/print" 7 | ) 8 | 9 | func main() { 10 | print.Boldln("trying", "A") 11 | 12 | fmt.Print("Yes,", "indeed ") 13 | print.SuccessCheck("\n") 14 | print.SuccessCheck(" oh yes!\n") 15 | print.Successln("It worked") 16 | print.Successf("very %s\n", "well") 17 | 18 | fmt.Println() 19 | print.Boldln("B too?") 20 | 21 | fmt.Print("No,", "no ") 22 | print.ErrorCross("\n") 23 | print.ErrorCross(" oh no!\n") 24 | print.Error("It did") 25 | print.Errorf(" not %s", "work") 26 | print.Errorln() 27 | 28 | fmt.Println() 29 | fmt.Println("I'm done.", "Really.") 30 | } 31 | -------------------------------------------------------------------------------- /examples/process-handling/process_handling.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | script "github.com/jojomi/go-script/v2" 8 | ) 9 | 10 | func main() { 11 | // make sure panics are printed in a human friendly way 12 | defer script.RecoverFunc() 13 | 14 | sc := script.NewContext() 15 | err := sc.SetWorkingDirTemp() 16 | if err != nil { 17 | panic(err) 18 | } 19 | command := script.LocalCommandFrom("ls -lahr /") 20 | pr, err := sc.ExecuteFullySilent(command) 21 | if err != nil { 22 | panic(err) 23 | } 24 | if strings.Contains(pr.Output(), "etc") { 25 | fmt.Println("/etc is in output!") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/progress/progress.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | script "github.com/jojomi/go-script/v2" 8 | ) 9 | 10 | func main() { 11 | sc := script.NewContext() 12 | ai := sc.ActivityIndicator("Loading") 13 | ai.Start() 14 | time.Sleep(3 * time.Second) 15 | ai.Text(" Finished.") 16 | ai.Persist() 17 | fmt.Println("All done, goodbye!") 18 | } 19 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | func (c Context) ReplaceInFile(filename, searchRegexp, replacement string) error { 11 | absoluteFilename := c.AbsPath(filename) 12 | 13 | // read file to string 14 | b, err := ioutil.ReadFile(absoluteFilename) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | // replace 20 | re, err := regexp.Compile(searchRegexp) 21 | if err != nil { 22 | return err 23 | } 24 | b = re.ReplaceAll(b, []byte(replacement)) 25 | 26 | // write back 27 | file, err := os.Open(absoluteFilename) 28 | if err != nil { 29 | return err 30 | } 31 | fileInfo, err := file.Stat() 32 | if err != nil { 33 | return err 34 | } 35 | err = ioutil.WriteFile(absoluteFilename, b, fileInfo.Mode()) 36 | if err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | // FileHasContent func 43 | func (c Context) FileHasContent(filename, search string) (bool, error) { 44 | fileContents, err := ioutil.ReadFile(c.AbsPath(filename)) 45 | if err != nil { 46 | return false, err 47 | } 48 | return strings.Contains(string(fileContents), search), nil 49 | } 50 | 51 | // FileHasContentRegexp func 52 | func (c Context) FileHasContentRegexp(filename, searchRegexp string) (bool, error) { 53 | fileContents, err := ioutil.ReadFile(c.AbsPath(filename)) 54 | if err != nil { 55 | return false, err 56 | } 57 | r, err := regexp.Compile(searchRegexp) 58 | if err != nil { 59 | return false, err 60 | } 61 | results := r.FindStringIndex(string(fileContents)) 62 | return len(results) > 0, nil 63 | } 64 | 65 | /*func (c Context) FileComment(filename, searchRegexp string) { 66 | 67 | } 68 | 69 | func (c Context) FileUncomment(filename, searchRegexp string) { 70 | 71 | }*/ 72 | -------------------------------------------------------------------------------- /file_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestFileHasContent(t *testing.T) { 12 | sc := NewContext() 13 | sc.SetWorkingDir("./test") 14 | 15 | _, err := sc.FileHasContent("non-existing-file.cfg", "github.com") 16 | assert.NotNil(t, err) 17 | 18 | has, err := sc.FileHasContent("file.cfg", "github.com") 19 | assert.Nil(t, err) 20 | assert.True(t, has) 21 | 22 | has, err = sc.FileHasContent("file.cfg", "gitlab.com") 23 | assert.Nil(t, err) 24 | assert.False(t, has) 25 | } 26 | 27 | func TestFileHasContentRegexp(t *testing.T) { 28 | sc := NewContext() 29 | sc.SetWorkingDir("./test") 30 | 31 | _, err := sc.FileHasContentRegexp("non-existing-file.cfg", `git[a-z]*\.com`) 32 | assert.NotNil(t, err) 33 | 34 | _, err = sc.FileHasContentRegexp("file.cfg", `git\p[a-z]*\.com`) // invalid regexp 35 | assert.NotNil(t, err) 36 | 37 | has, err := sc.FileHasContentRegexp("file.cfg", `git[a-z]*\.com`) 38 | assert.Nil(t, err) 39 | assert.True(t, has) 40 | 41 | has, err = sc.FileHasContentRegexp("file.cfg", `gitlab\.com`) 42 | assert.Nil(t, err) 43 | assert.False(t, has) 44 | } 45 | 46 | func TestReplaceInFile(t *testing.T) { 47 | tests := []struct { 48 | content string 49 | replace string 50 | with string 51 | expected string 52 | }{ 53 | // string based (attention, always using RegExp syntax internally!) 54 | { 55 | content: `useTLS: yes`, 56 | replace: `useTLS: yes`, 57 | with: `useTLS: no`, 58 | expected: `useTLS: no`, 59 | }, 60 | // using backreferences 61 | { 62 | content: `useTLS: yes`, 63 | replace: `(useTLS): yes`, 64 | with: `$1: no`, 65 | expected: `useTLS: no`, 66 | }, 67 | // multiple replacements 68 | { 69 | content: `I have many many more ideas.`, 70 | replace: `many\s*`, 71 | with: ``, 72 | expected: `I have more ideas.`, 73 | }, 74 | } 75 | 76 | sc := NewContext() 77 | sc.SetWorkingDir(".") 78 | 79 | filename := "test/service.cfg" 80 | 81 | for _, test := range tests { 82 | makeFile(sc, filename, test.content) 83 | err := sc.ReplaceInFile(filename, test.replace, test.with) 84 | assert.Nil(t, err) 85 | output, _ := ioutil.ReadFile(sc.AbsPath(filename)) 86 | assert.Equal(t, test.expected, string(output)) 87 | os.Remove(filename) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /filesystem.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "os" 5 | "os/user" 6 | "path" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/spf13/afero" 11 | ) 12 | 13 | // FileExists checks if a given filename exists (being a file). 14 | func (c *Context) FileExists(filename string) bool { 15 | filename = c.AbsPath(filename) 16 | fi, err := c.fs.Stat(filename) 17 | return !os.IsNotExist(err) && !fi.IsDir() 18 | } 19 | 20 | // EnsureDirExists ensures a directory with the given name exists. 21 | // This function panics if it is unable to find or create a directory as requested. 22 | // TODO also check if permissions are less than requested and update if possible 23 | func (c *Context) EnsureDirExists(dirname string, perm os.FileMode) error { 24 | fullPath := c.AbsPath(dirname) 25 | if !c.DirExists(fullPath) { 26 | err := c.fs.MkdirAll(fullPath, perm) 27 | if err != nil { 28 | return err 29 | } 30 | } 31 | return nil 32 | } 33 | 34 | // EnsurePathForFile guarantees the path for a given filename to exist. 35 | // If the directory is not yet existing, it will be created using the permission 36 | // mask given. 37 | // TODO also check if permissions are less than requested and update if possible 38 | func (c *Context) EnsurePathForFile(filename string, perm os.FileMode) error { 39 | return c.EnsureDirExists(filepath.Dir(filename), perm) 40 | } 41 | 42 | // DirExists checks if a given filename exists (being a directory). 43 | func (c *Context) DirExists(path string) bool { 44 | path = c.AbsPath(path) 45 | fi, err := c.fs.Stat(path) 46 | return !os.IsNotExist(err) && fi.IsDir() 47 | } 48 | 49 | // TempFile returns a temporary file and an error if one occurred 50 | func (c *Context) TempFile() (*os.File, error) { 51 | file, err := c.tempFileInternal() 52 | if err != nil { 53 | return nil, err 54 | } 55 | return file.(*os.File), nil 56 | } 57 | 58 | func (c *Context) tempFileInternal() (afero.File, error) { 59 | return afero.TempFile(c.fs, "", "") 60 | } 61 | 62 | // TempDir returns a temporary directory and an error if one occurred 63 | func (c *Context) TempDir() (string, error) { 64 | return afero.TempDir(c.fs, "", "") 65 | } 66 | 67 | // AbsPath returns the absolute path of the path given. If the input path 68 | // is absolute, it is returned untouched except for removing trailing path separators. 69 | // Otherwise the absolute path is built relative to the current working directory of the Context. 70 | // This function always returns a path *without* path separator at the end. See AbsPathSep for one that adds it. 71 | func (c *Context) AbsPath(filename string) string { 72 | filename = c.WithoutTrailingPathSep(filename) 73 | absPath, err := filepath.Abs(filename) 74 | if err != nil { 75 | return filename 76 | } 77 | isAbsolute := absPath == filename 78 | if !isAbsolute { 79 | absPath, err := filepath.Abs(path.Join(c.workingDir, filename)) 80 | if err != nil { 81 | return filename 82 | } 83 | return absPath 84 | } 85 | return filename 86 | } 87 | 88 | // AbsPathSep is a variant of AbsPath that always adds a trailing path separator 89 | func (c *Context) AbsPathSep(filename string) string { 90 | return c.WithTrailingPathSep(c.AbsPath(filename)) 91 | } 92 | 93 | // WithoutTrailingPathSep trims trailing os.PathSeparator from a string 94 | func (c *Context) WithoutTrailingPathSep(input string) string { 95 | return strings.TrimRight(input, string(os.PathSeparator)) 96 | } 97 | 98 | // WithTrailingPathSep adds a trailing os.PathSeparator to a string if it is missing 99 | func (c *Context) WithTrailingPathSep(input string) string { 100 | if strings.HasSuffix(input, string(os.PathSeparator)) { 101 | return input 102 | } 103 | return input + string(os.PathSeparator) 104 | } 105 | 106 | // ResolveSymlinks resolve symlinks in a directory. All symlinked files are 107 | // replaced with copies of the files they point to. Only one level symlinks 108 | // are currently supported. 109 | func (c *Context) ResolveSymlinks(dir string) error { 110 | var ( 111 | err error 112 | linkTargetPath string 113 | targetInfo os.FileInfo 114 | ) 115 | // directory does not exist -> nothing to do 116 | dir = c.AbsPath(dir) 117 | if !c.DirExists(dir) { 118 | return nil 119 | } 120 | err = afero.Walk(c.fs, dir, func(path string, info os.FileInfo, err error) error { 121 | // symlink? 122 | if info.Mode()&os.ModeSymlink == os.ModeSymlink { 123 | // resolve 124 | linkTargetPath, err = filepath.EvalSymlinks(path) 125 | if err != nil { 126 | panic(err) 127 | } 128 | targetInfo, err = c.fs.Stat(linkTargetPath) 129 | if err != nil { 130 | panic(err) 131 | } 132 | c.fs.Remove(path) 133 | // directory? 134 | if targetInfo.IsDir() { 135 | c.CopyDir(linkTargetPath, path) 136 | } else { 137 | c.CopyFile(linkTargetPath, path) 138 | } 139 | } 140 | return err 141 | }) 142 | return err 143 | } 144 | 145 | /* Move/Copy Files and Directories */ 146 | 147 | // MoveFile moves a file. Cross-device moving is supported, so files 148 | // can be moved from and to tmpfs mounts. 149 | func (c *Context) MoveFile(from, to string) error { 150 | from = c.AbsPath(from) 151 | to = c.AbsPath(to) 152 | 153 | // work around "invalid cross-device link" for os.Rename 154 | err := CopyFile(c.fs, from, to, true) 155 | if err != nil { 156 | return err 157 | } 158 | err = c.fs.Remove(from) 159 | if err != nil { 160 | return err 161 | } 162 | return nil 163 | } 164 | 165 | // MoveDir moves a directory. Cross-device moving is supported, so directories 166 | // can be moved from and to tmpfs mounts. 167 | func (c *Context) MoveDir(from, to string) error { 168 | from = c.AbsPath(from) 169 | to = c.AbsPath(to) 170 | 171 | // work around "invalid cross-device link" for os.Rename 172 | options := &CopyTreeOptions{ 173 | Ignore: nil, 174 | CopyFunction: Copy, 175 | } 176 | err := CopyTree(c.fs, from, to, options) 177 | if err != nil { 178 | return err 179 | } 180 | err = c.fs.RemoveAll(from) 181 | if err != nil { 182 | return err 183 | } 184 | return nil 185 | } 186 | 187 | // CopyFile copies a file. Cross-device copying is supported, so files 188 | // can be copied from and to tmpfs mounts. 189 | func (c *Context) CopyFile(from, to string) error { 190 | return CopyFile(c.fs, from, to, true) // don't follow symlinks 191 | } 192 | 193 | // CopyDir copies a directory. Cross-device copying is supported, so directories 194 | // can be copied from and to tmpfs mounts. 195 | func (c *Context) CopyDir(src, dst string) error { 196 | options := &CopyTreeOptions{ 197 | Ignore: nil, 198 | CopyFunction: Copy, 199 | } 200 | err := CopyTree(c.fs, src, dst, options) 201 | return err 202 | } 203 | 204 | // MustExpandHome replaces a tilde (~) in a path with the current user's home dir. 205 | func (c *Context) MustExpandHome(path string) string { 206 | if path == "~" { 207 | return c.mustGetHomeDir() 208 | } 209 | if strings.HasPrefix(path, "~/") { 210 | return c.mustGetHomeDir() + path[2:] 211 | } 212 | return path 213 | } 214 | 215 | func (c *Context) mustGetHomeDir() string { 216 | usr, err := user.Current() 217 | if err != nil { 218 | panic(err) 219 | } 220 | return usr.HomeDir 221 | } 222 | -------------------------------------------------------------------------------- /filesystem_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/spf13/afero" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | var myFileFileMode = os.FileMode(int(0700)) 13 | 14 | func TestFileExists(t *testing.T) { 15 | path := "/tmp/my-path/subdir" 16 | filename := "my-file" 17 | fullFilename := filepath.Join(path, filename) 18 | sc := NewContext() 19 | sc.fs = afero.NewMemMapFs() 20 | 21 | // make a file and check with absolute paths 22 | sc.SetWorkingDir("/not-needed") 23 | makeDirectory(sc, path) 24 | assert.False(t, sc.FileExists(fullFilename)) 25 | makeFile(sc, fullFilename, "") 26 | assert.True(t, sc.FileExists(fullFilename)) 27 | 28 | // check with relative paths using working dir 29 | sc.SetWorkingDir(path) 30 | sc.fs = afero.NewMemMapFs() 31 | makeDirectory(sc, path) 32 | assert.False(t, sc.FileExists(filename)) 33 | makeFile(sc, fullFilename, "") 34 | assert.True(t, sc.FileExists(filename)) 35 | } 36 | 37 | func TestDirExists(t *testing.T) { 38 | path := "/tmp/my-path/subdir" 39 | dirname := "my-directory" 40 | fullDirname := filepath.Join(path, dirname) 41 | sc := NewContext() 42 | sc.fs = afero.NewMemMapFs() 43 | 44 | // make a directory and check with absolute paths 45 | sc.SetWorkingDir("/not-needed") 46 | assert.False(t, sc.DirExists(fullDirname)) 47 | makeDirectory(sc, fullDirname) 48 | assert.True(t, sc.DirExists(fullDirname)) 49 | 50 | // check with relative paths using working dir 51 | sc.SetWorkingDir(path) 52 | sc.fs = afero.NewMemMapFs() 53 | assert.False(t, sc.DirExists(dirname)) 54 | makeDirectory(sc, fullDirname) 55 | assert.True(t, sc.DirExists(dirname)) 56 | } 57 | 58 | func TestAbsPath(t *testing.T) { 59 | sc := NewContext() 60 | sc.SetWorkingDir("/wd") 61 | assert.Equal(t, "/wd/file", sc.AbsPath("file")) 62 | assert.Equal(t, "/wd/dir", sc.AbsPath("dir")) 63 | assert.Equal(t, "/wd/dir", sc.AbsPath("dir/")) 64 | assert.Equal(t, "/abc/file", sc.AbsPath("/abc/file")) 65 | assert.Equal(t, "/abc/dir", sc.AbsPath("/abc/dir")) 66 | assert.Equal(t, "/abc/dir", sc.AbsPath("/abc/dir/")) 67 | } 68 | 69 | func TestAbsPathSep(t *testing.T) { 70 | sc := NewContext() 71 | sc.SetWorkingDir("/wd") 72 | assert.Equal(t, "/wd/dir/", sc.AbsPathSep("dir")) 73 | assert.Equal(t, "/wd/dir/", sc.AbsPathSep("dir/")) 74 | assert.Equal(t, "/abc/dir/", sc.AbsPathSep("/abc/dir")) 75 | assert.Equal(t, "/abc/dir/", sc.AbsPathSep("/abc/dir/")) 76 | } 77 | 78 | func TestWithTrailingPathSep(t *testing.T) { 79 | sc := NewContext() 80 | assert.Equal(t, "dir/", sc.WithTrailingPathSep("dir")) 81 | assert.Equal(t, "dir/", sc.WithTrailingPathSep("dir/")) 82 | assert.Equal(t, "/abc/dir/", sc.WithTrailingPathSep("/abc/dir")) 83 | assert.Equal(t, "/abc/dir/", sc.WithTrailingPathSep("/abc/dir/")) 84 | } 85 | 86 | func TestWithoutTrailingPathSep(t *testing.T) { 87 | sc := NewContext() 88 | assert.Equal(t, "dir", sc.WithoutTrailingPathSep("dir")) 89 | assert.Equal(t, "dir", sc.WithoutTrailingPathSep("dir/")) 90 | assert.Equal(t, "/abc/dir", sc.WithoutTrailingPathSep("/abc/dir")) 91 | assert.Equal(t, "/abc/dir", sc.WithoutTrailingPathSep("/abc/dir/")) 92 | } 93 | 94 | func TestResolveSymlinks(t *testing.T) { 95 | sc := NewContext() 96 | fs := afero.NewMemMapFs() 97 | sc.fs = fs 98 | sc.SetWorkingDir("/test") 99 | 100 | err := sc.ResolveSymlinks("dir-non-existing") 101 | assert.Nil(t, err) 102 | 103 | fs.MkdirAll("/test/dir", 0700) 104 | afero.WriteFile(fs, "/test/dir/file.txt", []byte("This is my content"), os.FileMode(0644)) 105 | err = sc.ResolveSymlinks("dir") 106 | assert.Nil(t, err) 107 | 108 | // TODO test actual symlinks (can afero mock os.Symlink to its MemFS?) 109 | } 110 | 111 | func TestEnsureDirExists(t *testing.T) { 112 | path := "/start" 113 | dir := "abcde" 114 | fullPath := filepath.Join(path, dir) 115 | sc := NewContext() 116 | sc.fs = afero.NewMemMapFs() 117 | sc.SetWorkingDir(path) 118 | err := sc.EnsureDirExists(dir, myFileFileMode) 119 | assert.Nil(t, err) 120 | assert.True(t, sc.DirExists(fullPath)) 121 | } 122 | 123 | // TODO func TestEnsureDirExistsFailure(t *testing.T) {} 124 | 125 | func TestEnsurePathForFile(t *testing.T) { 126 | path := "/root/" 127 | file := "xyz.zip" 128 | sc := NewContext() 129 | sc.fs = afero.NewMemMapFs() 130 | sc.SetWorkingDir(path) 131 | err := sc.EnsurePathForFile(file, myFileFileMode) 132 | assert.Nil(t, err) 133 | assert.True(t, sc.DirExists(path)) 134 | } 135 | 136 | func TestMoveFile(t *testing.T) { 137 | fileA := "/dir1/FileA" 138 | fileB := "/dir2/FileB" 139 | sc := NewContext() 140 | sc.fs = afero.NewMemMapFs() 141 | sc.SetWorkingDir("/outside") 142 | sc.EnsurePathForFile(fileA, myFileFileMode) 143 | sc.EnsurePathForFile(fileB, myFileFileMode) 144 | 145 | makeFile(sc, fileA, "insidethefile") 146 | assert.True(t, fileExists(sc, fileA)) 147 | assert.False(t, fileExists(sc, fileB)) 148 | 149 | sc.MoveFile(fileA, fileB) 150 | 151 | assert.False(t, fileExists(sc, fileA)) 152 | assert.True(t, fileExists(sc, fileB)) 153 | } 154 | 155 | func TestCopyFile(t *testing.T) { 156 | fileA := "/dir1/FileA" 157 | fileB := "/dir2/FileB" 158 | sc := NewContext() 159 | sc.fs = afero.NewMemMapFs() 160 | sc.SetWorkingDir("/outside") 161 | sc.EnsurePathForFile(fileA, myFileFileMode) 162 | sc.EnsurePathForFile(fileB, myFileFileMode) 163 | 164 | makeFile(sc, fileA, "insidethefile") 165 | assert.True(t, fileExists(sc, fileA)) 166 | assert.False(t, fileExists(sc, fileB)) 167 | 168 | sc.CopyFile(fileA, fileB) 169 | 170 | assert.True(t, fileExists(sc, fileA)) 171 | assert.True(t, fileExists(sc, fileB)) 172 | } 173 | 174 | func TestMoveDir(t *testing.T) { 175 | dirA := "/dir1" 176 | dirB := "/dir2" 177 | fileA := filepath.Join(dirA, "FileA") 178 | fileB := filepath.Join(dirA, "subdir/FileB") 179 | fileAAfter := filepath.Join(dirB, "FileA") 180 | fileBAfter := filepath.Join(dirB, "subdir/FileB") 181 | 182 | sc := NewContext() 183 | sc.fs = afero.NewMemMapFs() 184 | sc.SetWorkingDir("/outside") 185 | sc.EnsurePathForFile(fileA, myFileFileMode) 186 | sc.EnsurePathForFile(fileB, myFileFileMode) 187 | 188 | makeFile(sc, fileA, "insidethefilea") 189 | makeFile(sc, fileB, "insidethefileb") 190 | assert.True(t, fileExists(sc, fileA)) 191 | assert.True(t, fileExists(sc, fileB)) 192 | 193 | sc.MoveDir(dirA, dirB) 194 | 195 | assert.False(t, fileExists(sc, fileA)) 196 | assert.False(t, fileExists(sc, fileB)) 197 | assert.True(t, fileExists(sc, fileAAfter)) 198 | assert.True(t, fileExists(sc, fileBAfter)) 199 | } 200 | 201 | func TestCopyDir(t *testing.T) { 202 | dirA := "/dir1" 203 | dirB := "/dir2" 204 | fileA := filepath.Join(dirA, "FileA") 205 | fileB := filepath.Join(dirA, "subdir/FileB") 206 | fileAAfter := filepath.Join(dirB, "FileA") 207 | fileBAfter := filepath.Join(dirB, "subdir/FileB") 208 | 209 | sc := NewContext() 210 | sc.fs = afero.NewMemMapFs() 211 | sc.SetWorkingDir("/outside") 212 | sc.EnsurePathForFile(fileA, myFileFileMode) 213 | sc.EnsurePathForFile(fileB, myFileFileMode) 214 | 215 | makeFile(sc, fileA, "insidethefilea") 216 | makeFile(sc, fileB, "insidethefileb") 217 | assert.True(t, fileExists(sc, fileA)) 218 | assert.True(t, fileExists(sc, fileB)) 219 | 220 | sc.CopyDir(dirA, dirB) 221 | 222 | assert.True(t, fileExists(sc, fileA)) 223 | assert.True(t, fileExists(sc, fileB)) 224 | assert.True(t, fileExists(sc, fileAAfter)) 225 | assert.True(t, fileExists(sc, fileBAfter)) 226 | } 227 | 228 | func TestTempFile(t *testing.T) { 229 | content := "abcfilecontent" 230 | sc := NewContext() 231 | sc.fs = afero.NewMemMapFs() 232 | file, err := sc.tempFileInternal() 233 | assert.Nil(t, err) 234 | file.WriteString(content) 235 | file.Close() 236 | 237 | // verify 238 | r, err := afero.ReadFile(sc.fs, file.Name()) 239 | assert.Nil(t, err) 240 | assert.Equal(t, content, string(r)) 241 | } 242 | 243 | func TestTempDir(t *testing.T) { 244 | content := []byte("abcfilecontent") 245 | sc := NewContext() 246 | sc.fs = afero.NewMemMapFs() 247 | dir, err := sc.TempDir() 248 | assert.Nil(t, err) 249 | filename := filepath.Join(dir, "myfilename") 250 | afero.WriteFile(sc.fs, filename, content, myFileFileMode) 251 | 252 | // verify 253 | r, err := afero.ReadFile(sc.fs, filename) 254 | assert.Nil(t, err) 255 | assert.Equal(t, content, r) 256 | } 257 | 258 | func TestMustExpandHome(t *testing.T) { 259 | assert := assert.New(t) 260 | 261 | sc := NewContext() 262 | 263 | // no expansion required 264 | p := "/home/jojomi/~/weird/path" 265 | assert.Equal(p, sc.MustExpandHome(p)) 266 | 267 | // expansion required 268 | p = "~/subdir/deep" 269 | assert.NotEqual(p, sc.MustExpandHome(p)) 270 | assert.Less(len(p), len(sc.MustExpandHome(p))) 271 | } 272 | 273 | func makeFile(c *Context, filename, content string) { 274 | afero.WriteFile(c.fs, filename, []byte(content), myFileFileMode) 275 | } 276 | 277 | func makeDirectory(c *Context, path string) { 278 | c.fs.MkdirAll(path, myFileFileMode) 279 | } 280 | 281 | func fileExists(c *Context, filename string) bool { 282 | r, err := afero.Exists(c.fs, filename) 283 | return r && err == nil 284 | } 285 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jojomi/go-script/v2 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/AlecAivazis/survey/v2 v2.3.7 9 | github.com/fatih/color v1.18.0 10 | github.com/gernest/wow v0.1.0 11 | github.com/jojomi/go-script v2.1.0+incompatible 12 | github.com/mattn/go-isatty v0.0.20 13 | github.com/spf13/afero v1.14.0 14 | github.com/stretchr/testify v1.10.0 15 | gopkg.in/cheggaaa/pb.v1 v1.0.28 16 | ) 17 | 18 | require ( 19 | github.com/davecgh/go-spew v1.1.1 // indirect 20 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 21 | github.com/mattn/go-colorable v0.1.14 // indirect 22 | github.com/mattn/go-runewidth v0.0.16 // indirect 23 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect 24 | github.com/pmezard/go-difflib v1.0.0 // indirect 25 | github.com/rivo/uniseg v0.4.7 // indirect 26 | golang.org/x/crypto v0.38.0 // indirect 27 | golang.org/x/sys v0.33.0 // indirect 28 | golang.org/x/term v0.32.0 // indirect 29 | golang.org/x/text v0.25.0 // indirect 30 | gopkg.in/yaml.v3 v3.0.1 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= 2 | github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= 3 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= 4 | github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 5 | github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= 6 | github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 11 | github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 12 | github.com/gernest/wow v0.1.0 h1:g9xdwCwP0+xgVYlA2sopI0gZHqXe7HjI/7/LykG4fks= 13 | github.com/gernest/wow v0.1.0/go.mod h1:dEPabJRi5BneI1Nev1VWo0ZlcTWibHWp43qxKms4elY= 14 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= 15 | github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= 16 | github.com/jojomi/go-script v2.1.0+incompatible h1:W4KH0DmsdRzOb5Rr7CXQw0gr4uY3AJ9ELHH7sCAQSgg= 17 | github.com/jojomi/go-script v2.1.0+incompatible/go.mod h1:jMqZTFRBqNHHiMtu9FLwO2E6Xsk3IyvWlYHhqrkUAAk= 18 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 19 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 20 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 21 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 22 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 23 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 24 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 25 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 26 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 27 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 28 | github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 29 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= 30 | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 31 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 32 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 33 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 34 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 35 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 36 | github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= 37 | github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= 38 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 39 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 40 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 41 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 43 | golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 44 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 45 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 46 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 47 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 48 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 49 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 50 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 51 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 52 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 53 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 54 | golang.org/x/sys v0.0.0-20190116161447-11f53e031339/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 55 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 56 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 57 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 58 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 61 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 63 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 64 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 65 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 66 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 67 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 68 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 69 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 70 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 71 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 72 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 73 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 74 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 75 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 76 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 77 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 78 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 79 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 80 | gopkg.in/cheggaaa/pb.v1 v1.0.28 h1:n1tBJnnK2r7g9OW2btFH91V92STTUevLXYFb8gy9EMk= 81 | gopkg.in/cheggaaa/pb.v1 v1.0.28/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= 82 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 83 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 84 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 85 | -------------------------------------------------------------------------------- /helpers_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import "bytes" 4 | 5 | func setOutputBuffers(sc *Context) (out, err *bytes.Buffer) { 6 | stdoutBuffer := bytes.NewBuffer(make([]byte, 0, 100)) 7 | sc.stdout = stdoutBuffer 8 | stderrBuffer := bytes.NewBuffer(make([]byte, 0, 100)) 9 | sc.stderr = stderrBuffer 10 | return stdoutBuffer, stderrBuffer 11 | } 12 | -------------------------------------------------------------------------------- /interview/interview.go: -------------------------------------------------------------------------------- 1 | package interview 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/AlecAivazis/survey/v2" 8 | ) 9 | 10 | // Confirm allows querying for confirmation with a default value that is used when no answer is typed. 11 | func Confirm(question string, defaultValue bool) (result bool, err error) { 12 | prompt := &survey.Confirm{ 13 | Message: question, 14 | Default: defaultValue, 15 | } 16 | err = survey.AskOne(prompt, &result, nil) 17 | return 18 | } 19 | 20 | // ConfirmNoDefault allows querying for confirmation without a default value, so the user needs to answer the question explicitly. 21 | func ConfirmNoDefault(question string) (result bool, err error) { 22 | q := &survey.Input{ 23 | Message: question + " (y/n)", 24 | } 25 | v := func(val interface{}) error { 26 | str := strings.ToLower(val.(string)) 27 | if str != "y" && str != "yes" && str != "n" && str != "no" { 28 | return fmt.Errorf("Invalid input. Please type \"y\" for yes or \"n\" for no.") 29 | } 30 | return nil 31 | } 32 | var res string 33 | err = survey.AskOne(q, &res, survey.WithValidator(v)) 34 | 35 | if strings.ToLower(res) == "y" || strings.ToLower(res) == "yes" { 36 | result = true 37 | return 38 | } 39 | if strings.ToLower(res) == "n" || strings.ToLower(res) == "no" { 40 | result = false 41 | return 42 | } 43 | 44 | return 45 | } 46 | 47 | // ChooseOneString queries the user to choose one string from a list of strings 48 | func ChooseOneString(question string, options []string) (result string, err error) { 49 | prompt := &survey.Select{ 50 | Message: question, 51 | Options: options, 52 | } 53 | err = survey.AskOne(prompt, &result, nil) 54 | return 55 | } 56 | 57 | // ChooseMultiStrings queries the user to choose from a list of strings allowing multiple selection 58 | func ChooseMultiStrings(question string, options []string) (results []string, err error) { 59 | prompt := &survey.MultiSelect{ 60 | Message: question, 61 | Options: options, 62 | } 63 | err = survey.AskOne(prompt, &results, nil) 64 | return 65 | } 66 | -------------------------------------------------------------------------------- /precommit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | echo "gofmt..." 4 | gofmt -w *.go 5 | 6 | echo "go lint..." 7 | go get github.com/golang/lint/golint 8 | golint ./... 9 | 10 | echo "go vet..." 11 | go get golang.org/x/tools/cmd/vet 12 | go vet ./... 13 | 14 | echo "dependencies..." 15 | go list -f '{{join .Deps "\n"}}' | xargs go list -f '{{if not .Standard}}{{.ImportPath}}{{end}}' 16 | 17 | echo "go test..." 18 | go test -coverprofile=/tmp/cover.out ./... && go tool cover -html=/tmp/cover.out -o /tmp/coverage.html && xdg-open /tmp/coverage.html 19 | -------------------------------------------------------------------------------- /print/print.go: -------------------------------------------------------------------------------- 1 | package print 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/fatih/color" 7 | ) 8 | 9 | var ( 10 | // SucessChar is a single character signalling a successful operation 11 | SuccessChar = "✓" 12 | // ErrorChar is a single character signalling an error 13 | ErrorChar = "✗" 14 | ) 15 | 16 | var colorTitle = color.New(color.Bold, color.FgGreen) 17 | var printTitle = colorTitle.FprintFunc() 18 | var colorSubtitle = color.New(color.Bold, color.FgBlue) 19 | var printSubtitle = colorSubtitle.FprintFunc() 20 | 21 | var colorBold = color.New(color.Bold) 22 | var printBold = colorBold.FprintFunc() 23 | var printfBold = colorBold.FprintfFunc() 24 | var printlnBold = colorBold.FprintlnFunc() 25 | 26 | var colorSuccess = color.New(color.Bold, color.FgGreen) 27 | var printSuccess = colorSuccess.FprintFunc() 28 | var printfSuccess = colorSuccess.FprintfFunc() 29 | var printlnSuccess = colorSuccess.FprintlnFunc() 30 | 31 | var colorError = color.New(color.Bold, color.FgRed) 32 | var printError = colorError.FprintFunc() 33 | var printfError = colorError.FprintfFunc() 34 | var printlnError = colorError.FprintlnFunc() 35 | 36 | // Title func 37 | func Title(input ...interface{}) { 38 | printTitle(os.Stdout, "» ") 39 | printTitle(os.Stdout, input...) 40 | printTitle(os.Stdout, "\n") 41 | } 42 | 43 | // Subtitle func 44 | func Subtitle(input ...interface{}) { 45 | printSubtitle(os.Stdout, "› ") 46 | printSubtitle(os.Stdout, input...) 47 | printSubtitle(os.Stdout, "\n") 48 | } 49 | 50 | // Bold func 51 | func Bold(input ...interface{}) { 52 | printBold(os.Stdout, input...) 53 | } 54 | 55 | // Boldf func 56 | func Boldf(format string, input ...interface{}) { 57 | printfBold(os.Stdout, format, input...) 58 | } 59 | 60 | // Boldln func 61 | func Boldln(input ...interface{}) { 62 | printlnBold(os.Stdout, input...) 63 | } 64 | 65 | // Success func 66 | func Success(input ...interface{}) { 67 | printSuccess(os.Stdout, input...) 68 | } 69 | 70 | // Successf func 71 | func Successf(format string, input ...interface{}) { 72 | printfSuccess(os.Stdout, format, input...) 73 | } 74 | 75 | // Successln func 76 | func Successln(input ...interface{}) { 77 | printlnSuccess(os.Stdout, input...) 78 | } 79 | 80 | // SuccessCheck func 81 | func SuccessCheck(inputSuffix ...interface{}) { 82 | input := make([]interface{}, len(inputSuffix)+1) 83 | input[0] = SuccessChar 84 | for index, i := range inputSuffix { 85 | input[index+1] = i 86 | } 87 | printSuccess(os.Stdout, input...) 88 | } 89 | 90 | // Error func 91 | func Error(input ...interface{}) { 92 | printError(os.Stderr, input...) 93 | } 94 | 95 | // Errorf func 96 | func Errorf(format string, input ...interface{}) { 97 | printfError(os.Stderr, format, input...) 98 | } 99 | 100 | // Errorln func 101 | func Errorln(input ...interface{}) { 102 | printlnError(os.Stderr, input...) 103 | } 104 | 105 | // ErrorCross func 106 | func ErrorCross(inputSuffix ...interface{}) { 107 | input := make([]interface{}, len(inputSuffix)+1) 108 | input[0] = ErrorChar 109 | for index, i := range inputSuffix { 110 | input[index+1] = i 111 | } 112 | printError(os.Stderr, input...) 113 | } 114 | -------------------------------------------------------------------------------- /process.go: -------------------------------------------------------------------------------- 1 | // Package script is a library facilitating the creation of programs that resemble 2 | // bash scripts. 3 | package script 4 | 5 | import ( 6 | "bytes" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "os" 11 | "os/exec" 12 | "strconv" 13 | "strings" 14 | "syscall" 15 | ) 16 | 17 | // ProcessResult contains the results of a process execution be it successful or not. 18 | type ProcessResult struct { 19 | Cmd *exec.Cmd 20 | Process *os.Process 21 | ProcessState *os.ProcessState 22 | ProcessError error 23 | stdoutBuffer *bytes.Buffer 24 | stderrBuffer *bytes.Buffer 25 | } 26 | 27 | // CommandConfig defines details of command execution. 28 | type CommandConfig struct { 29 | RawStdout bool 30 | RawStderr bool 31 | OutputStdout bool 32 | OutputStderr bool 33 | ConnectStdin bool 34 | Detach bool 35 | } 36 | 37 | // NewProcessResult creates a new empty ProcessResult 38 | func NewProcessResult() *ProcessResult { 39 | p := &ProcessResult{} 40 | p.stdoutBuffer = bytes.NewBuffer(make([]byte, 0, 100)) 41 | p.stderrBuffer = bytes.NewBuffer(make([]byte, 0, 100)) 42 | return p 43 | } 44 | 45 | // Output returns a string representation of the output of the process denoted 46 | // by this struct. 47 | func (pr *ProcessResult) Output() string { 48 | return pr.stdoutBuffer.String() 49 | } 50 | 51 | // TrimmedOutput returns a string representation of the output of the process denoted 52 | // by this struct with surrounding whitespace removed. 53 | func (pr *ProcessResult) TrimmedOutput() string { 54 | return strings.TrimSpace(pr.Output()) 55 | } 56 | 57 | // Error returns a string representation of the stderr output of the process denoted 58 | // by this struct. 59 | func (pr *ProcessResult) Error() string { 60 | return pr.stderrBuffer.String() 61 | } 62 | 63 | // Successful returns true iff the process denoted by this struct was run 64 | // successfully. Success is defined as the exit code being set to 0. 65 | func (pr *ProcessResult) Successful() bool { 66 | code, err := pr.ExitCode() 67 | if err != nil { 68 | return false 69 | } 70 | return code == 0 71 | } 72 | 73 | // StateString returns a string representation of the process denoted by 74 | // this struct 75 | func (pr *ProcessResult) StateString() string { 76 | state := pr.ProcessState 77 | exitCode, err := pr.ExitCode() 78 | exitCodeString := "?" 79 | if err == nil { 80 | exitCodeString = strconv.Itoa(exitCode) 81 | } 82 | return fmt.Sprintf("PID: %d, Exited: %t, Exit Code: %s, Success: %t, User Time: %s", state.Pid(), state.Exited(), exitCodeString, state.Success(), state.UserTime()) 83 | } 84 | 85 | // ExitCode returns the exit code of the command denoted by this struct 86 | func (pr *ProcessResult) ExitCode() (int, error) { 87 | var ( 88 | waitStatus syscall.WaitStatus 89 | exitError *exec.ExitError 90 | ) 91 | ok := false 92 | if pr.ProcessError != nil { 93 | exitError, ok = pr.ProcessError.(*exec.ExitError) 94 | } 95 | if ok { 96 | waitStatus = exitError.Sys().(syscall.WaitStatus) 97 | } else { 98 | if pr.ProcessState == nil { 99 | return -1, errors.New("no exit code available") 100 | } 101 | waitStatus = pr.ProcessState.Sys().(syscall.WaitStatus) 102 | } 103 | return waitStatus.ExitStatus(), nil 104 | } 105 | 106 | // CommandPath finds the full path of a binary given its name. 107 | // also see https://golang.org/pkg/os/exec/#LookPath 108 | func (c *Context) CommandPath(name string) (path string) { 109 | path, err := exec.LookPath(name) 110 | if err != nil { 111 | return "" 112 | } 113 | return 114 | } 115 | 116 | // CommandExists checks if a given binary exists in PATH. 117 | func (c *Context) CommandExists(name string) bool { 118 | return c.CommandPath(name) != "" 119 | } 120 | 121 | // MustCommandExist ensures a given binary exists in PATH, otherwise panics. 122 | func (c *Context) MustCommandExist(name string) { 123 | if !c.CommandExists(name) { 124 | panic(fmt.Errorf("Command %s is not available. Please make sure it is installed and accessible.", name)) 125 | } 126 | } 127 | 128 | // ExecuteRaw executes a system command without touching stdout and stderr. 129 | func (c *Context) ExecuteRaw(command Command) (pr *ProcessResult, err error) { 130 | pr, err = c.Execute(CommandConfig{ 131 | RawStdout: true, 132 | RawStderr: true, 133 | ConnectStdin: true, 134 | }, command) 135 | return 136 | } 137 | 138 | // ExecuteDebug executes a system command, stdout and stderr are piped 139 | func (c *Context) ExecuteDebug(command Command) (pr *ProcessResult, err error) { 140 | pr, err = c.Execute(CommandConfig{ 141 | OutputStdout: true, 142 | OutputStderr: true, 143 | ConnectStdin: true, 144 | }, command) 145 | return 146 | } 147 | 148 | // ExecuteSilent executes a system command without outputting stdout (it is 149 | // still captured and can be retrieved using the returned ProcessResult) 150 | func (c *Context) ExecuteSilent(command Command) (pr *ProcessResult, err error) { 151 | pr, err = c.Execute(CommandConfig{ 152 | OutputStdout: false, 153 | OutputStderr: true, 154 | ConnectStdin: true, 155 | }, command) 156 | return 157 | } 158 | 159 | // ExecuteFullySilent executes a system command without outputting stdout or 160 | // stderr (both are still captured and can be retrieved using the returned ProcessResult) 161 | func (c *Context) ExecuteFullySilent(command Command) (pr *ProcessResult, err error) { 162 | pr, err = c.Execute(CommandConfig{ 163 | OutputStdout: false, 164 | OutputStderr: false, 165 | ConnectStdin: true, 166 | }, command) 167 | return 168 | } 169 | 170 | // MustExecuteDebug ensures a system command to be executed, otherwise panics 171 | func (c *Context) MustExecuteDebug(command Command) (pr *ProcessResult) { 172 | pr, err := c.Execute(CommandConfig{ 173 | OutputStdout: true, 174 | OutputStderr: true, 175 | ConnectStdin: true, 176 | }, command) 177 | if err != nil { 178 | panic(err) 179 | } 180 | return 181 | } 182 | 183 | // MustExecuteSilent ensures a system command to be executed without outputting 184 | // stdout, otherwise panics 185 | func (c *Context) MustExecuteSilent(command Command) (pr *ProcessResult) { 186 | pr, err := c.Execute(CommandConfig{ 187 | OutputStdout: false, 188 | OutputStderr: true, 189 | ConnectStdin: true, 190 | }, command) 191 | if err != nil { 192 | panic(err) 193 | } 194 | return 195 | } 196 | 197 | // MustExecuteFullySilent ensures a system command to be executed without 198 | // outputting stdout and stderr, otherwise panics 199 | func (c *Context) MustExecuteFullySilent(command Command) (pr *ProcessResult) { 200 | pr, err := c.Execute(CommandConfig{ 201 | OutputStdout: false, 202 | OutputStderr: false, 203 | ConnectStdin: true, 204 | }, command) 205 | if err != nil { 206 | panic(err) 207 | } 208 | return 209 | } 210 | 211 | // Execute executes a system command according to given CommandConfig. 212 | func (c *Context) Execute(cc CommandConfig, command Command) (pr *ProcessResult, err error) { 213 | cmd, pr := c.prepareCommand(cc, command) 214 | 215 | if cc.Detach { 216 | cmd.SysProcAttr = &syscall.SysProcAttr{ 217 | Setpgid: true, 218 | } 219 | } 220 | 221 | err = cmd.Start() 222 | if err != nil { 223 | return 224 | } 225 | pr.Process = cmd.Process 226 | 227 | if !cc.Detach { 228 | c.WaitCmd(pr) 229 | } 230 | 231 | return 232 | } 233 | 234 | // ExecuteDetachedDebug executes a system command, stdout and stderr are piped. 235 | // The command is executed in the background (detached). 236 | func (c *Context) ExecuteDetachedDebug(command Command) (pr *ProcessResult, err error) { 237 | pr, err = c.Execute(CommandConfig{ 238 | OutputStdout: true, 239 | OutputStderr: true, 240 | Detach: true, 241 | }, command) 242 | return 243 | } 244 | 245 | // ExecuteDetachedSilent executes a system command without outputting stdout (it is 246 | // still captured and can be retrieved using the returned ProcessResult). 247 | // The command is executed in the background (detached). 248 | func (c *Context) ExecuteDetachedSilent(command Command) (pr *ProcessResult, err error) { 249 | pr, err = c.Execute(CommandConfig{ 250 | OutputStdout: false, 251 | OutputStderr: true, 252 | Detach: true, 253 | }, command) 254 | return 255 | } 256 | 257 | // ExecuteDetachedFullySilent executes a system command without outputting stdout or 258 | // stderr (both are still captured and can be retrieved using the returned ProcessResult). 259 | // The command is executed in the background (detached). 260 | func (c *Context) ExecuteDetachedFullySilent(command Command) (pr *ProcessResult, err error) { 261 | pr, err = c.Execute(CommandConfig{ 262 | OutputStdout: false, 263 | OutputStderr: false, 264 | Detach: true, 265 | }, command) 266 | return 267 | } 268 | 269 | func (c Context) prepareCommand(cc CommandConfig, command Command) (*exec.Cmd, *ProcessResult) { 270 | pr := NewProcessResult() 271 | 272 | cmd := exec.Command(command.Binary(), command.Args()...) 273 | pr.Cmd = cmd 274 | 275 | cmd.Dir = c.workingDir 276 | cmd.Env = c.GetFullEnv() 277 | 278 | if cc.RawStdout { 279 | cmd.Stdout = os.Stdout 280 | } else { 281 | if !cc.OutputStdout { 282 | cmd.Stdout = pr.stdoutBuffer 283 | } else { 284 | cmd.Stdout = io.MultiWriter(c.stdout, pr.stdoutBuffer) 285 | } 286 | } 287 | if cc.RawStderr { 288 | cmd.Stderr = os.Stderr 289 | } else { 290 | if !cc.OutputStderr { 291 | cmd.Stderr = pr.stderrBuffer 292 | } else { 293 | cmd.Stderr = io.MultiWriter(c.stderr, pr.stderrBuffer) 294 | } 295 | } 296 | 297 | if cc.ConnectStdin { 298 | cmd.Stdin = c.stdin 299 | } 300 | return cmd, pr 301 | } 302 | 303 | // WaitCmd waits for a command to be finished (useful on detached processes). 304 | func (c Context) WaitCmd(pr *ProcessResult) { 305 | err := pr.Cmd.Wait() 306 | pr.ProcessState = pr.Cmd.ProcessState 307 | pr.ProcessError = err 308 | } 309 | -------------------------------------------------------------------------------- /process_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | var ( 11 | commandInPath = "ls" 12 | commandNotInPath = "binary-not-in-path" 13 | 14 | nonExistingBinary = "./not-existing" 15 | 16 | basicOutputStdout = "hello this is me\nwhatever\n" 17 | basicOutputStderr = "\"abc\"\n" 18 | ) 19 | 20 | /* OUTPUT HANDLING */ 21 | 22 | func TestProcessOutputErrorCatching(t *testing.T) { 23 | sc := processContext() 24 | setOutputBuffers(sc) 25 | 26 | pr, err := sc.ExecuteFullySilent(LocalCommandFrom("./bin basic-output")) 27 | assert.Nil(t, err) 28 | assert.Equal(t, basicOutputStdout, pr.Output()) 29 | assert.Equal(t, basicOutputStderr, pr.Error()) 30 | 31 | pr, err = sc.ExecuteDebug(LocalCommandFrom("./bin basic-output")) 32 | assert.Nil(t, err) 33 | assert.Equal(t, basicOutputStdout, pr.Output()) 34 | assert.Equal(t, basicOutputStderr, pr.Error()) 35 | 36 | pr, err = sc.ExecuteSilent(LocalCommandFrom("./bin basic-output")) 37 | assert.Nil(t, err) 38 | assert.Equal(t, basicOutputStdout, pr.Output()) 39 | assert.Equal(t, basicOutputStderr, pr.Error()) 40 | } 41 | 42 | func TestProcessStdoutStderr(t *testing.T) { 43 | sc := processContext() 44 | 45 | outBuffer, errBuffer := setOutputBuffers(sc) 46 | _, err := sc.ExecuteFullySilent(LocalCommandFrom("./bin basic-output")) 47 | assert.Nil(t, err) 48 | assert.Equal(t, "", outBuffer.String()) 49 | assert.Equal(t, "", errBuffer.String()) 50 | 51 | outBuffer, errBuffer = setOutputBuffers(sc) 52 | _, err = sc.ExecuteSilent(LocalCommandFrom("./bin basic-output")) 53 | assert.Nil(t, err) 54 | assert.Equal(t, "", outBuffer.String()) 55 | assert.Equal(t, basicOutputStderr, errBuffer.String()) 56 | 57 | outBuffer, errBuffer = setOutputBuffers(sc) 58 | _, err = sc.ExecuteDebug(LocalCommandFrom("./bin basic-output")) 59 | assert.Nil(t, err) 60 | assert.Equal(t, basicOutputStdout, outBuffer.String()) 61 | assert.Equal(t, basicOutputStderr, errBuffer.String()) 62 | } 63 | 64 | func TestProcessStdin(t *testing.T) { 65 | input := "my input" 66 | sc := processContext() 67 | 68 | sc.stdin = strings.NewReader(input) 69 | pr, err := sc.ExecuteFullySilent(LocalCommandFrom("./bin echo")) 70 | assert.Nil(t, err) 71 | assert.Equal(t, input+"\n", pr.Output()) 72 | assert.Equal(t, input+"\n", pr.Error()) 73 | } 74 | 75 | /* COMMAND EXECUTION */ 76 | 77 | func TestProcessRunFailure(t *testing.T) { 78 | sc := processContext() 79 | _, err := sc.ExecuteFullySilent(LocalCommandFrom(nonExistingBinary)) 80 | assert.NotNil(t, err) 81 | } 82 | 83 | func TestProcessStateString(t *testing.T) { 84 | sc := processContext() 85 | pr, err := sc.ExecuteFullySilent(LocalCommandFrom("./bin basic-output")) 86 | assert.Nil(t, err) 87 | assert.Regexp(t, `^PID: \d+, Exited: true, Exit Code: 0, Success: true, User Time: \d+(\.\d+)?[mµ]?s$`, pr.StateString()) 88 | } 89 | 90 | func TestProcessMustExecuteDebug(t *testing.T) { 91 | sc := processContext() 92 | setOutputBuffers(sc) 93 | pr := sc.MustExecuteDebug(LocalCommandFrom("./bin basic-output")) 94 | assert.NotNil(t, pr) 95 | 96 | assert.Panics(t, func() { 97 | sc.MustExecuteDebug(LocalCommandFrom(nonExistingBinary)) 98 | }) 99 | } 100 | 101 | func TestProcessMustExecuteSilent(t *testing.T) { 102 | sc := processContext() 103 | setOutputBuffers(sc) 104 | pr := sc.MustExecuteSilent(LocalCommandFrom("./bin basic-output")) 105 | assert.NotNil(t, pr) 106 | 107 | assert.Panics(t, func() { 108 | sc.MustExecuteSilent(LocalCommandFrom(nonExistingBinary)) 109 | }) 110 | } 111 | 112 | func TestProcessMustExecuteFullySilent(t *testing.T) { 113 | sc := processContext() 114 | setOutputBuffers(sc) 115 | pr := sc.MustExecuteFullySilent(LocalCommandFrom("./bin basic-output")) 116 | assert.NotNil(t, pr) 117 | 118 | assert.Panics(t, func() { 119 | sc.MustExecuteFullySilent(LocalCommandFrom(nonExistingBinary)) 120 | }) 121 | } 122 | 123 | /* DETACHED COMMANDS */ 124 | 125 | func TestProcessExecuteDetachedDebug(t *testing.T) { 126 | sc := processContext() 127 | stdout, stderr := setOutputBuffers(sc) 128 | pr, err := sc.ExecuteDetachedDebug(LocalCommandFrom("./bin sleep")) 129 | assert.Nil(t, err) 130 | assert.NotNil(t, pr) 131 | assert.IsType(t, int(0), pr.Process.Pid, "Not seen a PID on a detached process. Did it even start?") // int 132 | 133 | _, exitErr := pr.ExitCode() 134 | assert.NotNil(t, exitErr) 135 | assert.False(t, pr.Successful()) 136 | 137 | sc.ExecuteDebug(LocalCommandFrom("./bin basic-output")) 138 | 139 | sc.WaitCmd(pr) 140 | assert.True(t, pr.Successful()) 141 | assert.Equal(t, "before\n"+basicOutputStdout+"after\n", stdout.String()) 142 | assert.Equal(t, "error-before\n"+basicOutputStderr+"error-after\n", stderr.String()) 143 | } 144 | 145 | func TestProcessExecuteDetachedSilent(t *testing.T) { 146 | sc := processContext() 147 | stdout, stderr := setOutputBuffers(sc) 148 | pr, err := sc.ExecuteDetachedSilent(LocalCommandFrom("./bin sleep")) 149 | assert.Nil(t, err) 150 | assert.NotNil(t, pr) 151 | 152 | sc.ExecuteDebug(LocalCommandFrom("./bin basic-output")) 153 | 154 | sc.WaitCmd(pr) 155 | assert.Equal(t, basicOutputStdout, stdout.String()) 156 | assert.Equal(t, "error-before\n"+basicOutputStderr+"error-after\n", stderr.String()) 157 | } 158 | 159 | func TestProcessExecuteDetachedFullySilent(t *testing.T) { 160 | sc := processContext() 161 | stdout, stderr := setOutputBuffers(sc) 162 | pr, err := sc.ExecuteDetachedFullySilent(LocalCommandFrom("./bin sleep")) 163 | assert.Nil(t, err) 164 | assert.NotNil(t, pr) 165 | 166 | sc.ExecuteDebug(LocalCommandFrom("./bin basic-output")) 167 | 168 | sc.WaitCmd(pr) 169 | assert.Equal(t, basicOutputStdout, stdout.String()) 170 | assert.Equal(t, basicOutputStderr, stderr.String()) 171 | } 172 | 173 | /* COMMAND HANDLING */ 174 | 175 | func TestProcessCommandExists(t *testing.T) { 176 | sc := NewContext() 177 | sc.CommandExists(commandInPath) 178 | } 179 | 180 | func TestProcessCommandPath(t *testing.T) { 181 | sc := NewContext() 182 | path := sc.CommandPath(commandInPath) 183 | assert.NotEqual(t, "", path) 184 | } 185 | 186 | func TestProcessCommandPathFailure(t *testing.T) { 187 | sc := NewContext() 188 | path := sc.CommandPath(commandNotInPath) 189 | assert.Equal(t, "", path) 190 | } 191 | 192 | func TestProcessCommandPathFailurePanic(t *testing.T) { 193 | sc := NewContext() 194 | assert.Panics(t, func() { 195 | sc.MustCommandExist(commandNotInPath) 196 | }) 197 | } 198 | 199 | /* EXIT CODE HANDLING */ 200 | 201 | func TestProcessSuccessful(t *testing.T) { 202 | sc := processContext() 203 | pr, err := sc.ExecuteFullySilent((LocalCommandFrom(commandInPath))) 204 | assert.Nil(t, err) 205 | assert.Equal(t, true, pr.Successful()) 206 | } 207 | 208 | func TestProcessExitCode(t *testing.T) { 209 | sc := processContext() 210 | pr, err := sc.ExecuteFullySilent(LocalCommandFrom("./bin exit-code-error")) 211 | assert.Nil(t, err) 212 | exitCode, exitErr := pr.ExitCode() 213 | assert.Nil(t, exitErr) 214 | assert.Equal(t, 28, exitCode) 215 | } 216 | 217 | /* HELPER FUNCTIONS */ 218 | 219 | func processContext() *Context { 220 | sc := NewContext() 221 | sc.SetWorkingDir("./test/bin") 222 | return sc 223 | } 224 | -------------------------------------------------------------------------------- /progress.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "io" 5 | "os" 6 | 7 | "github.com/gernest/wow" 8 | "github.com/gernest/wow/spin" 9 | "gopkg.in/cheggaaa/pb.v1" 10 | ) 11 | 12 | // ActivityIndicatorCustom returns an activity indicator as specified. 13 | // Use Start() to start and Stop() to stop it. 14 | // See: https://godoc.org/github.com/gernest/wow 15 | func (c Context) ActivityIndicatorCustom(text string, typ spin.Name) *wow.Wow { 16 | w := wow.New(c.stdout, spin.Get(typ), " "+text) 17 | return w 18 | } 19 | 20 | // ActivityIndicator returns an activity indicator with specified text and default animation. 21 | func (c Context) ActivityIndicator(text string) *wow.Wow { 22 | return c.ActivityIndicatorCustom(text, spin.Dots) 23 | } 24 | 25 | // ProgressReader returns a reader that is able to visualize read progress. 26 | func (c Context) ProgressReader(reader io.Reader, size int) (io.Reader, *pb.ProgressBar) { 27 | bar := pb.New(size).SetUnits(pb.U_BYTES) 28 | 29 | // create proxy reader 30 | return bar.NewProxyReader(reader), bar 31 | } 32 | 33 | // ProgressFileReader returns a reader that is able to visualize read progress for a file. 34 | func (c Context) ProgressFileReader(f *os.File) (io.Reader, *pb.ProgressBar, error) { 35 | size, err := f.Stat() 36 | if err != nil { 37 | return nil, nil, err 38 | } 39 | r, bar := c.ProgressReader(f, int(size.Size())) 40 | return r, bar, nil 41 | } 42 | -------------------------------------------------------------------------------- /progress_test.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestActivityIndicator(t *testing.T) { 11 | sc := NewContext() 12 | stdout, stderr := setOutputBuffers(sc) 13 | 14 | ai := sc.ActivityIndicator("indicator") 15 | ai.Start() 16 | time.Sleep(1 * time.Second) 17 | assert.Equal(t, "", stdout.String()) 18 | assert.Equal(t, "", stderr.String()) 19 | } 20 | -------------------------------------------------------------------------------- /script.go: -------------------------------------------------------------------------------- 1 | package script 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // RecoverFunc prints any panic message to Stderr 9 | var RecoverFunc = func() { 10 | if r := recover(); r != nil { 11 | os.Stderr.WriteString(fmt.Sprintf("%v\n", r)) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /shutil.go: -------------------------------------------------------------------------------- 1 | // forked from https://github.com/termie/go-shutil 2 | package script 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/spf13/afero" 11 | ) 12 | 13 | type SameFileError struct { 14 | Src string 15 | Dst string 16 | } 17 | 18 | func (e SameFileError) Error() string { 19 | return fmt.Sprintf("%s and %s are the same file", e.Src, e.Dst) 20 | } 21 | 22 | type SpecialFileError struct { 23 | File string 24 | FileInfo os.FileInfo 25 | } 26 | 27 | func (e SpecialFileError) Error() string { 28 | return fmt.Sprintf("`%s` is a named pipe", e.File) 29 | } 30 | 31 | type NotADirectoryError struct { 32 | Src string 33 | } 34 | 35 | func (e NotADirectoryError) Error() string { 36 | return fmt.Sprintf("`%s` is not a directory", e.Src) 37 | } 38 | 39 | type AlreadyExistsError struct { 40 | Dst string 41 | } 42 | 43 | func (e AlreadyExistsError) Error() string { 44 | return fmt.Sprintf("`%s` already exists", e.Dst) 45 | } 46 | 47 | func samefile(fs afero.Fs, src string, dst string) bool { 48 | srcInfo, _ := fs.Stat(src) 49 | dstInfo, _ := fs.Stat(dst) 50 | return os.SameFile(srcInfo, dstInfo) 51 | } 52 | 53 | func specialfile(fi os.FileInfo) bool { 54 | return (fi.Mode() & os.ModeNamedPipe) == os.ModeNamedPipe 55 | } 56 | 57 | func stringInSlice(a string, list []string) bool { 58 | for _, b := range list { 59 | if b == a { 60 | return true 61 | } 62 | } 63 | return false 64 | } 65 | 66 | // CopyFile copies data from src to dst 67 | func CopyFile(fs afero.Fs, src, dst string, followSymlinks bool) error { 68 | if samefile(fs, src, dst) { 69 | return &SameFileError{src, dst} 70 | } 71 | 72 | // Make sure src exists and neither are special files 73 | srcStat, err := fs.Stat(src) 74 | if err != nil { 75 | return err 76 | } 77 | if specialfile(srcStat) { 78 | return &SpecialFileError{src, srcStat} 79 | } 80 | 81 | dstStat, err := fs.Stat(dst) 82 | if err != nil && !os.IsNotExist(err) { 83 | return err 84 | } else if err == nil { 85 | if specialfile(dstStat) { 86 | return &SpecialFileError{dst, dstStat} 87 | } 88 | } 89 | 90 | // do the actual copy 91 | fsrc, err := fs.Open(src) 92 | if err != nil { 93 | return err 94 | } 95 | defer fsrc.Close() 96 | 97 | fdst, err := fs.Create(dst) 98 | if err != nil { 99 | return err 100 | } 101 | defer fdst.Close() 102 | 103 | size, err := io.Copy(fdst, fsrc) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | if size != srcStat.Size() { 109 | return fmt.Errorf("%s: %d/%d copied", src, size, srcStat.Size()) 110 | } 111 | 112 | return nil 113 | } 114 | 115 | // CopyMode copies mode bits from src to dst. 116 | func CopyMode(fs afero.Fs, src, dst string, followSymlinks bool) error { 117 | srcStat, err := fs.Stat(src) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | // get the actual file stats 123 | srcStat, _ = fs.Stat(src) 124 | err = fs.Chmod(dst, srcStat.Mode()) 125 | return err 126 | } 127 | 128 | // Copy data and mode bits ("cp src dst"). Return the file's destination. 129 | // 130 | // The destination may be a directory. 131 | // 132 | // If followSymlinks is false, symlinks won't be followed. This 133 | // resembles GNU's "cp -P src dst". 134 | // 135 | // If source and destination are the same file, a SameFileError will be 136 | // rased. 137 | func Copy(fs afero.Fs, src, dst string, followSymlinks bool) (string, error) { 138 | dstInfo, err := fs.Stat(dst) 139 | 140 | if err == nil && dstInfo.Mode().IsDir() { 141 | dst = filepath.Join(dst, filepath.Base(src)) 142 | } 143 | 144 | if err != nil && !os.IsNotExist(err) { 145 | return dst, err 146 | } 147 | 148 | err = CopyFile(fs, src, dst, followSymlinks) 149 | if err != nil { 150 | return dst, err 151 | } 152 | 153 | err = CopyMode(fs, src, dst, followSymlinks) 154 | if err != nil { 155 | return dst, err 156 | } 157 | 158 | return dst, nil 159 | } 160 | 161 | type CopyTreeOptions struct { 162 | CopyFunction func(afero.Fs, string, string, bool) (string, error) 163 | Ignore func(string, []os.FileInfo) []string 164 | } 165 | 166 | // Recursively copy a directory tree. 167 | // 168 | // The destination directory must not already exist. 169 | // 170 | // If the optional Symlinks flag is true, symbolic links in the 171 | // source tree result in symbolic links in the destination tree; if 172 | // it is false, the contents of the files pointed to by symbolic 173 | // links are copied. If the file pointed by the symlink doesn't 174 | // exist, an error will be returned. 175 | // 176 | // You can set the optional IgnoreDanglingSymlinks flag to true if you 177 | // want to silence this error. Notice that this has no effect on 178 | // platforms that don't support os.Symlink. 179 | // 180 | // The optional ignore argument is a callable. If given, it 181 | // is called with the `src` parameter, which is the directory 182 | // being visited by CopyTree(), and `names` which is the list of 183 | // `src` contents, as returned by ioutil.ReadDir(): 184 | // 185 | // callable(src, entries) -> ignoredNames 186 | // 187 | // Since CopyTree() is called recursively, the callable will be 188 | // called once for each directory that is copied. It returns a 189 | // list of names relative to the `src` directory that should 190 | // not be copied. 191 | // 192 | // The optional copyFunction argument is a callable that will be used 193 | // to copy each file. It will be called with the source path and the 194 | // destination path as arguments. By default, Copy() is used, but any 195 | // function that supports the same signature (like Copy2() when it 196 | // exists) can be used. 197 | func CopyTree(fs afero.Fs, src, dst string, options *CopyTreeOptions) error { 198 | if options == nil { 199 | options = &CopyTreeOptions{ 200 | Ignore: nil, 201 | CopyFunction: Copy, 202 | } 203 | } 204 | 205 | srcFileInfo, err := fs.Stat(src) 206 | if err != nil { 207 | return err 208 | } 209 | 210 | if !srcFileInfo.IsDir() { 211 | return &NotADirectoryError{src} 212 | } 213 | 214 | _, err = fs.Open(dst) 215 | if !os.IsNotExist(err) { 216 | return &AlreadyExistsError{dst} 217 | } 218 | 219 | entries, err := afero.ReadDir(fs, src) 220 | if err != nil { 221 | return err 222 | } 223 | 224 | err = fs.MkdirAll(dst, srcFileInfo.Mode()) 225 | if err != nil { 226 | return err 227 | } 228 | 229 | ignoredNames := []string{} 230 | if options.Ignore != nil { 231 | ignoredNames = options.Ignore(src, entries) 232 | } 233 | 234 | for _, entry := range entries { 235 | if stringInSlice(entry.Name(), ignoredNames) { 236 | continue 237 | } 238 | srcPath := filepath.Join(src, entry.Name()) 239 | dstPath := filepath.Join(dst, entry.Name()) 240 | 241 | entryFileInfo, err := fs.Stat(srcPath) 242 | if err != nil { 243 | return err 244 | } 245 | 246 | if entryFileInfo.IsDir() { 247 | err = CopyTree(fs, srcPath, dstPath, options) 248 | if err != nil { 249 | return err 250 | } 251 | } else { 252 | _, err = options.CopyFunction(fs, srcPath, dstPath, false) 253 | if err != nil { 254 | return err 255 | } 256 | } 257 | } 258 | return nil 259 | } 260 | -------------------------------------------------------------------------------- /test/bin/basic-output: -------------------------------------------------------------------------------- 1 | out: hello this is me 2 | err: "abc" 3 | out: whatever 4 | exit: 0 5 | -------------------------------------------------------------------------------- /test/bin/bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jojomi/go-script/c1ee4c53423f3dfec7d9a3f448540234d39acf03/test/bin/bin -------------------------------------------------------------------------------- /test/bin/bin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "log" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | func main() { 13 | args := os.Args[1:] 14 | if len(args) < 1 { 15 | return 16 | } 17 | 18 | var err error 19 | file, err := os.Open(args[0]) 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | defer file.Close() 24 | 25 | scanner := bufio.NewScanner(file) 26 | var ( 27 | input string 28 | value string 29 | intValue int 30 | ) 31 | for scanner.Scan() { 32 | input = scanner.Text() 33 | 34 | if strings.HasPrefix(input, "out: ") { 35 | value = input[len("out: "):] 36 | os.Stdout.Write([]byte(value + "\n")) 37 | continue 38 | } 39 | if strings.HasPrefix(input, "err: ") { 40 | value = input[len("err: "):] 41 | os.Stderr.Write([]byte(value + "\n")) 42 | continue 43 | } 44 | if strings.HasPrefix(input, "echo: ") { 45 | stdinScanner := bufio.NewScanner(os.Stdin) 46 | stdinScanner.Scan() 47 | value = stdinScanner.Text() + "\n" 48 | os.Stdout.Write([]byte(value)) 49 | os.Stderr.Write([]byte(value)) 50 | continue 51 | } 52 | if strings.HasPrefix(input, "exit: ") { 53 | value = input[len("exit: "):] 54 | intValue, err = strconv.Atoi(value) 55 | if err != nil { 56 | panic(err) 57 | } 58 | os.Exit(intValue) 59 | continue 60 | } 61 | if strings.HasPrefix(input, "sleep: ") { 62 | value = input[len("sleep: "):] 63 | intValue, err = strconv.Atoi(value) 64 | if err != nil { 65 | panic(err) 66 | } 67 | time.Sleep(time.Millisecond * time.Duration(intValue)) 68 | continue 69 | } 70 | } 71 | 72 | if err := scanner.Err(); err != nil { 73 | log.Fatal(err) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/bin/echo: -------------------------------------------------------------------------------- 1 | echo: 2 | -------------------------------------------------------------------------------- /test/bin/exit-code-error: -------------------------------------------------------------------------------- 1 | exit: 28 2 | -------------------------------------------------------------------------------- /test/bin/make.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | go build -o bin 4 | -------------------------------------------------------------------------------- /test/bin/sleep: -------------------------------------------------------------------------------- 1 | out: before 2 | err: error-before 3 | sleep: 50 4 | out: after 5 | err: error-after 6 | -------------------------------------------------------------------------------- /test/dir/dir-2.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jojomi/go-script/c1ee4c53423f3dfec7d9a3f448540234d39acf03/test/dir/dir-2.txt -------------------------------------------------------------------------------- /test/dir/dir.txt: -------------------------------------------------------------------------------- 1 | dir.txt content 2 | -------------------------------------------------------------------------------- /test/dir/subdir/subdir-file: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jojomi/go-script/c1ee4c53423f3dfec7d9a3f448540234d39acf03/test/dir/subdir/subdir-file -------------------------------------------------------------------------------- /test/file.cfg: -------------------------------------------------------------------------------- 1 | ############################### 2 | # THIS IS A COMMENT 3 | # CONFIG FILE FOR MY SERVICE 4 | ############################### 5 | 6 | 7 | domain: github.com 8 | backup-domain: github.com 9 | useTLS: yes 10 | secondary-service: gitlab-com 11 | 12 | # deploy: docker 13 | -------------------------------------------------------------------------------- /test/file.sample: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jojomi/go-script/c1ee4c53423f3dfec7d9a3f448540234d39acf03/test/file.sample --------------------------------------------------------------------------------