Skip to content

Commit fd32601

Browse files
authored
fix: tty passthrough (#3)
1 parent d7f3e40 commit fd32601

8 files changed

Lines changed: 158 additions & 5 deletions

File tree

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ All public APIs are covered by runnable examples under `./examples`, and the tes
212212
| **Decoding** | [Decode](#decode) [DecodeJSON](#decodejson) [DecodeWith](#decodewith) [DecodeYAML](#decodeyaml) [FromCombined](#fromcombined) [FromStderr](#fromstderr) [FromStdout](#fromstdout) [Into](#into) [Trim](#trim) |
213213
| **Environment** | [Env](#env) [EnvAppend](#envappend) [EnvInherit](#envinherit) [EnvList](#envlist) [EnvOnly](#envonly) |
214214
| **Errors** | [Error](#error) [Unwrap](#unwrap) |
215-
| **Execution** | [CombinedOutput](#combinedoutput) [Output](#output) [OutputBytes](#outputbytes) [OutputTrimmed](#outputtrimmed) [Run](#run) [Start](#start) |
215+
| **Execution** | [CombinedOutput](#combinedoutput) [Output](#output) [OutputBytes](#outputbytes) [OutputTrimmed](#outputtrimmed) [Run](#run) [Start](#start) [OnExecCmd](#onexeccmd) |
216216
| **Input** | [StdinBytes](#stdinbytes) [StdinFile](#stdinfile) [StdinReader](#stdinreader) [StdinString](#stdinstring) |
217217
| **OS Controls** | [CreationFlags](#creationflags) [HideWindow](#hidewindow) [Pdeathsig](#pdeathsig) [Setpgid](#setpgid) [Setsid](#setsid) |
218218
| **Pipelining** | [Pipe](#pipe) [PipeBestEffort](#pipebesteffort) [PipeStrict](#pipestrict) [PipelineResults](#pipelineresults) |
@@ -620,6 +620,18 @@ fmt.Println(res.ExitCode == 0)
620620
// #bool true
621621
```
622622

623+
### <a id="onexeccmd"></a>OnExecCmd
624+
625+
OnExecCmd registers a callback to mutate the underlying exec.Cmd before start.
626+
627+
```go
628+
_, _ = execx.Command("printf", "hi").
629+
OnExecCmd(func(cmd *exec.Cmd) {
630+
cmd.Env = append(cmd.Env, "EXAMPLE=1")
631+
}).
632+
Run()
633+
```
634+
623635
## Input
624636

625637
### <a id="stdinbytes"></a>StdinBytes
@@ -1019,6 +1031,8 @@ _, _ = execx.Command("printf", "hi\n").
10191031

10201032
StderrWriter sets a raw writer for stderr.
10211033

1034+
When the writer is a terminal and no line callbacks or combined output are enabled, execx passes stderr through directly and does not buffer it for results.
1035+
10221036
```go
10231037
var out strings.Builder
10241038
_, err := execx.Command("go", "env", "-badflag").
@@ -1036,6 +1050,8 @@ fmt.Println(err == nil)
10361050

10371051
StdoutWriter sets a raw writer for stdout.
10381052

1053+
When the writer is a terminal and no line callbacks or combined output are enabled, execx passes stdout through directly and does not buffer it for results.
1054+
10391055
```go
10401056
var out strings.Builder
10411057
_, _ = execx.Command("printf", "hello").

examples/creationflags/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
)
1010

1111
func main() {
12-
// CreationFlags is a no-op on non-Windows platforms; on Windows it sets process creation flags.
12+
// CreationFlags sets Windows process creation flags (for example, create a new process group).
1313

1414
// Example: creation flags
1515
out, _ := execx.Command("printf", "ok").CreationFlags(execx.CreateNewProcessGroup).Output()

examples/hidewindow/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
)
1010

1111
func main() {
12-
// HideWindow is a no-op on non-Windows platforms; on Windows it hides console windows.
12+
// HideWindow hides console windows and sets CREATE_NO_WINDOW for console apps.
1313

1414
// Example: hide window
1515
out, _ := execx.Command("printf", "ok").HideWindow(true).Output()

examples/onexeccmd/main.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//go:build ignore
2+
// +build ignore
3+
4+
package main
5+
6+
import (
7+
"os/exec"
8+
9+
"github.com/goforj/execx"
10+
)
11+
12+
func main() {
13+
// OnExecCmd registers a callback to mutate the underlying exec.Cmd before start.
14+
15+
// Example: exec cmd
16+
_, _ = execx.Command("printf", "hi").
17+
OnExecCmd(func(cmd *exec.Cmd) {
18+
cmd.Env = append(cmd.Env, "EXAMPLE=1")
19+
}).
20+
Run()
21+
}

execx.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import (
1515
"sync"
1616
"syscall"
1717
"time"
18+
19+
"golang.org/x/term"
1820
)
1921

2022
type envMode int
@@ -71,6 +73,7 @@ type Cmd struct {
7173
stderrW io.Writer
7274

7375
sysProcAttr *syscall.SysProcAttr
76+
onExecCmd func(*exec.Cmd)
7477

7578
next *Cmd
7679
root *Cmd
@@ -374,6 +377,9 @@ func (c *Cmd) OnStderr(fn func(string)) *Cmd {
374377
// StdoutWriter sets a raw writer for stdout.
375378
// @group Streaming
376379
//
380+
// When the writer is a terminal and no line callbacks or combined output are enabled,
381+
// execx passes stdout through directly and does not buffer it for results.
382+
//
377383
// Example: stdout writer
378384
//
379385
// var out strings.Builder
@@ -390,6 +396,9 @@ func (c *Cmd) StdoutWriter(w io.Writer) *Cmd {
390396
// StderrWriter sets a raw writer for stderr.
391397
// @group Streaming
392398
//
399+
// When the writer is a terminal and no line callbacks or combined output are enabled,
400+
// execx passes stderr through directly and does not buffer it for results.
401+
//
393402
// Example: stderr writer
394403
//
395404
// var out strings.Builder
@@ -407,6 +416,21 @@ func (c *Cmd) StderrWriter(w io.Writer) *Cmd {
407416
return c
408417
}
409418

419+
// OnExecCmd registers a callback to mutate the underlying exec.Cmd before start.
420+
// @group Execution
421+
//
422+
// Example: exec cmd
423+
//
424+
// _, _ = execx.Command("printf", "hi").
425+
// OnExecCmd(func(cmd *exec.Cmd) {
426+
// cmd.SysProcAttr = &syscall.SysProcAttr{}
427+
// }).
428+
// Run()
429+
func (c *Cmd) OnExecCmd(fn func(*exec.Cmd)) *Cmd {
430+
c.onExecCmd = fn
431+
return c
432+
}
433+
410434
// Pipe appends a new command to the pipeline. Pipelines run on all platforms.
411435
// @group Pipelining
412436
//
@@ -808,10 +832,28 @@ func (c *Cmd) execCmd() *exec.Cmd {
808832
if c.sysProcAttr != nil {
809833
cmd.SysProcAttr = c.sysProcAttr
810834
}
835+
if c.onExecCmd != nil {
836+
c.onExecCmd(cmd)
837+
}
811838
return cmd
812839
}
813840

841+
var isTerminalFunc = term.IsTerminal
842+
843+
func isTerminalWriter(w io.Writer) bool {
844+
f, ok := w.(*os.File)
845+
if !ok {
846+
return false
847+
}
848+
return isTerminalFunc(int(f.Fd()))
849+
}
850+
814851
func (c *Cmd) stdoutWriter(buf *bytes.Buffer, withCombined bool, combined *bytes.Buffer, shadow *shadowContext) io.Writer {
852+
if c.stdoutW != nil && c.onStdout == nil && !withCombined {
853+
if isTerminalWriter(c.stdoutW) {
854+
return c.stdoutW
855+
}
856+
}
815857
writers := []io.Writer{}
816858
if c.stdoutW != nil {
817859
writers = append(writers, c.stdoutW)
@@ -831,6 +873,11 @@ func (c *Cmd) stdoutWriter(buf *bytes.Buffer, withCombined bool, combined *bytes
831873
}
832874

833875
func (c *Cmd) stderrWriter(buf *bytes.Buffer, withCombined bool, combined *bytes.Buffer, shadow *shadowContext) io.Writer {
876+
if c.stderrW != nil && c.onStderr == nil && !withCombined {
877+
if isTerminalWriter(c.stderrW) {
878+
return c.stderrW
879+
}
880+
}
834881
writers := []io.Writer{}
835882
if c.stderrW != nil {
836883
writers = append(writers, c.stderrW)
@@ -854,6 +901,7 @@ type lineWriter struct {
854901
buf bytes.Buffer
855902
}
856903

904+
// Write buffers output and emits completed lines to the callback.
857905
func (l *lineWriter) Write(p []byte) (int, error) {
858906
if l.onLine == nil {
859907
return len(p), nil
@@ -933,8 +981,10 @@ type shadowContext struct {
933981
type ShadowPhase string
934982

935983
const (
984+
// ShadowBefore labels the pre-execution shadow print.
936985
ShadowBefore ShadowPhase = "before"
937-
ShadowAfter ShadowPhase = "after"
986+
// ShadowAfter labels the post-execution shadow print.
987+
ShadowAfter ShadowPhase = "after"
938988
)
939989

940990
// ShadowEvent captures details for ShadowPrint formatting.
@@ -1015,6 +1065,7 @@ func wrapShadowWriter(out io.Writer, shadow *shadowContext) io.Writer {
10151065
return out
10161066
}
10171067

1068+
// Write forwards output while tracking spacing for shadow output.
10181069
func (s *shadowOutputWriter) Write(p []byte) (int, error) {
10191070
if s.ctx != nil && s.ctx.spacing && len(p) > 0 {
10201071
s.ctx.mu.Lock()

execx_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package execx
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"io"
@@ -514,6 +515,61 @@ func TestLineWriterNil(t *testing.T) {
514515
}
515516
}
516517

518+
func TestOnExecCmdApplied(t *testing.T) {
519+
called := false
520+
cmd := Command("printf", "hi").OnExecCmd(func(ec *exec.Cmd) {
521+
called = true
522+
ec.Env = append(ec.Env, "EXECX_TEST=1")
523+
})
524+
execCmd := cmd.execCmd()
525+
if !called {
526+
t.Fatalf("expected OnExecCmd callback to run")
527+
}
528+
found := false
529+
for _, entry := range execCmd.Env {
530+
if entry == "EXECX_TEST=1" {
531+
found = true
532+
break
533+
}
534+
}
535+
if !found {
536+
t.Fatalf("expected OnExecCmd to mutate env")
537+
}
538+
}
539+
540+
func TestIsTerminalWriterNonFile(t *testing.T) {
541+
var buf bytes.Buffer
542+
if isTerminalWriter(&buf) {
543+
t.Fatalf("expected non-file writer to be non-terminal")
544+
}
545+
}
546+
547+
func TestStdoutWriterTTYPassthrough(t *testing.T) {
548+
prev := isTerminalFunc
549+
isTerminalFunc = func(int) bool { return true }
550+
t.Cleanup(func() {
551+
isTerminalFunc = prev
552+
})
553+
cmd := Command("printf", "hi").StdoutWriter(os.Stdout)
554+
out := cmd.stdoutWriter(&bytes.Buffer{}, false, &bytes.Buffer{}, nil)
555+
if out != os.Stdout {
556+
t.Fatalf("expected stdout writer to passthrough tty")
557+
}
558+
}
559+
560+
func TestStderrWriterTTYPassthrough(t *testing.T) {
561+
prev := isTerminalFunc
562+
isTerminalFunc = func(int) bool { return true }
563+
t.Cleanup(func() {
564+
isTerminalFunc = prev
565+
})
566+
cmd := Command("printf", "hi").StderrWriter(os.Stderr)
567+
out := cmd.stderrWriter(&bytes.Buffer{}, false, &bytes.Buffer{}, nil)
568+
if out != os.Stderr {
569+
t.Fatalf("expected stderr writer to passthrough tty")
570+
}
571+
}
572+
517573
func TestSignalFromStateNil(t *testing.T) {
518574
if signalFromState(nil) != nil {
519575
t.Fatalf("expected nil signal")

go.mod

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,9 @@ module github.com/goforj/execx
22

33
go 1.24.4
44

5-
require gopkg.in/yaml.v3 v3.0.1
5+
require (
6+
golang.org/x/term v0.0.0-20210503060354-a79de5458b56
7+
gopkg.in/yaml.v3 v3.0.1
8+
)
9+
10+
require golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
2+
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
3+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
4+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
15
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
26
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
37
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

0 commit comments

Comments
 (0)