From 40f3e23b2d62c37f1feeb3b5e2a2d1107db3b495 Mon Sep 17 00:00:00 2001 From: Dhanalakshmi-D04 Date: Sat, 8 Aug 2026 08:47:48 +0530 Subject: [PATCH] Add Shell method for running commands via the system shell Adds ExecCommand(name, args...), which runs a command directly with no parsing, holding the shared stdin/stdout/stderr/env/context/ error-handling logic. Exec is now a thin wrapper that splits cmdLine with shell.Fields and delegates to ExecCommand. A shell.Fields parse error is now surfaced immediately via p.WithError, rather than only becoming visible once the pipe is read; this is covered by TestExec_SetsErrorImmediatelyOnInvalidCommandLineSyntax. Shell runs cmdLine via the operating system's native shell (sh -c on Unix-like systems, cmd /C on Windows, chosen via runtime.GOOS), by calling ExecCommand directly with cmdLine as a single, completely unmodified argument. This fixes the WithEnv/variable-expansion bug described in #239, since the shell alone is responsible for parsing and expansion, with no local re-interpretation of the command line. Tested on both Linux and Windows. --- README.md | 2 + script.go | 96 +++++++++++++++++++++++++---------- script_test.go | 8 +++ script_unix_test.go | 111 +++++++++++++++++++++++++++++++++++++++++ script_windows_test.go | 38 ++++++++++++++ 5 files changed, 230 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 7e6e332e..e893f090 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 | @@ -363,6 +364,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 457505dc..03736a7d 100644 --- a/script.go +++ b/script.go @@ -18,6 +18,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "sort" "strconv" "strings" @@ -187,6 +188,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 { @@ -417,41 +424,41 @@ func (p *Pipe) Error() error { return p.err } -// Exec runs cmdLine as an external command, sending it the contents of the -// pipe as input, and produces the command's standard output (see below for -// error output). The effect of this is to filter the contents of the pipe -// through the external command. +// ExecCommand runs name as an external command with the supplied args, +// sending it the contents of the pipe as input, and produces the +// command's combined output (interleaving standard output and standard +// error). Unlike [Pipe.Exec], ExecCommand performs no parsing of its +// own: name and args are passed directly to the operating system, +// exactly as supplied, with no shell involved. // // # Environment // -// The command inherits the current process's environment, optionally modified -// by [Pipe.WithEnv]. +// The command inherits the current process's environment, optionally +// modified by [Pipe.WithEnv]. // // # Context // -// The command inherits the pipe's context (if any was set by [Pipe.WithContext]), and -// will be cancelled if the context is cancelled or times out. +// The command inherits the pipe's context (if any was set by +// [Pipe.WithContext]), and will be cancelled if the context is +// cancelled or times out. // // # Error handling // -// If the command had a non-zero exit status, the pipe's error status will also -// be set to the string “exit status X”, where X is the integer exit status. -// Even in the event of a non-zero exit status, the command's output will still -// be available in the pipe. This is often helpful for debugging. However, -// because [Pipe.String] is a no-op if the pipe's error status is set, if you -// want output you will need to reset the error status before calling -// [Pipe.String]. +// If the command had a non-zero exit status, the pipe's error status +// will also be set to the string "exit status X", where X is the +// integer exit status. Even in the event of a non-zero exit status, +// the command's output will still be available in the pipe. This is +// often helpful for debugging. However, because [Pipe.String] is a +// no-op if the pipe's error status is set, if you want output you +// will need to reset the error status before calling [Pipe.String]. // -// If the command writes to its standard error stream, this will also go to the -// pipe, along with its standard output. However, the standard error text can -// instead be redirected to a supplied writer, using [Pipe.WithStderr]. -func (p *Pipe) Exec(cmdLine string) *Pipe { +// If the command writes to its standard error stream, this will also +// go to the pipe, along with its standard output. However, the +// standard error text can instead be redirected to a supplied writer, +// using [Pipe.WithStderr]. +func (p *Pipe) ExecCommand(name string, args ...string) *Pipe { return p.Filter(func(r io.Reader, w io.Writer) error { - args, err := shell.Fields(cmdLine, nil) - if err != nil { - return err - } - cmd := exec.CommandContext(p.ctx, args[0], args[1:]...) + cmd := exec.CommandContext(p.ctx, name, args...) cmd.Stdin = r cmd.Stdout = w cmd.Stderr = w @@ -463,7 +470,7 @@ func (p *Pipe) Exec(cmdLine string) *Pipe { if pipeEnv != nil { cmd.Env = pipeEnv } - err = cmd.Start() + err := cmd.Start() if err != nil { fmt.Fprintln(cmd.Stderr, err) return err @@ -472,6 +479,22 @@ func (p *Pipe) Exec(cmdLine string) *Pipe { }) } +// Exec runs cmdLine as an external command, sending it the contents of +// the pipe as input, and produces the command's standard output (see +// below for error output). The effect of this is to filter the +// contents of the pipe through the external command. cmdLine is split +// into a program name and arguments using shell.Fields-style parsing. +// +// See [Pipe.ExecCommand] for details on error handling, context, and +// environment variables. +func (p *Pipe) Exec(cmdLine string) *Pipe { + args, err := shell.Fields(cmdLine, nil) + if err != nil { + return p.WithError(err) + } + return p.ExecCommand(args[0], args[1:]...) +} + // ExecForEach renders cmdLine as a Go template for each line of input, running // the resulting command, and produces the combined output of all these // commands in sequence. See [Pipe.Exec] for details on error handling and @@ -931,6 +954,29 @@ 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. cmdLine is passed to the shell completely unmodified +// as a single argument; unlike [Pipe.Exec], Shell performs no parsing +// of cmdLine at all, so the shell alone is responsible for +// interpreting quoting, variable expansion, and other syntax, 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.ExecCommand] for details on error handling, context, and +// environment variables set via [Pipe.WithEnv]. +func (p *Pipe) Shell(cmdLine string) *Pipe { + shell, flag := "sh", "-c" + if runtime.GOOS == "windows" { + shell, flag = "cmd", "/C" + } + return p.ExecCommand(shell, flag, cmdLine) +} + // Slice returns the pipe's contents as a slice of strings, one element per // line, or an error. // diff --git a/script_test.go b/script_test.go index c4f9a77b..e3a3820d 100644 --- a/script_test.go +++ b/script_test.go @@ -1232,6 +1232,14 @@ func TestExecErrorsWhenTheSpecifiedCommandDoesNotExist(t *testing.T) { } } +func TestExec_SetsErrorImmediatelyOnInvalidCommandLineSyntax(t *testing.T) { + t.Parallel() + p := script.Exec("echo \"unterminated") + if p.Error() == nil { + t.Error("want error to be set immediately after Exec with invalid syntax, before reading the pipe") + } +} + func TestExecRunsGoWithNoArgsAndGetsUsageMessagePlusErrorExitStatus2(t *testing.T) { t.Parallel() // We can't make many cross-platform assumptions about what external 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") + } +}