Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -620,6 +620,18 @@ fmt.Println(res.ExitCode == 0)
// #bool true
```

### <a id="onexeccmd"></a>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

### <a id="stdinbytes"></a>StdinBytes
Expand Down Expand Up @@ -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").
Expand All @@ -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").
Expand Down
2 changes: 1 addition & 1 deletion examples/creationflags/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion examples/hidewindow/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions examples/onexeccmd/main.go
Original file line number Diff line number Diff line change
@@ -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()
}
53 changes: 52 additions & 1 deletion execx.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"sync"
"syscall"
"time"

"golang.org/x/term"
)

type envMode int
Expand Down Expand Up @@ -71,6 +73,7 @@ type Cmd struct {
stderrW io.Writer

sysProcAttr *syscall.SysProcAttr
onExecCmd func(*exec.Cmd)

next *Cmd
root *Cmd
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
//
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down
56 changes: 56 additions & 0 deletions execx_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package execx

import (
"bytes"
"context"
"errors"
"io"
Expand Down Expand Up @@ -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")
Expand Down
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down