diff --git a/README.md b/README.md index 84e4a27..e52b319 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ All public APIs are covered by runnable examples under `./examples`, and the tes | **Decoding** | [Decode](#decode) [DecodeJSON](#decodejson) [DecodeWith](#decodewith) [DecodeYAML](#decodeyaml) [FromCombined](#fromcombined) [FromStderr](#fromstderr) [FromStdout](#fromstdout) [Into](#into) [Trim](#trim) | | **Environment** | [Env](#env) [EnvAppend](#envappend) [EnvInherit](#envinherit) [EnvList](#envlist) [EnvOnly](#envonly) | | **Errors** | [Error](#error) [Unwrap](#unwrap) | -| **Execution** | [CombinedOutput](#combinedoutput) [Output](#output) [OutputBytes](#outputbytes) [OutputTrimmed](#outputtrimmed) [Run](#run) [Start](#start) | +| **Execution** | [CombinedOutput](#combinedoutput) [Output](#output) [OutputBytes](#outputbytes) [OutputTrimmed](#outputtrimmed) [Run](#run) [Start](#start) [OnExecCmd](#onexeccmd) | | **Input** | [StdinBytes](#stdinbytes) [StdinFile](#stdinfile) [StdinReader](#stdinreader) [StdinString](#stdinstring) | | **OS Controls** | [CreationFlags](#creationflags) [HideWindow](#hidewindow) [Pdeathsig](#pdeathsig) [Setpgid](#setpgid) [Setsid](#setsid) | | **Pipelining** | [Pipe](#pipe) [PipeBestEffort](#pipebesteffort) [PipeStrict](#pipestrict) [PipelineResults](#pipelineresults) | @@ -620,6 +620,18 @@ fmt.Println(res.ExitCode == 0) // #bool true ``` +### OnExecCmd + +OnExecCmd registers a callback to mutate the underlying exec.Cmd before start. + +```go +_, _ = execx.Command("printf", "hi"). + OnExecCmd(func(cmd *exec.Cmd) { + cmd.Env = append(cmd.Env, "EXAMPLE=1") + }). + Run() +``` + ## Input ### StdinBytes @@ -1019,6 +1031,8 @@ _, _ = execx.Command("printf", "hi\n"). StderrWriter sets a raw writer for stderr. +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. + ```go var out strings.Builder _, err := execx.Command("go", "env", "-badflag"). @@ -1036,6 +1050,8 @@ fmt.Println(err == nil) StdoutWriter sets a raw writer for stdout. +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. + ```go var out strings.Builder _, _ = execx.Command("printf", "hello"). diff --git a/examples/creationflags/main.go b/examples/creationflags/main.go index 3876d16..2556811 100644 --- a/examples/creationflags/main.go +++ b/examples/creationflags/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // CreationFlags is a no-op on non-Windows platforms; on Windows it sets process creation flags. + // CreationFlags sets Windows process creation flags (for example, create a new process group). // Example: creation flags out, _ := execx.Command("printf", "ok").CreationFlags(execx.CreateNewProcessGroup).Output() diff --git a/examples/hidewindow/main.go b/examples/hidewindow/main.go index 1f88f6c..2ca4166 100644 --- a/examples/hidewindow/main.go +++ b/examples/hidewindow/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - // HideWindow is a no-op on non-Windows platforms; on Windows it hides console windows. + // HideWindow hides console windows and sets CREATE_NO_WINDOW for console apps. // Example: hide window out, _ := execx.Command("printf", "ok").HideWindow(true).Output() diff --git a/examples/onexeccmd/main.go b/examples/onexeccmd/main.go new file mode 100644 index 0000000..9b39831 --- /dev/null +++ b/examples/onexeccmd/main.go @@ -0,0 +1,21 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "os/exec" + + "github.com/goforj/execx" +) + +func main() { + // OnExecCmd registers a callback to mutate the underlying exec.Cmd before start. + + // Example: exec cmd + _, _ = execx.Command("printf", "hi"). + OnExecCmd(func(cmd *exec.Cmd) { + cmd.Env = append(cmd.Env, "EXAMPLE=1") + }). + Run() +} diff --git a/execx.go b/execx.go index 90b9ca0..07695b5 100644 --- a/execx.go +++ b/execx.go @@ -15,6 +15,8 @@ import ( "sync" "syscall" "time" + + "golang.org/x/term" ) type envMode int @@ -71,6 +73,7 @@ type Cmd struct { stderrW io.Writer sysProcAttr *syscall.SysProcAttr + onExecCmd func(*exec.Cmd) next *Cmd root *Cmd @@ -374,6 +377,9 @@ func (c *Cmd) OnStderr(fn func(string)) *Cmd { // StdoutWriter sets a raw writer for stdout. // @group Streaming // +// 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. +// // Example: stdout writer // // var out strings.Builder @@ -390,6 +396,9 @@ func (c *Cmd) StdoutWriter(w io.Writer) *Cmd { // StderrWriter sets a raw writer for stderr. // @group Streaming // +// 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. +// // Example: stderr writer // // var out strings.Builder @@ -407,6 +416,21 @@ func (c *Cmd) StderrWriter(w io.Writer) *Cmd { return c } +// OnExecCmd registers a callback to mutate the underlying exec.Cmd before start. +// @group Execution +// +// Example: exec cmd +// +// _, _ = execx.Command("printf", "hi"). +// OnExecCmd(func(cmd *exec.Cmd) { +// cmd.SysProcAttr = &syscall.SysProcAttr{} +// }). +// Run() +func (c *Cmd) OnExecCmd(fn func(*exec.Cmd)) *Cmd { + c.onExecCmd = fn + return c +} + // Pipe appends a new command to the pipeline. Pipelines run on all platforms. // @group Pipelining // @@ -808,10 +832,28 @@ func (c *Cmd) execCmd() *exec.Cmd { if c.sysProcAttr != nil { cmd.SysProcAttr = c.sysProcAttr } + if c.onExecCmd != nil { + c.onExecCmd(cmd) + } return cmd } +var isTerminalFunc = term.IsTerminal + +func isTerminalWriter(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok { + return false + } + return isTerminalFunc(int(f.Fd())) +} + func (c *Cmd) stdoutWriter(buf *bytes.Buffer, withCombined bool, combined *bytes.Buffer, shadow *shadowContext) io.Writer { + if c.stdoutW != nil && c.onStdout == nil && !withCombined { + if isTerminalWriter(c.stdoutW) { + return c.stdoutW + } + } writers := []io.Writer{} if c.stdoutW != nil { writers = append(writers, c.stdoutW) @@ -831,6 +873,11 @@ func (c *Cmd) stdoutWriter(buf *bytes.Buffer, withCombined bool, combined *bytes } func (c *Cmd) stderrWriter(buf *bytes.Buffer, withCombined bool, combined *bytes.Buffer, shadow *shadowContext) io.Writer { + if c.stderrW != nil && c.onStderr == nil && !withCombined { + if isTerminalWriter(c.stderrW) { + return c.stderrW + } + } writers := []io.Writer{} if c.stderrW != nil { writers = append(writers, c.stderrW) @@ -854,6 +901,7 @@ type lineWriter struct { buf bytes.Buffer } +// Write buffers output and emits completed lines to the callback. func (l *lineWriter) Write(p []byte) (int, error) { if l.onLine == nil { return len(p), nil @@ -933,8 +981,10 @@ type shadowContext struct { type ShadowPhase string const ( + // ShadowBefore labels the pre-execution shadow print. ShadowBefore ShadowPhase = "before" - ShadowAfter ShadowPhase = "after" + // ShadowAfter labels the post-execution shadow print. + ShadowAfter ShadowPhase = "after" ) // ShadowEvent captures details for ShadowPrint formatting. @@ -1015,6 +1065,7 @@ func wrapShadowWriter(out io.Writer, shadow *shadowContext) io.Writer { return out } +// Write forwards output while tracking spacing for shadow output. func (s *shadowOutputWriter) Write(p []byte) (int, error) { if s.ctx != nil && s.ctx.spacing && len(p) > 0 { s.ctx.mu.Lock() diff --git a/execx_test.go b/execx_test.go index d230de4..2747631 100644 --- a/execx_test.go +++ b/execx_test.go @@ -1,6 +1,7 @@ package execx import ( + "bytes" "context" "errors" "io" @@ -514,6 +515,61 @@ func TestLineWriterNil(t *testing.T) { } } +func TestOnExecCmdApplied(t *testing.T) { + called := false + cmd := Command("printf", "hi").OnExecCmd(func(ec *exec.Cmd) { + called = true + ec.Env = append(ec.Env, "EXECX_TEST=1") + }) + execCmd := cmd.execCmd() + if !called { + t.Fatalf("expected OnExecCmd callback to run") + } + found := false + for _, entry := range execCmd.Env { + if entry == "EXECX_TEST=1" { + found = true + break + } + } + if !found { + t.Fatalf("expected OnExecCmd to mutate env") + } +} + +func TestIsTerminalWriterNonFile(t *testing.T) { + var buf bytes.Buffer + if isTerminalWriter(&buf) { + t.Fatalf("expected non-file writer to be non-terminal") + } +} + +func TestStdoutWriterTTYPassthrough(t *testing.T) { + prev := isTerminalFunc + isTerminalFunc = func(int) bool { return true } + t.Cleanup(func() { + isTerminalFunc = prev + }) + cmd := Command("printf", "hi").StdoutWriter(os.Stdout) + out := cmd.stdoutWriter(&bytes.Buffer{}, false, &bytes.Buffer{}, nil) + if out != os.Stdout { + t.Fatalf("expected stdout writer to passthrough tty") + } +} + +func TestStderrWriterTTYPassthrough(t *testing.T) { + prev := isTerminalFunc + isTerminalFunc = func(int) bool { return true } + t.Cleanup(func() { + isTerminalFunc = prev + }) + cmd := Command("printf", "hi").StderrWriter(os.Stderr) + out := cmd.stderrWriter(&bytes.Buffer{}, false, &bytes.Buffer{}, nil) + if out != os.Stderr { + t.Fatalf("expected stderr writer to passthrough tty") + } +} + func TestSignalFromStateNil(t *testing.T) { if signalFromState(nil) != nil { t.Fatalf("expected nil signal") diff --git a/go.mod b/go.mod index 42edfb6..ccc3fb9 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,9 @@ module github.com/goforj/execx go 1.24.4 -require gopkg.in/yaml.v3 v3.0.1 +require ( + golang.org/x/term v0.0.0-20210503060354-a79de5458b56 + gopkg.in/yaml.v3 v3.0.1 +) + +require golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect diff --git a/go.sum b/go.sum index a62c313..7c681ef 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=