├── images └── tailer.gif ├── .gitignore ├── .github └── workflows │ ├── goreleaser.yml │ └── go.yml ├── go.mod ├── LICENSE ├── .goreleaser.yaml ├── README.md ├── pkg └── tailer │ ├── options.go │ ├── tailer_test.go │ └── tailer.go ├── go.sum └── cmd └── tailer └── main.go /images/tailer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hionay/tailer/HEAD/images/tailer.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | cmd/tailer/tailer 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | dist/ 16 | completions/ 17 | vendor/ 18 | .idea -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | - run: git fetch --force --tags 19 | - uses: actions/setup-go@v4 20 | with: 21 | go-version: stable 22 | - uses: goreleaser/goreleaser-action@v4 23 | with: 24 | distribution: goreleaser 25 | version: latest 26 | args: release --clean 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.TAILER_GORELEASER_TOKEN }} 29 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hionay/tailer 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/fatih/color v1.15.0 7 | github.com/stretchr/testify v1.8.4 8 | github.com/urfave/cli/v2 v2.25.7 9 | golang.org/x/term v0.9.0 10 | ) 11 | 12 | require ( 13 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/mattn/go-colorable v0.1.13 // indirect 16 | github.com/mattn/go-isatty v0.0.19 // indirect 17 | github.com/pmezard/go-difflib v1.0.0 // indirect 18 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 19 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 20 | golang.org/x/sys v0.9.0 // indirect 21 | gopkg.in/yaml.v3 v3.0.1 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | test: 14 | strategy: 15 | matrix: 16 | os: [ 'ubuntu-latest', 'macOS-latest', 'windows-latest' ] 17 | go: [ '1.18', '1.19', '1.20' ] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v4 24 | with: 25 | go-version: ${{ matrix.go }} 26 | 27 | - name: Test 28 | run: go test -count=1 -v -failfast -cover ./... 29 | 30 | - name: Test with race detector 31 | run: go test -count=1 -v -race -failfast -cover ./... 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Halil ibrahim Onay 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 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - main: ./cmd/tailer 6 | binary: tailer 7 | ldflags: 8 | - -s -w -X main.version={{.Version}} 9 | tags: 10 | - urfave_cli_no_docs 11 | env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | - windows 16 | - darwin 17 | 18 | archives: 19 | - format: tar.gz 20 | name_template: >- 21 | {{ .ProjectName }}_{{ .Version }}_ 22 | {{- if eq .Os "darwin" }}macOS 23 | {{- else }}{{ .Os }}{{ end }}_{{ .Arch }} 24 | format_overrides: 25 | - goos: windows 26 | format: zip 27 | 28 | nfpms: 29 | - package_name: tailer 30 | file_name_template: "{{ .ConventionalFileName }}" 31 | homepage: https://github.com/hionay/tailer 32 | maintainer: Halil ibrahim Onay 33 | description: A simple CLI tool to insert lines when command output stops 34 | license: MIT 35 | formats: 36 | - deb 37 | - rpm 38 | - archlinux 39 | 40 | release: 41 | prerelease: auto 42 | snapshot: 43 | name_template: "{{ incpatch .Version }}-next" 44 | changelog: 45 | sort: asc 46 | filters: 47 | exclude: 48 | - '^docs:' 49 | - '^test:' -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tailer 2 | 3 | [![Go](https://github.com/hionay/tailer/actions/workflows/go.yml/badge.svg)](https://github.com/hionay/tailer/actions/workflows/go.yml) 4 | 5 | I was inspired by [samwho/spacer](https://github.com/samwho/spacer), which was written in Rust, and I really liked it. I decided to write it in Go, and here we go! :) 6 | 7 | `tailer` is a simple CLI tool to insert lines when command output stops. 8 | 9 | ![](https://github.com/hionay/tailer/blob/main/images/tailer.gif) 10 | 11 | ## Installation 12 | 13 | ```bash 14 | go install github.com/hionay/tailer/cmd/tailer@latest 15 | ``` 16 | 17 | ## Usage 18 | Here are the commands and flags you can use with `tailer`: 19 | ``` 20 | COMMANDS: 21 | exec, e execute a command and tail its output 22 | help, h Shows a list of commands or help for one command 23 | 24 | GLOBAL OPTIONS: 25 | --no-color disable color output (default: false) 26 | --after value, -a value duration to wait after last output (default: 1s) 27 | --dash value, -d value dash character to print (default: "─") 28 | --help, -h show help 29 | --version, -v print the version 30 | ``` 31 | 32 | You can use `tailer` to execute a command with `exec` without the need for piping. 33 | 34 | ```bash 35 | $ tailer exec "python3 -m http.server 9300" 36 | ``` 37 | -------------------------------------------------------------------------------- /pkg/tailer/options.go: -------------------------------------------------------------------------------- 1 | package tailer 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "time" 7 | ) 8 | 9 | const DefaultDashString = "─" 10 | const DefaultWaitDuration = 1 * time.Second 11 | 12 | type options struct { 13 | inrd io.Reader 14 | outwr io.Writer 15 | dashString string 16 | afterDuration time.Duration 17 | noColor bool 18 | } 19 | 20 | func getDefaultOptions() options { 21 | return options{ 22 | inrd: os.Stdin, 23 | outwr: os.Stdout, 24 | dashString: DefaultDashString, 25 | afterDuration: DefaultWaitDuration, 26 | noColor: false, 27 | } 28 | } 29 | 30 | type TailerOptionFunc func(*options) 31 | 32 | func WithInputReader(rd io.Reader) TailerOptionFunc { 33 | return func(opts *options) { 34 | opts.inrd = rd 35 | } 36 | } 37 | 38 | func WithOutputWriter(wr io.Writer) TailerOptionFunc { 39 | return func(opts *options) { 40 | opts.outwr = wr 41 | } 42 | } 43 | 44 | func WithNoColor(noColor bool) TailerOptionFunc { 45 | return func(opts *options) { 46 | opts.noColor = noColor 47 | } 48 | } 49 | 50 | func WithDashString(str string) TailerOptionFunc { 51 | return func(opts *options) { 52 | opts.dashString = str 53 | } 54 | } 55 | 56 | func WithAfterDuration(dur time.Duration) TailerOptionFunc { 57 | return func(opts *options) { 58 | opts.afterDuration = dur 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/tailer/tailer_test.go: -------------------------------------------------------------------------------- 1 | package tailer 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "strings" 9 | "sync" 10 | "testing" 11 | "time" 12 | 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestTailerRunNoInput(t *testing.T) { 18 | in := make([]byte, 0) 19 | var out bytes.Buffer 20 | tl := New( 21 | WithInputReader(bytes.NewReader(in)), 22 | WithOutputWriter(&out), 23 | WithAfterDuration(0), 24 | ) 25 | var wg sync.WaitGroup 26 | wg.Add(1) 27 | go func() { 28 | defer wg.Done() 29 | err := tl.Run(context.Background()) 30 | assert.NoError(t, err) 31 | }() 32 | time.AfterFunc(10*time.Millisecond, func() { _ = tl.Close() }) 33 | wg.Wait() 34 | require.Equal(t, 0, out.Len()) 35 | } 36 | 37 | func TestTailerRunNoInputWithDuration(t *testing.T) { 38 | in := make([]byte, 0) 39 | var out bytes.Buffer 40 | tl := New( 41 | WithInputReader(bytes.NewReader(in)), 42 | WithOutputWriter(&out), 43 | WithAfterDuration(10*time.Millisecond), 44 | ) 45 | var wg sync.WaitGroup 46 | wg.Add(1) 47 | go func() { 48 | defer wg.Done() 49 | err := tl.Run(context.Background()) 50 | assert.NoError(t, err) 51 | }() 52 | time.AfterFunc(30*time.Millisecond, func() { _ = tl.Close() }) 53 | wg.Wait() 54 | require.Equal(t, 0, out.Len()) 55 | } 56 | 57 | func TestTailerRun(t *testing.T) { 58 | pr, pw := io.Pipe() 59 | defer pw.Close() 60 | 61 | var out bytes.Buffer 62 | tl := New( 63 | WithInputReader(pr), 64 | WithOutputWriter(&out), 65 | WithAfterDuration(5*time.Millisecond), 66 | ) 67 | var wg sync.WaitGroup 68 | wg.Add(1) 69 | go func() { 70 | defer wg.Done() 71 | err := tl.Run(context.Background()) 72 | assert.NoError(t, err) 73 | }() 74 | txt := "hello world" 75 | fmt.Fprintln(pw, txt) 76 | time.AfterFunc(50*time.Millisecond, func() { _ = tl.Close() }) 77 | wg.Wait() 78 | _ = pw.Close() 79 | require.True(t, strings.HasPrefix(out.String(), txt+"\n")) 80 | require.True(t, strings.HasSuffix(out.String(), DefaultDashString+"\n")) 81 | } 82 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 6 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 7 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 8 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 9 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 10 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 11 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 15 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 16 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 17 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 18 | github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= 19 | github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= 20 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= 21 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= 22 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 | golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= 25 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 26 | golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= 27 | golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 31 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | -------------------------------------------------------------------------------- /pkg/tailer/tailer.go: -------------------------------------------------------------------------------- 1 | package tailer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/fatih/color" 13 | "golang.org/x/term" 14 | ) 15 | 16 | type Tailer struct { 17 | pw io.WriteCloser 18 | readch chan struct{} 19 | opts options 20 | wg sync.WaitGroup 21 | mu sync.Mutex 22 | isTerminal bool 23 | } 24 | 25 | func New(opts ...TailerOptionFunc) *Tailer { 26 | tl := &Tailer{ 27 | readch: make(chan struct{}, 1), 28 | opts: getDefaultOptions(), 29 | } 30 | for _, opt := range opts { 31 | opt(&tl.opts) 32 | } 33 | return tl 34 | } 35 | 36 | func (tl *Tailer) Run(ctx context.Context) error { 37 | pr, pw := io.Pipe() 38 | tl.mu.Lock() 39 | tl.pw = pw 40 | tl.mu.Unlock() 41 | 42 | if f, ok := tl.opts.outwr.(*os.File); ok && f != nil { 43 | tl.isTerminal = term.IsTerminal(int(f.Fd())) 44 | } 45 | 46 | tl.wg.Add(1) 47 | go tl.worker(ctx) 48 | go func() { 49 | _, _ = io.Copy(pw, tl.opts.inrd) 50 | _ = tl.Close() 51 | }() 52 | 53 | writerFn := writeFunc(func(p []byte) (int, error) { 54 | tl.readch <- struct{}{} 55 | tl.mu.Lock() 56 | defer tl.mu.Unlock() 57 | return tl.opts.outwr.Write(p) 58 | }) 59 | _, err := io.Copy(writerFn, pr) 60 | close(tl.readch) 61 | tl.wg.Wait() 62 | return err 63 | } 64 | 65 | func (tl *Tailer) Close() error { 66 | tl.mu.Lock() 67 | defer tl.mu.Unlock() 68 | return tl.pw.Close() 69 | } 70 | 71 | func (tl *Tailer) worker(ctx context.Context) { 72 | defer tl.wg.Done() 73 | 74 | timer := time.NewTimer(0) 75 | if !timer.Stop() { 76 | <-timer.C 77 | } 78 | 79 | last := time.Now() 80 | for { 81 | select { 82 | case <-ctx.Done(): 83 | _ = tl.Close() 84 | return 85 | case _, ok := <-tl.readch: 86 | if !ok { 87 | _ = tl.Close() 88 | return 89 | } 90 | if !timer.Stop() { 91 | select { 92 | case <-timer.C: 93 | default: 94 | } 95 | } 96 | timer.Reset(tl.opts.afterDuration) 97 | case ts := <-timer.C: 98 | tl.printLine(ts, last) 99 | last = ts 100 | } 101 | } 102 | } 103 | 104 | func (tl *Tailer) printLine(ts, last time.Time) { 105 | var ( 106 | datestr = ts.Format("2006-01-02") 107 | tmstr = ts.Format("15:04:05") 108 | sincestr = ts.Sub(last).Truncate(100 * time.Millisecond).String() 109 | ) 110 | filled := len(datestr) + len(tmstr) + len(sincestr) + 3 111 | if !tl.opts.noColor { 112 | datestr = color.GreenString(datestr) 113 | tmstr = color.YellowString(tmstr) 114 | sincestr = color.BlueString(sincestr) 115 | } 116 | 117 | width := 80 118 | if tl.isTerminal { 119 | if f, ok := tl.opts.outwr.(*os.File); ok && f != nil { 120 | w, _, err := term.GetSize(int(f.Fd())) 121 | if err == nil { 122 | width = w 123 | } 124 | } 125 | } 126 | 127 | var sb strings.Builder 128 | sb.WriteString(datestr + " " + tmstr + " " + sincestr + " ") 129 | if count := width - filled; count > 0 { 130 | sb.WriteString(strings.Repeat(tl.opts.dashString, count)) 131 | } 132 | tl.mu.Lock() 133 | _, _ = fmt.Fprintln(tl.opts.outwr, sb.String()) 134 | tl.mu.Unlock() 135 | } 136 | 137 | type writeFunc func(p []byte) (int, error) 138 | 139 | func (wf writeFunc) Write(p []byte) (int, error) { return wf(p) } 140 | -------------------------------------------------------------------------------- /cmd/tailer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "log" 8 | "os" 9 | "os/exec" 10 | "os/signal" 11 | "strings" 12 | "syscall" 13 | 14 | "github.com/urfave/cli/v2" 15 | 16 | "github.com/hionay/tailer/pkg/tailer" 17 | ) 18 | 19 | const ( 20 | flagAfter = "after" 21 | flagAfterShort = "a" 22 | flagDash = "dash" 23 | flagDashShort = "d" 24 | flagNoColor = "no-color" 25 | ) 26 | 27 | const ( 28 | commandExec = "exec" 29 | commandExecShort = "e" 30 | ) 31 | 32 | var version string 33 | 34 | func main() { 35 | app := &cli.App{ 36 | Name: "tailer", 37 | Version: version, 38 | Usage: "a simple CLI tool to insert lines when command output stops", 39 | Before: func(c *cli.Context) error { 40 | if c.IsSet(flagDash) { 41 | if c.String(flagDash) == "" { 42 | return errors.New("dash char cannot be empty") 43 | } 44 | if len(c.String(flagDash)) > 1 { 45 | return errors.New("dash char cannot be longer than 1 character") 46 | } 47 | } 48 | return nil 49 | }, 50 | Flags: []cli.Flag{ 51 | &cli.BoolFlag{ 52 | Name: flagNoColor, 53 | Usage: "disable color output", 54 | }, 55 | &cli.DurationFlag{ 56 | Name: flagAfter, 57 | Usage: "duration to wait after last output", 58 | Value: tailer.DefaultWaitDuration, 59 | Aliases: []string{flagAfterShort}, 60 | }, 61 | &cli.StringFlag{ 62 | Name: flagDash, 63 | Usage: "dash character to print", 64 | Value: tailer.DefaultDashString, 65 | Aliases: []string{flagDashShort}, 66 | }, 67 | }, 68 | Action: func(c *cli.Context) error { 69 | opts := []tailer.TailerOptionFunc{ 70 | tailer.WithAfterDuration(c.Duration(flagAfter)), 71 | tailer.WithDashString(c.String(flagDash)), 72 | } 73 | if c.Bool(flagNoColor) { 74 | opts = append(opts, tailer.WithNoColor(true)) 75 | } 76 | tl := tailer.New(opts...) 77 | return tl.Run(c.Context) 78 | }, 79 | Commands: []*cli.Command{ 80 | { 81 | Name: commandExec, 82 | Usage: "Execute a command and tail its output", 83 | Before: func(c *cli.Context) error { 84 | if c.NArg() == 0 { 85 | return errors.New("arguments cannot be empty") 86 | } 87 | return nil 88 | }, 89 | Aliases: []string{commandExecShort}, 90 | Action: execAction, 91 | }, 92 | }, 93 | } 94 | 95 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 96 | defer cancel() 97 | 98 | if err := app.RunContext(ctx, os.Args); err != nil { 99 | log.Fatal(err) 100 | } 101 | } 102 | 103 | func execAction(c *cli.Context) error { 104 | first, tail := parseCommand(c.Args().First()) 105 | if tail == nil { 106 | tail = c.Args().Tail() 107 | } 108 | cmd := exec.CommandContext(c.Context, first, tail...) 109 | pr, pw := io.Pipe() 110 | cmd.Stdout = pw 111 | cmd.Stderr = pw 112 | 113 | opts := []tailer.TailerOptionFunc{ 114 | tailer.WithAfterDuration(c.Duration(flagAfter)), 115 | tailer.WithDashString(c.String(flagDash)), 116 | tailer.WithInputReader(pr), 117 | } 118 | if c.Bool(flagNoColor) { 119 | opts = append(opts, tailer.WithNoColor(true)) 120 | } 121 | go func() { 122 | if err := cmd.Run(); err != nil { 123 | log.Printf("failed to run command: %v", err) 124 | } 125 | pw.Close() 126 | }() 127 | tl := tailer.New(opts...) 128 | return tl.Run(c.Context) 129 | } 130 | 131 | func parseCommand(args string) (string, []string) { 132 | if strings.Contains(args, " ") { 133 | split := strings.Split(args, " ") 134 | return split[0], split[1:] 135 | } 136 | return args, nil 137 | } 138 | --------------------------------------------------------------------------------