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=