From f844d448b5648c407648b20a6cb63db43762ffaa Mon Sep 17 00:00:00 2001 From: Dhanalakshmi D Date: Wed, 29 Jul 2026 14:45:14 +0530 Subject: [PATCH 1/2] Add Shell method for running commands via the system shell Adds a new Shell method (and package-level Shell function) that runs a command line through the native OS shell (sh -c on Unix, cmd /C on Windows), rather than parsing arguments manually like Exec does. This allows correct variable expansion when used with WithEnv, fixing the issue described in #239, while leaving Exec unchanged for backward compatibility. --- README.md | 2 + script.go | 43 ++++++++++++++++ script_unix_test.go | 111 +++++++++++++++++++++++++++++++++++++++++ script_windows_test.go | 38 ++++++++++++++ shell_unix.go | 11 ++++ shell_windows.go | 11 ++++ 6 files changed, 216 insertions(+) create mode 100644 shell_unix.go create mode 100644 shell_windows.go diff --git a/README.md b/README.md index 038e31e6..93fe43d5 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,7 @@ These are functions that create a pipe with a given contents: | [`IfExists`](https://pkg.go.dev/github.com/bitfield/script#IfExists) | do something only if some file exists | | [`ListFiles`](https://pkg.go.dev/github.com/bitfield/script#ListFiles) | file listing (including wildcards) | | [`Post`](https://pkg.go.dev/github.com/bitfield/script#Post) | HTTP response | +| [`Shell`](https://pkg.go.dev/github.com/bitfield/script#Shell) | command output, run via the system shell | | [`Slice`](https://pkg.go.dev/github.com/bitfield/script#Slice) | slice elements, one per line | | [`Stdin`](https://pkg.go.dev/github.com/bitfield/script#Stdin) | standard input | @@ -362,6 +363,7 @@ Filters are methods on an existing pipe that also return a pipe, allowing you to | [`RejectRegexp`](https://pkg.go.dev/github.com/bitfield/script#Pipe.RejectRegexp) | lines not matching given regexp | | [`Replace`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Replace) | matching text replaced with given string | | [`ReplaceRegexp`](https://pkg.go.dev/github.com/bitfield/script#Pipe.ReplaceRegexp) | matching text replaced with given string | +| [`Shell`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Shell) | filtered through the system shell | | [`Tee`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Tee) | input copied to supplied writers | Note that filters run concurrently, rather than producing nothing until each stage has fully read its input. This is convenient for executing long-running commands, for example. If you do need to wait for the pipeline to complete, call [`Wait`](https://pkg.go.dev/github.com/bitfield/script#Pipe.Wait). diff --git a/script.go b/script.go index 2195da43..c405d417 100644 --- a/script.go +++ b/script.go @@ -184,6 +184,12 @@ func Post(url string) *Pipe { return NewPipe().Post(url) } +// Shell creates a pipe that runs cmdLine as a command via the system shell, +// and produces its combined output. See [Pipe.Shell] for details. +func Shell(cmdLine string) *Pipe { + return NewPipe().Shell(cmdLine) +} + // Slice creates a pipe containing each element of s, one per line. If s is // empty or nil, then the pipe is empty. func Slice(s []string) *Pipe { @@ -888,6 +894,43 @@ func (p *Pipe) SHA256Sums() *Pipe { return p.HashSums(sha256.New()) } +// Shell runs cmdLine as a command via the operating system's native shell +// ("sh -c" on Unix-like systems, "cmd /C" on Windows), sending it the +// contents of the pipe as input, and produces the command's combined +// output. Unlike [Pipe.Exec], Shell performs no argument parsing of its +// own; the entire command line is passed unmodified to the shell, which +// handles all parsing, quoting, and variable expansion exactly as it +// would on an interactive command line. +// +// Note that variable syntax differs by platform: Unix shells expand +// variables written as $VAR, while cmd.exe on Windows expands variables +// written as %VAR%. +// +// See [Pipe.Exec] for details on error handling and environment variables +// set via [Pipe.WithEnv]. +func (p *Pipe) Shell(cmdLine string) *Pipe { + return p.Filter(func(r io.Reader, w io.Writer) error { + cmd := shellCommand(cmdLine) + cmd.Stdin = r + cmd.Stdout = w + cmd.Stderr = w + pipeStderr := p.stdErr() + if pipeStderr != nil { + cmd.Stderr = pipeStderr + } + pipeEnv := p.environment() + if pipeEnv != nil { + cmd.Env = pipeEnv + } + err := cmd.Start() + if err != nil { + fmt.Fprintln(cmd.Stderr, err) + return err + } + return cmd.Wait() + }) +} + // Slice returns the pipe's contents as a slice of strings, one element per // line, or an error. // diff --git a/script_unix_test.go b/script_unix_test.go index ae512c4c..bf802599 100644 --- a/script_unix_test.go +++ b/script_unix_test.go @@ -7,6 +7,10 @@ import ( "path/filepath" "testing" + "bytes" + "errors" + "strings" + "github.com/bitfield/script" "github.com/google/go-cmp/cmp" ) @@ -222,3 +226,110 @@ func ExamplePipe_ExecForEach() { // b // c } + +func TestShellRunsShWithEchoHelloAndGetsOutputHello(t *testing.T) { + t.Parallel() + p := script.Shell("echo hello") + if p.Error() != nil { + t.Fatal(p.Error()) + } + want := "hello\n" + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if want != got { + t.Error(cmp.Diff(want, got)) + } +} + +func TestShell_ExpandsEnvironmentVariablesSetViaWithEnv(t *testing.T) { + t.Parallel() + env := []string{"ENV1=test1", "ENV2=test2"} + got, err := script.NewPipe().WithEnv(env).Shell("echo ENV1=$ENV1 ENV2=$ENV2").String() + if err != nil { + t.Fatal(err) + } + want := "ENV1=test1 ENV2=test2\n" + if want != got { + t.Error(cmp.Diff(want, got)) + } +} + +func TestShellExpandsHomeVariableWithoutWithEnv(t *testing.T) { + t.Parallel() + p := script.Shell("echo $HOME") + if p.Error() != nil { + t.Fatal(p.Error()) + } + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(got) == "" { + t.Error("want non-empty $HOME expansion, got empty string") + } +} + +func TestShellPipesDataToExternalCommandAndGetsExpectedOutput(t *testing.T) { + t.Parallel() + p := script.File("testdata/hello.txt").Shell("cat") + want := "hello world" + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if want != got { + t.Error(cmp.Diff(want, got)) + } +} + +func TestShellErrorsRunningCommandThatDoesNotExist(t *testing.T) { + t.Parallel() + p := script.Shell("doesntexist_command_xyz") + p.Wait() + if p.Error() == nil { + t.Error("want error running non-existent command") + } +} + +func TestShellSendsStderrOutputToPipeStderr(t *testing.T) { + t.Parallel() + buf := new(bytes.Buffer) + out, err := script.NewPipe().WithStderr(buf).Shell("go").String() + if err == nil { + t.Fatal("want error when command returns a non-zero exit status") + } + if out != "" { + t.Fatalf("unexpected output: %q", out) + } + if !strings.Contains(buf.String(), "Usage") { + t.Errorf("want stderr output containing the word 'Usage', got %q", buf.String()) + } +} + +func TestShellOnEmptyPipeProducesNoOutputAndNoError(t *testing.T) { + t.Parallel() + got, err := script.NewPipe().Shell("cat").String() + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("want empty output, got %q", got) + } +} + +func TestShellOnPipeWithExistingErrorIsNoOp(t *testing.T) { + t.Parallel() + fakeErr := errors.New("existing error") + p := script.NewPipe().WithError(fakeErr).Shell("echo hello") + if p.Error() != fakeErr { + t.Errorf("want existing error %v preserved, got %v", fakeErr, p.Error()) + } +} + +func ExamplePipe_Shell() { + script.Echo("Hello, world!").Shell("tr a-z A-Z").Stdout() + // Output: + // HELLO, WORLD! +} diff --git a/script_windows_test.go b/script_windows_test.go index 3290d4ec..defeb943 100644 --- a/script_windows_test.go +++ b/script_windows_test.go @@ -91,3 +91,41 @@ func ExamplePipe_Dirname() { // ./src // C:\ } + +func TestShellRunsCmdWithEchoHelloAndGetsOutputHello(t *testing.T) { + t.Parallel() + p := script.Shell("echo hello") + if p.Error() != nil { + t.Fatal(p.Error()) + } + want := "hello\r\n" + got, err := p.String() + if err != nil { + t.Fatal(err) + } + if want != got { + t.Errorf("want %q, got %q", want, got) + } +} + +func TestShell_ExpandsEnvironmentVariablesSetViaWithEnvOnWindows(t *testing.T) { + t.Parallel() + env := []string{"ENV1=test1"} + got, err := script.NewPipe().WithEnv(env).Shell("echo ENV1=%ENV1%").String() + if err != nil { + t.Fatal(err) + } + want := "ENV1=test1\r\n" + if want != got { + t.Errorf("want %q, got %q", want, got) + } +} + +func TestShellErrorsRunningCommandThatDoesNotExistOnWindows(t *testing.T) { + t.Parallel() + p := script.Shell("doesntexist_command_xyz") + p.Wait() + if p.Error() == nil { + t.Error("want error running non-existent command") + } +} diff --git a/shell_unix.go b/shell_unix.go new file mode 100644 index 00000000..aa716aaf --- /dev/null +++ b/shell_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package script + +import "os/exec" + +// shellCommand returns the command used to run cmdLine via the system +// shell on Unix-like systems. +func shellCommand(cmdLine string) *exec.Cmd { + return exec.Command("sh", "-c", cmdLine) +} diff --git a/shell_windows.go b/shell_windows.go new file mode 100644 index 00000000..80145184 --- /dev/null +++ b/shell_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package script + +import "os/exec" + +// shellCommand returns the command used to run cmdLine via the system +// shell on Windows. +func shellCommand(cmdLine string) *exec.Cmd { + return exec.Command("cmd", "/C", cmdLine) +} From 9868af65c2d24926cae0bab9c107a2e2665c7541 Mon Sep 17 00:00:00 2001 From: Dhanalakshmi-D04 Date: Sun, 2 Aug 2026 03:30:51 +0530 Subject: [PATCH 2/2] Implement Shell in terms of Exec Shell now builds a shell-invocation command line (sh -c / cmd /C, chosen via runtime.GOOS) and passes it to Exec, instead of maintaining a separate runCmd-based implementation. This removes the need for a third command-running code path and the two platform-specific source files (shell_unix.go, shell_windows.go). --- script.go | 42 ++++++++++++++++++------------------------ shell_unix.go | 11 ----------- shell_windows.go | 11 ----------- 3 files changed, 18 insertions(+), 46 deletions(-) delete mode 100644 shell_unix.go delete mode 100644 shell_windows.go diff --git a/script.go b/script.go index d43d61fb..8f96ff0b 100644 --- a/script.go +++ b/script.go @@ -18,6 +18,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "sort" "strconv" "strings" @@ -940,8 +941,8 @@ func (p *Pipe) SHA256Sums() *Pipe { // Shell runs cmdLine as a command via the operating system's native shell // ("sh -c" on Unix-like systems, "cmd /C" on Windows), sending it the // contents of the pipe as input, and produces the command's combined -// output. Unlike [Pipe.Exec], Shell performs no argument parsing of its -// own; the entire command line is passed unmodified to the shell, which +// output. Unlike calling [Pipe.Exec] directly, Shell performs no argument +// parsing of cmdLine itself; it is passed unmodified to the shell, which // handles all parsing, quoting, and variable expansion exactly as it // would on an interactive command line. // @@ -949,29 +950,22 @@ func (p *Pipe) SHA256Sums() *Pipe { // variables written as $VAR, while cmd.exe on Windows expands variables // written as %VAR%. // -// See [Pipe.Exec] for details on error handling and environment variables -// set via [Pipe.WithEnv]. +// See [Pipe.Exec] for details on error handling, context, and +// environment variables set via [Pipe.WithEnv]. func (p *Pipe) Shell(cmdLine string) *Pipe { - return p.Filter(func(r io.Reader, w io.Writer) error { - cmd := shellCommand(cmdLine) - cmd.Stdin = r - cmd.Stdout = w - cmd.Stderr = w - pipeStderr := p.stdErr() - if pipeStderr != nil { - cmd.Stderr = pipeStderr - } - pipeEnv := p.environment() - if pipeEnv != nil { - cmd.Env = pipeEnv - } - err := cmd.Start() - if err != nil { - fmt.Fprintln(cmd.Stderr, err) - return err - } - return cmd.Wait() - }) + shell, flag := "sh", "-c" + if runtime.GOOS == "windows" { + shell, flag = "cmd", "/C" + } + return p.Exec(shell + " " + flag + " " + quoteShellArg(cmdLine)) +} + +// quoteShellArg quotes s using POSIX single-quoting rules, so that +// [Pipe.Exec]'s internal argument parsing treats it as a single +// argument, preserving cmdLine exactly for the shell invoked by +// [Pipe.Shell]. +func quoteShellArg(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // Slice returns the pipe's contents as a slice of strings, one element per diff --git a/shell_unix.go b/shell_unix.go deleted file mode 100644 index aa716aaf..00000000 --- a/shell_unix.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build !windows - -package script - -import "os/exec" - -// shellCommand returns the command used to run cmdLine via the system -// shell on Unix-like systems. -func shellCommand(cmdLine string) *exec.Cmd { - return exec.Command("sh", "-c", cmdLine) -} diff --git a/shell_windows.go b/shell_windows.go deleted file mode 100644 index 80145184..00000000 --- a/shell_windows.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build windows - -package script - -import "os/exec" - -// shellCommand returns the command used to run cmdLine via the system -// shell on Windows. -func shellCommand(cmdLine string) *exec.Cmd { - return exec.Command("cmd", "/C", cmdLine) -}