diff --git a/README.md b/README.md index 02a1b68..9c2b78f 100644 --- a/README.md +++ b/README.md @@ -14,186 +14,133 @@ Latest tag - Tests + Tests Go Report Card

-It provides a clean, composable API for running system commands without sacrificing control, correctness, or transparency. -No magic. No hidden behavior. Just a better way to work with processes. +## What execx is -## Why execx? +execx is a small, explicit wrapper around `os/exec`. It keeps the `exec.Cmd` model but adds fluent construction and consistent result handling. -The standard library’s `os/exec` package is powerful, but verbose and easy to misuse. -`execx` keeps the same underlying model while making the common cases obvious and safe. - -**execx is for you if you want:** - -- Clear, chainable command construction -- Predictable execution semantics -- Explicit control over arguments, environment, and I/O -- Zero shell interpolation or magic -- A small, auditable API surface +There is no shell interpolation. Arguments, environment, and I/O are set directly, and nothing runs until you call `Run`, `Output`, or `Start`. ## Installation ```bash go get github.com/goforj/execx -```` +``` ## Quick Start ```go -out, err := execx. - Command("git", "status"). - Output() - +out, _ := execx.Command("echo", "hello").OutputTrimmed() fmt.Println(out) +// #string hello ``` -Or with structured execution: - -```go -res, err := execx.Command("ls", "-la").Run() -if err != nil { - log.Fatal(err) -} - -fmt.Println(res.Stdout) -``` +On Windows, use `cmd /c echo hello` or `powershell -Command "echo hello"` for shell built-ins. -## Fluent Command Construction +## Basic usage -Commands are built fluently and executed explicitly. +Build a command and run it: ```go -cmd := execx. - Command("docker", "run"). - Arg("--rm"). - Arg("-p", "8080:80"). - Arg("nginx") +cmd := execx.Command("echo").Arg("hello") +res, _ := cmd.Run() +fmt.Print(res.Stdout) +// hello ``` -Nothing is executed until you call `Run`, `Output`, or `Start`. +Arguments are appended deterministically and never shell-expanded. -## Argument Handling +## Output handling -Arguments are appended deterministically and never shell-expanded. +Use `Output` variants when you only need stdout: ```go -cmd.Arg("--env", "PROD") -cmd.Arg(map[string]string{"--name": "api"}) +out, _ := execx.Command("echo", "hello").OutputTrimmed() +fmt.Println(out) +// #string hello ``` -This guarantees predictable behavior across platforms. +`Output`, `OutputBytes`, `OutputTrimmed`, and `CombinedOutput` differ only in how they return data. -## Execution Modes - -### Run +## Pipelining -Execute and return a structured result: +Pipelines run on all platforms; command availability is OS-specific. ```go -_, _ = cmd.Run() +out, _ := execx.Command("printf", "go"). + Pipe("tr", "a-z", "A-Z"). + OutputTrimmed() +fmt.Println(out) +// #string GO ``` -### Output Variants - -```go -out, err := cmd.Output() -out, err := cmd.OutputBytes() -out, err := cmd.OutputTrimmed() -out, err := cmd.CombinedOutput() -``` +On Windows, use `cmd /c` or `powershell -Command` for shell built-ins. -### Output +`PipeStrict` (default) stops at the first failing stage and returns that error. +`PipeBestEffort` runs all stages, returns the last stage output, and surfaces the first error if any stage failed. -Return stdout directly: +## Context & cancellation ```go -out, err := cmd.Output() -``` - -### Start (async) - -```go -proc := cmd.Start() -_, _ = proc.Wait() -proc.KillAfter(5 * time.Second) +ctx, cancel := context.WithTimeout(context.Background(), time.Second) +defer cancel() +res, _ := execx.Command("go", "env", "GOOS").WithContext(ctx).Run() +fmt.Println(res.ExitCode == 0) +// #bool true ``` -## Result Object +## Environment & I/O control -Every execution returns a `Result`: +Environment is explicit and deterministic: ```go -type Result struct { - Stdout string - Stderr string - ExitCode int - Err error - Duration time.Duration -} +cmd := execx.Command("echo", "hello").Env("MODE=prod") +fmt.Println(strings.Contains(strings.Join(cmd.EnvList(), ","), "MODE=prod")) +// #bool true ``` -* Non-zero exit codes do **not** imply failure -* `Err` mirrors the returned error (spawn, context, signal) - -## Pipelining - -Chain commands safely (pipeline execution is cross-platform; the commands you choose must exist on that OS): +Standard input is opt-in: ```go -out, err := execx. - Command("ps", "aux"). - Pipe("grep", "nginx"). - Pipe("awk", "{print $2}"). - Output() +out, _ := execx.Command("cat"). + StdinString("hi"). + OutputTrimmed() +fmt.Println(out) +// #string hi ``` -Pipelines are explicit and deterministic. `PipeStrict` stops at the first failing stage and returns its error. `PipeBestEffort` still runs all stages, returns the last stage output, and surfaces the first error if any stage failed. +## Advanced features -On Windows, use `cmd /c` or `powershell -Command` to access shell built-ins when needed. +For process control, use `Start` with the `Process` helpers: ```go -cmd := execx.Command("ps", "aux").Pipe("grep", "nginx") -cmd.PipeStrict() // default -cmd.PipeBestEffort() // returns last stage, surfaces first error +proc := execx.Command("go", "env", "GOOS").Start() +res, _ := proc.Wait() +fmt.Println(res.ExitCode == 0) +// #bool true ``` -## Context & Timeouts +Signals, timeouts, and OS controls are documented in the API section below. -```go -ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) -defer cancel() - -execx.Command("sleep", "5"). - WithContext(ctx). - Run() - -execx.Command("sleep", "5"). - WithTimeout(2 * time.Second). - Run() +ShadowPrint is available for emitting the command line before and after execution. -execx.Command("sleep", "5"). - WithDeadline(time.Now().Add(2 * time.Second)). - Run() -``` - -## Environment Control +## Kitchen Sink Chaining Example ```go -cmd.Env("DEBUG=true") -cmd.Env(map[string]string{"MODE": "prod"}) -cmd.EnvOnly(map[string]string{"MODE": "prod"}) -cmd.EnvInherit() -cmd.EnvAppend(map[string]string{"DEBUG": "1"}) -``` +// Run executes the command and returns the result and any error. -## Streaming Output +ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) +defer cancel() -```go -cmd. +res, err := execx. + Command("printf", "hello\nworld\n"). + Pipe("tr", "a-z", "A-Z"). + Env("MODE=demo"). + WithContext(ctx). OnStdout(func(line string) { fmt.Println("OUT:", line) }). @@ -201,64 +148,41 @@ cmd. fmt.Println("ERR:", line) }). Run() -``` - -## Raw Writers - -```go -cmd.StdoutWriter(os.Stdout) -cmd.StderrWriter(os.Stderr) -``` - -## Exit Handling - -```go -if res.IsExitCode(1) { - log.Println("Command failed") -} -``` -## Debugging Helpers + if !res.OK() { + log.Fatalf("command failed: %v", err) + } -```go -cmd.Args() -cmd.EnvList() -cmd.ShellEscaped() + fmt.Printf("Stdout: %q\n", res.Stdout) + fmt.Printf("Stderr: %q\n", res.Stderr) + fmt.Printf("ExitCode: %d\n", res.ExitCode) + fmt.Printf("Error: %v\n", res.Err) + fmt.Printf("Duration: %v\n", res.Duration) + // OUT: HELLO + // OUT: WORLD + // Stdout: "HELLO\nWORLD\n" + // Stderr: "" + // ExitCode: 0 + // Error: + // Duration: 10.123456ms ``` -## Design Principles +## Non-goals and design principles -* **Explicit over implicit** -* **No hidden behavior** -* **No shell magic** -* **Composable over clever** -* **Predictable over flexible** +Design principles: -`execx` is intentionally boring — in the best possible way. +* Explicit over implicit +* No shell interpolation +* Composable, deterministic behavior -## Non-Goals +Non-goals: * Shell scripting replacement * Command parsing or glob expansion * Task runners or build systems * Automatic retries or heuristics -## Testing & Reliability - -* 100% public API coverage -* Deterministic behavior -* No global state -* Safe for concurrent read-only use; mutation during execution is undefined - -## Runnable examples - -Every function has a corresponding runnable example under [`./examples`](./examples). - -These examples are **generated directly from the documentation blocks** of each function, ensuring the docs and code never drift. These are the same examples you see here in the README and GoDoc. - -An automated test executes **every example** to verify it builds and runs successfully. - -This guarantees all examples are valid, up-to-date, and remain functional as the API evolves. +All public APIs are covered by runnable examples under `./examples`, and the test suite executes them to keep docs and behavior in sync. @@ -280,6 +204,7 @@ This guarantees all examples are valid, up-to-date, and remain functional as the | **Results** | [IsExitCode](#isexitcode) [IsSignal](#issignal) [OK](#ok) | | **Streaming** | [OnStderr](#onstderr) [OnStdout](#onstdout) [StderrWriter](#stderrwriter) [StdoutWriter](#stdoutwriter) | | **WorkingDir** | [Dir](#dir) | +| **User Feedback** | [ShadowPrint](#shadowprint) [ShadowPrintOff](#shadowprintoff) [ShadowPrintPrefix](#shadowprintprefix) [ShadowPrintMask](#shadowprintmask) [ShadowPrintFormatter](#shadowprintformatter) | ## Arguments @@ -289,10 +214,10 @@ This guarantees all examples are valid, up-to-date, and remain functional as the Arg appends arguments to the command. ```go -cmd := execx.Command("go", "env").Arg("GOOS") +cmd := execx.Command("printf").Arg("hello") out, _ := cmd.Output() -fmt.Println(out != "") -// #bool true +fmt.Print(out) +// hello ``` ## Construction @@ -302,10 +227,10 @@ fmt.Println(out != "") Command constructs a new command without executing it. ```go -cmd := execx.Command("go", "env", "GOOS") +cmd := execx.Command("printf", "hello") out, _ := cmd.Output() -fmt.Println(out != "") -// #bool true +fmt.Print(out) +// hello ``` ## Context @@ -364,6 +289,62 @@ fmt.Println(cmd.ShellEscaped()) // #string echo 'hello world' 'it'\\''s' ``` +## User Feedback + +### ShadowPrint + +ShadowPrint writes the shell-escaped command to stderr before and after execution. + +```go +_, _ = execx.Command("printf", "hi").ShadowPrint().Run() +// execx > printf hi +// execx > printf hi (1ms) +``` + +### ShadowPrintOff + +ShadowPrintOff disables shadow printing for this command chain. + +```go +_, _ = execx.Command("printf", "hi").ShadowPrint().ShadowPrintOff().Run() +``` + +### ShadowPrintPrefix + +ShadowPrintPrefix sets the prefix used by ShadowPrint. + +```go +_, _ = execx.Command("printf", "hi").ShadowPrintPrefix("run").Run() +// run > printf hi +// run > printf hi (1ms) +``` + +### ShadowPrintMask + +ShadowPrintMask applies a masker to the shadow-printed command string. + +```go +mask := func(cmd string) string { + return strings.ReplaceAll(cmd, "secret", "***") +} +_, _ = execx.Command("printf", "secret").ShadowPrintMask(mask).Run() +// execx > printf *** +// execx > printf *** (1ms) +``` + +### ShadowPrintFormatter + +ShadowPrintFormatter sets a formatter for ShadowPrint output. + +```go +formatter := func(ev execx.ShadowEvent) string { + return fmt.Sprintf("shadow: %s %s", ev.Phase, ev.Command) +} +_, _ = execx.Command("printf", "hi").ShadowPrintFormatter(formatter).Run() +// shadow: before printf hi +// shadow: after printf hi +``` + ### String String returns a human-readable representation of the command. @@ -455,9 +436,13 @@ fmt.Println(err.Unwrap() != nil) CombinedOutput executes the command and returns stdout+stderr and any error. ```go -out, _ := execx.Command("go", "env", "GOOS").CombinedOutput() -fmt.Println(out != "") -// #bool true +out, err := execx.Command("go", "env", "-badflag").CombinedOutput() +fmt.Print(out) +fmt.Println(err == nil) +// flag provided but not defined: -badflag +// usage: go env [-json] [-changed] [-u] [-w] [var ...] +// Run 'go help env' for details. +// false ``` ### Output @@ -465,9 +450,9 @@ fmt.Println(out != "") Output executes the command and returns stdout and any error. ```go -out, _ := execx.Command("go", "env", "GOOS").Output() -fmt.Println(out != "") -// #bool true +out, _ := execx.Command("printf", "hello").Output() +fmt.Print(out) +// hello ``` ### OutputBytes @@ -475,9 +460,9 @@ fmt.Println(out != "") OutputBytes executes the command and returns stdout bytes and any error. ```go -out, _ := execx.Command("go", "env", "GOOS").OutputBytes() -fmt.Println(len(out) > 0) -// #bool true +out, _ := execx.Command("printf", "hello").OutputBytes() +fmt.Println(string(out)) +// #string hello ``` ### OutputTrimmed @@ -485,9 +470,9 @@ fmt.Println(len(out) > 0) OutputTrimmed executes the command and returns trimmed stdout and any error. ```go -out, _ := execx.Command("go", "env", "GOOS").OutputTrimmed() -fmt.Println(out != "") -// #bool true +out, _ := execx.Command("printf", "hello\n").OutputTrimmed() +fmt.Println(out) +// #string hello ``` ### Run @@ -566,115 +551,62 @@ fmt.Println(out) ## OS Controls -### CreationFlags +OS controls map to `syscall.SysProcAttr` for process/session configuration. Use them when you need process groups, detached sessions, or OS-specific process creation flags. On unsupported platforms they are no-ops. -CreationFlags is a no-op on non-Windows platforms. +### CreationFlags -_Example: creation flags_ +CreationFlags sets Windows process creation flags (for example, create a new process group). It is a no-op on non-Windows platforms. -```go -fmt.Println(execx.Command("go", "env", "GOOS").CreationFlags(0) != nil) -// #bool true -``` +Common flags: -_Example: creation flags_ +- `execx.CreateNewProcessGroup` +- `execx.CreateNewConsole` +- `execx.CreateNoWindow` ```go -fmt.Println(execx.Command("go", "env", "GOOS").CreationFlags(0) != nil) -// #bool true +out, _ := execx.Command("printf", "ok").CreationFlags(execx.CreateNewProcessGroup).Output() +fmt.Print(out) +// ok ``` ### HideWindow -HideWindow is a no-op on non-Windows platforms. - -_Example: hide window_ +HideWindow hides console windows on Windows (it sets `SysProcAttr.HideWindow` and `CREATE_NO_WINDOW`). It is a no-op on non-Windows platforms. ```go -fmt.Println(execx.Command("go", "env", "GOOS").HideWindow(true) != nil) -// #bool true -``` - -_Example: hide window_ - -```go -fmt.Println(execx.Command("go", "env", "GOOS").HideWindow(true) != nil) -// #bool true +out, _ := execx.Command("printf", "ok").HideWindow(true).Output() +fmt.Print(out) +// ok ``` ### Pdeathsig -Pdeathsig sets a parent-death signal on Linux. - -_Example: pdeathsig_ +Pdeathsig sets a parent-death signal on Linux so the child receives a signal if the parent exits. It is a no-op on non-Linux platforms. ```go -fmt.Println(execx.Command("go", "env", "GOOS").Pdeathsig(0) != nil) -// #bool true -``` - -_Example: pdeathsig_ - -```go -fmt.Println(execx.Command("go", "env", "GOOS").Pdeathsig(0) != nil) -// #bool true -``` - -_Example: pdeathsig_ - -```go -fmt.Println(execx.Command("go", "env", "GOOS").Pdeathsig(0) != nil) -// #bool true +out, _ := execx.Command("printf", "ok").Pdeathsig(syscall.SIGTERM).Output() +fmt.Print(out) +// ok ``` ### Setpgid -Setpgid sets the process group ID behavior. - -_Example: setpgid_ - -```go -fmt.Println(execx.Command("go", "env", "GOOS").Setpgid(true) != nil) -// #bool true -``` - -_Example: setpgid_ +Setpgid places the child in a new process group. Use this when you want to signal or terminate a group independently of the parent. ```go -fmt.Println(execx.Command("go", "env", "GOOS").Setpgid(true) != nil) -// #bool true -``` - -_Example: setpgid_ - -```go -fmt.Println(execx.Command("go", "env", "GOOS").Setpgid(true) != nil) -// #bool true +out, _ := execx.Command("printf", "ok").Setpgid(true).Output() +fmt.Print(out) +// ok ``` ### Setsid -Setsid sets the session ID behavior. - -_Example: setsid_ - -```go -fmt.Println(execx.Command("go", "env", "GOOS").Setsid(true) != nil) -// #bool true -``` - -_Example: setsid_ - -```go -fmt.Println(execx.Command("go", "env", "GOOS").Setsid(true) != nil) -// #bool true -``` - -_Example: setsid_ +Setsid starts the child in a new session, detaching it from the controlling terminal. ```go -fmt.Println(execx.Command("go", "env", "GOOS").Setsid(true) != nil) -// #bool true +out, _ := execx.Command("printf", "ok").Setsid(true).Output() +fmt.Print(out) +// ok ``` ## Pipelining @@ -696,12 +628,12 @@ fmt.Println(out) PipeBestEffort sets best-effort pipeline semantics (run all stages, surface the first error). ```go -res, err := execx.Command("false"). +res, _ := execx.Command("false"). Pipe("printf", "ok"). PipeBestEffort(). Run() -fmt.Println(err == nil && res.Stdout == "ok") -// #bool true +fmt.Print(res.Stdout) +// ok ``` ### PipeStrict @@ -722,11 +654,14 @@ fmt.Println(res.ExitCode != 0) PipelineResults executes the command and returns per-stage results and any error. ```go -results, err := execx.Command("printf", "go"). +results, _ := execx.Command("printf", "go"). Pipe("tr", "a-z", "A-Z"). PipelineResults() -fmt.Println(err == nil && len(results) == 2) -// #bool true +fmt.Printf("%+v", results) +// [ +// {Stdout:go Stderr: ExitCode:0 Err: Duration:6.367208ms signal:} +// {Stdout:GO Stderr: ExitCode:0 Err: Duration:4.976291ms signal:} +// ] ``` ## Process @@ -738,8 +673,8 @@ GracefulShutdown sends a signal and escalates to kill after the timeout. ```go proc := execx.Command("sleep", "2").Start() _ = proc.GracefulShutdown(os.Interrupt, 100*time.Millisecond) -res, err := proc.Wait() -fmt.Println(err != nil || res.ExitCode != 0) +res, _ := proc.Wait() +fmt.Println(res.IsSignal(os.Interrupt)) // #bool true ``` @@ -750,9 +685,9 @@ Interrupt sends an interrupt signal to the process. ```go proc := execx.Command("sleep", "2").Start() _ = proc.Interrupt() -res, err := proc.Wait() -fmt.Println(err != nil || res.ExitCode != 0) -// #bool true +res, _ := proc.Wait() +fmt.Printf("%+v", res) +// {Stdout: Stderr: ExitCode:-1 Err: Duration:75.987ms signal:interrupt} ``` ### KillAfter @@ -762,9 +697,9 @@ KillAfter terminates the process after the given duration. ```go proc := execx.Command("sleep", "2").Start() proc.KillAfter(100 * time.Millisecond) -res, err := proc.Wait() -fmt.Println(err != nil || res.ExitCode != 0) -// #bool true +res, _ := proc.Wait() +fmt.Printf("%+v", res) +// {Stdout: Stderr: ExitCode:-1 Err: Duration:100.456ms signal:killed} ``` ### Send @@ -774,9 +709,9 @@ Send sends a signal to the process. ```go proc := execx.Command("sleep", "2").Start() _ = proc.Send(os.Interrupt) -res, err := proc.Wait() -fmt.Println(err != nil || res.ExitCode != 0) -// #bool true +res, _ := proc.Wait() +fmt.Printf("%+v", res) +// {Stdout: Stderr: ExitCode:-1 Err: Duration:80.123ms signal:interrupt} ``` ### Terminate @@ -786,9 +721,9 @@ Terminate kills the process immediately. ```go proc := execx.Command("sleep", "2").Start() _ = proc.Terminate() -res, err := proc.Wait() -fmt.Println(err != nil || res.ExitCode != 0) -// #bool true +res, _ := proc.Wait() +fmt.Printf("%+v", res) +// {Stdout: Stderr: ExitCode:-1 Err: Duration:70.654ms signal:killed} ``` ### Wait @@ -798,8 +733,9 @@ Wait waits for the command to complete and returns the result and any error. ```go proc := execx.Command("go", "env", "GOOS").Start() res, _ := proc.Wait() -fmt.Println(res.ExitCode == 0) -// #bool true +fmt.Printf("%+v", res) +// {Stdout:darwin +// Stderr: ExitCode:0 Err: Duration:1.234ms signal:} ``` ## Results @@ -850,7 +786,7 @@ fmt.Println(err == nil) // flag provided but not defined: -badflag // usage: go env [-json] [-changed] [-u] [-w] [var ...] // Run 'go help env' for details. -// true +// false ``` ### OnStdout @@ -858,10 +794,10 @@ fmt.Println(err == nil) OnStdout registers a line callback for stdout. ```go -_, _ = execx.Command("go", "env", "GOOS"). +_, _ = execx.Command("printf", "hi\n"). OnStdout(func(line string) { fmt.Println(line) }). Run() -// darwin +// hi ``` ### StderrWriter @@ -878,7 +814,7 @@ fmt.Println(err == nil) // flag provided but not defined: -badflag // usage: go env [-json] [-changed] [-u] [-w] [var ...] // Run 'go help env' for details. -// true +// false ``` ### StdoutWriter @@ -887,11 +823,11 @@ StdoutWriter sets a raw writer for stdout. ```go var out strings.Builder -_, err := execx.Command("go", "env", "GOOS"). +_, _ = execx.Command("printf", "hello"). StdoutWriter(&out). Run() -fmt.Println(err == nil && out.Len() > 0) -// #bool true +fmt.Print(out.String()) +// hello ``` ## WorkingDir diff --git a/creationflags_nonwindows.go b/creationflags_nonwindows.go new file mode 100644 index 0000000..b36b6f0 --- /dev/null +++ b/creationflags_nonwindows.go @@ -0,0 +1,12 @@ +//go:build !windows + +package execx + +const ( + // CreateNewProcessGroup starts the process in a new process group. + CreateNewProcessGroup = 0x00000200 + // CreateNewConsole creates a new console for the process. + CreateNewConsole = 0x00000010 + // CreateNoWindow prevents console windows from being created. + CreateNoWindow = 0x08000000 +) diff --git a/creationflags_windows.go b/creationflags_windows.go new file mode 100644 index 0000000..e255a80 --- /dev/null +++ b/creationflags_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package execx + +import "syscall" + +const ( + // CreateNewProcessGroup starts the process in a new process group. + CreateNewProcessGroup = syscall.CREATE_NEW_PROCESS_GROUP + // CreateNewConsole creates a new console for the process. + CreateNewConsole = syscall.CREATE_NEW_CONSOLE + // CreateNoWindow prevents console windows from being created. + CreateNoWindow = syscall.CREATE_NO_WINDOW +) diff --git a/examples/arg/main.go b/examples/arg/main.go index c40898b..89a1f8b 100644 --- a/examples/arg/main.go +++ b/examples/arg/main.go @@ -12,8 +12,8 @@ func main() { // Arg appends arguments to the command. // Example: add args - cmd := execx.Command("go", "env").Arg("GOOS") + cmd := execx.Command("printf").Arg("hello") out, _ := cmd.Output() - fmt.Println(out != "") - // #bool true + fmt.Print(out) + // hello } diff --git a/examples/combinedoutput/main.go b/examples/combinedoutput/main.go index d941379..4eb4a24 100644 --- a/examples/combinedoutput/main.go +++ b/examples/combinedoutput/main.go @@ -12,7 +12,11 @@ func main() { // CombinedOutput executes the command and returns stdout+stderr and any error. // Example: combined output - out, _ := execx.Command("go", "env", "GOOS").CombinedOutput() - fmt.Println(out != "") - // #bool true + out, err := execx.Command("go", "env", "-badflag").CombinedOutput() + fmt.Print(out) + fmt.Println(err == nil) + // flag provided but not defined: -badflag + // usage: go env [-json] [-changed] [-u] [-w] [var ...] + // Run 'go help env' for details. + // false } diff --git a/examples/command/main.go b/examples/command/main.go index d27a541..4c0f907 100644 --- a/examples/command/main.go +++ b/examples/command/main.go @@ -12,8 +12,8 @@ func main() { // Command constructs a new command without executing it. // Example: command - cmd := execx.Command("go", "env", "GOOS") + cmd := execx.Command("printf", "hello") out, _ := cmd.Output() - fmt.Println(out != "") - // #bool true + fmt.Print(out) + // hello } diff --git a/examples/creationflags/main.go b/examples/creationflags/main.go index fbcee1e..2556811 100644 --- a/examples/creationflags/main.go +++ b/examples/creationflags/main.go @@ -9,12 +9,10 @@ import ( ) func main() { - // CreationFlags sets Windows creation flags. + // CreationFlags sets Windows process creation flags (for example, create a new process group). // Example: creation flags - fmt.Println(execx.Command("go", "env", "GOOS").CreationFlags(0) != nil) - // #bool true - // Example: creation flags - fmt.Println(execx.Command("go", "env", "GOOS").CreationFlags(0) != nil) - // #bool true + out, _ := execx.Command("printf", "ok").CreationFlags(execx.CreateNewProcessGroup).Output() + fmt.Print(out) + // ok } diff --git a/examples/gracefulshutdown/main.go b/examples/gracefulshutdown/main.go index 9a7ebcb..e2278c1 100644 --- a/examples/gracefulshutdown/main.go +++ b/examples/gracefulshutdown/main.go @@ -16,7 +16,7 @@ func main() { // Example: graceful shutdown proc := execx.Command("sleep", "2").Start() _ = proc.GracefulShutdown(os.Interrupt, 100*time.Millisecond) - res, err := proc.Wait() - fmt.Println(err != nil || res.ExitCode != 0) + res, _ := proc.Wait() + fmt.Println(res.IsSignal(os.Interrupt)) // #bool true } diff --git a/examples/hidewindow/main.go b/examples/hidewindow/main.go index 7de6212..2ca4166 100644 --- a/examples/hidewindow/main.go +++ b/examples/hidewindow/main.go @@ -9,12 +9,10 @@ import ( ) func main() { - // HideWindow controls window visibility and sets CREATE_NO_WINDOW for console apps. + // HideWindow hides console windows and sets CREATE_NO_WINDOW for console apps. // Example: hide window - fmt.Println(execx.Command("go", "env", "GOOS").HideWindow(true) != nil) - // #bool true - // Example: hide window - fmt.Println(execx.Command("go", "env", "GOOS").HideWindow(true) != nil) - // #bool true + out, _ := execx.Command("printf", "ok").HideWindow(true).Output() + fmt.Print(out) + // ok } diff --git a/examples/interrupt/main.go b/examples/interrupt/main.go index e30f8c7..e5b5f72 100644 --- a/examples/interrupt/main.go +++ b/examples/interrupt/main.go @@ -14,7 +14,7 @@ func main() { // Example: interrupt proc := execx.Command("sleep", "2").Start() _ = proc.Interrupt() - res, err := proc.Wait() - fmt.Println(err != nil || res.ExitCode != 0) - // #bool true + res, _ := proc.Wait() + fmt.Printf("%+v", res) + // {Stdout: Stderr: ExitCode:-1 Err: Duration:75.987ms signal:interrupt} } diff --git a/examples/killafter/main.go b/examples/killafter/main.go index 3d2471b..a84461a 100644 --- a/examples/killafter/main.go +++ b/examples/killafter/main.go @@ -15,7 +15,7 @@ func main() { // Example: kill after proc := execx.Command("sleep", "2").Start() proc.KillAfter(100 * time.Millisecond) - res, err := proc.Wait() - fmt.Println(err != nil || res.ExitCode != 0) - // #bool true + res, _ := proc.Wait() + fmt.Printf("%+v", res) + // {Stdout: Stderr: ExitCode:-1 Err: Duration:100.456ms signal:killed} } diff --git a/examples/kitchensink/main.go b/examples/kitchensink/main.go new file mode 100644 index 0000000..7f4e4f5 --- /dev/null +++ b/examples/kitchensink/main.go @@ -0,0 +1,50 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "context" + "fmt" + "github.com/goforj/execx" + "log" + "time" +) + +func main() { + // Run executes the command and returns the result and any error. + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + res, err := execx. + Command("printf", "hello\nworld\n"). + Pipe("tr", "a-z", "A-Z"). + Env("MODE=demo"). + WithContext(ctx). + OnStdout(func(line string) { + fmt.Println("OUT:", line) + }). + OnStderr(func(line string) { + fmt.Println("ERR:", line) + }). + Run() + + if !res.OK() { + log.Fatalf("command failed: %v", err) + } + + fmt.Printf("Stdout: %q\n", res.Stdout) + fmt.Printf("Stderr: %q\n", res.Stderr) + fmt.Printf("ExitCode: %d\n", res.ExitCode) + fmt.Printf("Error: %v\n", res.Err) + fmt.Printf("Duration: %v\n", res.Duration) + // OUT: HELLO + // OUT: WORLD + // Stdout: "HELLO\nWORLD\n" + // Stderr: "" + // ExitCode: 0 + // Error: + // Duration: 10.123456ms + +} diff --git a/examples/onstderr/main.go b/examples/onstderr/main.go index 6239f9e..db28ccf 100644 --- a/examples/onstderr/main.go +++ b/examples/onstderr/main.go @@ -21,5 +21,5 @@ func main() { // flag provided but not defined: -badflag // usage: go env [-json] [-changed] [-u] [-w] [var ...] // Run 'go help env' for details. - // true + // false } diff --git a/examples/onstdout/main.go b/examples/onstdout/main.go index 93c92c7..46de6f4 100644 --- a/examples/onstdout/main.go +++ b/examples/onstdout/main.go @@ -12,8 +12,8 @@ func main() { // OnStdout registers a line callback for stdout. // Example: stdout lines - _, _ = execx.Command("go", "env", "GOOS"). + _, _ = execx.Command("printf", "hi\n"). OnStdout(func(line string) { fmt.Println(line) }). Run() - // darwin + // hi } diff --git a/examples/output/main.go b/examples/output/main.go index b593173..096607a 100644 --- a/examples/output/main.go +++ b/examples/output/main.go @@ -12,7 +12,7 @@ func main() { // Output executes the command and returns stdout and any error. // Example: output - out, _ := execx.Command("go", "env", "GOOS").Output() - fmt.Println(out != "") - // #bool true + out, _ := execx.Command("printf", "hello").Output() + fmt.Print(out) + // hello } diff --git a/examples/outputbytes/main.go b/examples/outputbytes/main.go index a79b3c6..ad7f167 100644 --- a/examples/outputbytes/main.go +++ b/examples/outputbytes/main.go @@ -12,7 +12,7 @@ func main() { // OutputBytes executes the command and returns stdout bytes and any error. // Example: output bytes - out, _ := execx.Command("go", "env", "GOOS").OutputBytes() - fmt.Println(len(out) > 0) - // #bool true + out, _ := execx.Command("printf", "hello").OutputBytes() + fmt.Println(string(out)) + // #string hello } diff --git a/examples/outputtrimmed/main.go b/examples/outputtrimmed/main.go index 4042a5f..a4c47c4 100644 --- a/examples/outputtrimmed/main.go +++ b/examples/outputtrimmed/main.go @@ -12,7 +12,7 @@ func main() { // OutputTrimmed executes the command and returns trimmed stdout and any error. // Example: output trimmed - out, _ := execx.Command("go", "env", "GOOS").OutputTrimmed() - fmt.Println(out != "") - // #bool true + out, _ := execx.Command("printf", "hello\n").OutputTrimmed() + fmt.Println(out) + // #string hello } diff --git a/examples/pdeathsig/main.go b/examples/pdeathsig/main.go index 7fa2301..450a6a6 100644 --- a/examples/pdeathsig/main.go +++ b/examples/pdeathsig/main.go @@ -6,18 +6,14 @@ package main import ( "fmt" "github.com/goforj/execx" + "syscall" ) func main() { - // Pdeathsig sets a parent-death signal on Linux. + // Pdeathsig sets a parent-death signal on Linux so the child is signaled if the parent exits. // Example: pdeathsig - fmt.Println(execx.Command("go", "env", "GOOS").Pdeathsig(0) != nil) - // #bool true - // Example: pdeathsig - fmt.Println(execx.Command("go", "env", "GOOS").Pdeathsig(0) != nil) - // #bool true - // Example: pdeathsig - fmt.Println(execx.Command("go", "env", "GOOS").Pdeathsig(0) != nil) - // #bool true + out, _ := execx.Command("printf", "ok").Pdeathsig(syscall.SIGTERM).Output() + fmt.Print(out) + // ok } diff --git a/examples/pipebesteffort/main.go b/examples/pipebesteffort/main.go index 39c477a..f518bdf 100644 --- a/examples/pipebesteffort/main.go +++ b/examples/pipebesteffort/main.go @@ -12,10 +12,10 @@ func main() { // PipeBestEffort sets best-effort pipeline semantics (run all stages, surface the first error). // Example: best effort - res, err := execx.Command("false"). + res, _ := execx.Command("false"). Pipe("printf", "ok"). PipeBestEffort(). Run() - fmt.Println(err == nil && res.Stdout == "ok") - // #bool true + fmt.Print(res.Stdout) + // ok } diff --git a/examples/pipelineresults/main.go b/examples/pipelineresults/main.go index b3d7c07..609cd56 100644 --- a/examples/pipelineresults/main.go +++ b/examples/pipelineresults/main.go @@ -12,9 +12,12 @@ func main() { // PipelineResults executes the command and returns per-stage results and any error. // Example: pipeline results - results, err := execx.Command("printf", "go"). + results, _ := execx.Command("printf", "go"). Pipe("tr", "a-z", "A-Z"). PipelineResults() - fmt.Println(err == nil && len(results) == 2) - // #bool true + fmt.Printf("%+v", results) + // [ + // {Stdout:go Stderr: ExitCode:0 Err: Duration:6.367208ms signal:} + // {Stdout:GO Stderr: ExitCode:0 Err: Duration:4.976291ms signal:} + // ] } diff --git a/examples/send/main.go b/examples/send/main.go index 1ffbffa..f262c14 100644 --- a/examples/send/main.go +++ b/examples/send/main.go @@ -15,7 +15,7 @@ func main() { // Example: send signal proc := execx.Command("sleep", "2").Start() _ = proc.Send(os.Interrupt) - res, err := proc.Wait() - fmt.Println(err != nil || res.ExitCode != 0) - // #bool true + res, _ := proc.Wait() + fmt.Printf("%+v", res) + // {Stdout: Stderr: ExitCode:-1 Err: Duration:80.123ms signal:interrupt} } diff --git a/examples/setpgid/main.go b/examples/setpgid/main.go index fac2709..8e02196 100644 --- a/examples/setpgid/main.go +++ b/examples/setpgid/main.go @@ -9,15 +9,10 @@ import ( ) func main() { - // Setpgid sets the process group ID behavior. + // Setpgid places the child in a new process group for group signals. // Example: setpgid - fmt.Println(execx.Command("go", "env", "GOOS").Setpgid(true) != nil) - // #bool true - // Example: setpgid - fmt.Println(execx.Command("go", "env", "GOOS").Setpgid(true) != nil) - // #bool true - // Example: setpgid - fmt.Println(execx.Command("go", "env", "GOOS").Setpgid(true) != nil) - // #bool true + out, _ := execx.Command("printf", "ok").Setpgid(true).Output() + fmt.Print(out) + // ok } diff --git a/examples/setsid/main.go b/examples/setsid/main.go index 4ee527b..548a68e 100644 --- a/examples/setsid/main.go +++ b/examples/setsid/main.go @@ -9,15 +9,10 @@ import ( ) func main() { - // Setsid sets the session ID behavior. + // Setsid starts the child in a new session, detaching it from the terminal. // Example: setsid - fmt.Println(execx.Command("go", "env", "GOOS").Setsid(true) != nil) - // #bool true - // Example: setsid - fmt.Println(execx.Command("go", "env", "GOOS").Setsid(true) != nil) - // #bool true - // Example: setsid - fmt.Println(execx.Command("go", "env", "GOOS").Setsid(true) != nil) - // #bool true + out, _ := execx.Command("printf", "ok").Setsid(true).Output() + fmt.Print(out) + // ok } diff --git a/examples/shadowprint/main.go b/examples/shadowprint/main.go new file mode 100644 index 0000000..0499d1e --- /dev/null +++ b/examples/shadowprint/main.go @@ -0,0 +1,15 @@ +//go:build ignore +// +build ignore + +package main + +import "github.com/goforj/execx" + +func main() { + // ShadowPrint writes the shell-escaped command to stderr before and after execution. + + // Example: shadow print + _, _ = execx.Command("printf", "hi").ShadowPrint().Run() + // execx > printf hi + // execx > printf hi (1ms) +} diff --git a/examples/shadowprintformatter/main.go b/examples/shadowprintformatter/main.go new file mode 100644 index 0000000..571baa4 --- /dev/null +++ b/examples/shadowprintformatter/main.go @@ -0,0 +1,21 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "fmt" + "github.com/goforj/execx" +) + +func main() { + // ShadowPrintFormatter sets a formatter for ShadowPrint output. + + // Example: shadow print formatter + formatter := func(ev execx.ShadowEvent) string { + return fmt.Sprintf("shadow: %s %s", ev.Phase, ev.Command) + } + _, _ = execx.Command("printf", "hi").ShadowPrintFormatter(formatter).Run() + // shadow: before printf hi + // shadow: after printf hi +} diff --git a/examples/shadowprintmask/main.go b/examples/shadowprintmask/main.go new file mode 100644 index 0000000..94c775a --- /dev/null +++ b/examples/shadowprintmask/main.go @@ -0,0 +1,21 @@ +//go:build ignore +// +build ignore + +package main + +import ( + "github.com/goforj/execx" + "strings" +) + +func main() { + // ShadowPrintMask sets a command masker for ShadowPrint output. + + // Example: shadow print mask + mask := func(cmd string) string { + return strings.ReplaceAll(cmd, "secret", "***") + } + _, _ = execx.Command("printf", "secret").ShadowPrintMask(mask).Run() + // execx > printf *** + // execx > printf *** (1ms) +} diff --git a/examples/shadowprintoff/main.go b/examples/shadowprintoff/main.go new file mode 100644 index 0000000..42bfad6 --- /dev/null +++ b/examples/shadowprintoff/main.go @@ -0,0 +1,13 @@ +//go:build ignore +// +build ignore + +package main + +import "github.com/goforj/execx" + +func main() { + // ShadowPrintOff disables shadow printing for this command chain. + + // Example: shadow print off + _, _ = execx.Command("printf", "hi").ShadowPrint().ShadowPrintOff().Run() +} diff --git a/examples/shadowprintprefix/main.go b/examples/shadowprintprefix/main.go new file mode 100644 index 0000000..1b1cde3 --- /dev/null +++ b/examples/shadowprintprefix/main.go @@ -0,0 +1,15 @@ +//go:build ignore +// +build ignore + +package main + +import "github.com/goforj/execx" + +func main() { + // ShadowPrintPrefix sets the prefix used by ShadowPrint. + + // Example: shadow print prefix + _, _ = execx.Command("printf", "hi").ShadowPrintPrefix("run").Run() + // run > printf hi + // run > printf hi (1ms) +} diff --git a/examples/stderrwriter/main.go b/examples/stderrwriter/main.go index 5bfd581..d33cca6 100644 --- a/examples/stderrwriter/main.go +++ b/examples/stderrwriter/main.go @@ -22,5 +22,5 @@ func main() { // flag provided but not defined: -badflag // usage: go env [-json] [-changed] [-u] [-w] [var ...] // Run 'go help env' for details. - // true + // false } diff --git a/examples/stdoutwriter/main.go b/examples/stdoutwriter/main.go index 1efa718..877d34c 100644 --- a/examples/stdoutwriter/main.go +++ b/examples/stdoutwriter/main.go @@ -14,9 +14,9 @@ func main() { // Example: stdout writer var out strings.Builder - _, err := execx.Command("go", "env", "GOOS"). + _, _ = execx.Command("printf", "hello"). StdoutWriter(&out). Run() - fmt.Println(err == nil && out.Len() > 0) - // #bool true + fmt.Print(out.String()) + // hello } diff --git a/examples/terminate/main.go b/examples/terminate/main.go index eb0603e..fcf624b 100644 --- a/examples/terminate/main.go +++ b/examples/terminate/main.go @@ -14,7 +14,7 @@ func main() { // Example: terminate proc := execx.Command("sleep", "2").Start() _ = proc.Terminate() - res, err := proc.Wait() - fmt.Println(err != nil || res.ExitCode != 0) - // #bool true + res, _ := proc.Wait() + fmt.Printf("%+v", res) + // {Stdout: Stderr: ExitCode:-1 Err: Duration:70.654ms signal:killed} } diff --git a/examples/wait/main.go b/examples/wait/main.go index b3d1f03..cf79731 100644 --- a/examples/wait/main.go +++ b/examples/wait/main.go @@ -14,6 +14,7 @@ func main() { // Example: wait proc := execx.Command("go", "env", "GOOS").Start() res, _ := proc.Wait() - fmt.Println(res.ExitCode == 0) - // #bool true + fmt.Printf("%+v", res) + // {Stdout:darwin + // Stderr: ExitCode:0 Err: Duration:1.234ms signal:} } diff --git a/execx.go b/execx.go index 20e5520..12ea48f 100644 --- a/execx.go +++ b/execx.go @@ -36,10 +36,10 @@ const ( // // Example: command // -// cmd := execx.Command("go", "env", "GOOS") +// cmd := execx.Command("printf", "hello") // out, _ := cmd.Output() -// fmt.Println(out != "") -// // #bool true +// fmt.Print(out) +// // hello func Command(name string, args ...string) *Cmd { cmd := &Cmd{ name: name, @@ -74,6 +74,11 @@ type Cmd struct { next *Cmd root *Cmd pipeMode pipeMode + + shadowPrint bool + shadowPrefix string + shadowFormatter func(ShadowEvent) string + shadowMask func(string) string } // Arg appends arguments to the command. @@ -81,10 +86,10 @@ type Cmd struct { // // Example: add args // -// cmd := execx.Command("go", "env").Arg("GOOS") +// cmd := execx.Command("printf").Arg("hello") // out, _ := cmd.Output() -// fmt.Println(out != "") -// // #bool true +// fmt.Print(out) +// // hello func (c *Cmd) Arg(values ...any) *Cmd { for _, value := range values { switch v := value.(type) { @@ -338,10 +343,10 @@ func (c *Cmd) StdinFile(file *os.File) *Cmd { // // Example: stdout lines // -// _, _ = execx.Command("go", "env", "GOOS"). +// _, _ = execx.Command("printf", "hi\n"). // OnStdout(func(line string) { fmt.Println(line) }). // Run() -// // darwin +// // hi func (c *Cmd) OnStdout(fn func(string)) *Cmd { c.onStdout = fn return c @@ -361,7 +366,7 @@ func (c *Cmd) OnStdout(fn func(string)) *Cmd { // // flag provided but not defined: -badflag // // usage: go env [-json] [-changed] [-u] [-w] [var ...] // // Run 'go help env' for details. -// // true +// // false func (c *Cmd) OnStderr(fn func(string)) *Cmd { c.onStderr = fn return c @@ -373,11 +378,11 @@ func (c *Cmd) OnStderr(fn func(string)) *Cmd { // Example: stdout writer // // var out strings.Builder -// _, err := execx.Command("go", "env", "GOOS"). +// _, _ = execx.Command("printf", "hello"). // StdoutWriter(&out). // Run() -// fmt.Println(err == nil && out.Len() > 0) -// // #bool true +// fmt.Print(out.String()) +// // hello func (c *Cmd) StdoutWriter(w io.Writer) *Cmd { c.stdoutW = w return c @@ -397,7 +402,7 @@ func (c *Cmd) StdoutWriter(w io.Writer) *Cmd { // // flag provided but not defined: -badflag // // usage: go env [-json] [-changed] [-u] [-w] [var ...] // // Run 'go help env' for details. -// // true +// // false func (c *Cmd) StderrWriter(w io.Writer) *Cmd { c.stderrW = w return c @@ -451,12 +456,12 @@ func (c *Cmd) PipeStrict() *Cmd { // // Example: best effort // -// res, err := execx.Command("false"). +// res, _ := execx.Command("false"). // Pipe("printf", "ok"). // PipeBestEffort(). // Run() -// fmt.Println(err == nil && res.Stdout == "ok") -// // #bool true +// fmt.Print(res.Stdout) +// // ok func (c *Cmd) PipeBestEffort() *Cmd { c.rootCmd().pipeMode = pipeBestEffort return c @@ -527,6 +532,82 @@ func (c *Cmd) ShellEscaped() string { return strings.Join(parts, " ") } +// ShadowPrint writes the shell-escaped command to stderr before and after execution. +// @group User Feedback +// +// Example: shadow print +// +// _, _ = execx.Command("printf", "hi").ShadowPrint().Run() +// // execx > printf hi +// // execx > printf hi (1ms) +func (c *Cmd) ShadowPrint() *Cmd { + c.rootCmd().shadowPrint = true + return c +} + +// ShadowPrintOff disables shadow printing for this command chain. +// @group User Feedback +// +// Example: shadow print off +// +// _, _ = execx.Command("printf", "hi").ShadowPrint().ShadowPrintOff().Run() +func (c *Cmd) ShadowPrintOff() *Cmd { + root := c.rootCmd() + root.shadowPrint = false + return c +} + +// ShadowPrintPrefix sets the prefix used by ShadowPrint. +// @group User Feedback +// +// Example: shadow print prefix +// +// _, _ = execx.Command("printf", "hi").ShadowPrintPrefix("run").Run() +// // run > printf hi +// // run > printf hi (1ms) +func (c *Cmd) ShadowPrintPrefix(prefix string) *Cmd { + root := c.rootCmd() + root.shadowPrint = true + root.shadowPrefix = prefix + return c +} + +// ShadowPrintMask sets a command masker for ShadowPrint output. +// @group User Feedback +// +// Example: shadow print mask +// +// mask := func(cmd string) string { +// return strings.ReplaceAll(cmd, "secret", "***") +// } +// _, _ = execx.Command("printf", "secret").ShadowPrintMask(mask).Run() +// // execx > printf *** +// // execx > printf *** (1ms) +func (c *Cmd) ShadowPrintMask(fn func(string) string) *Cmd { + root := c.rootCmd() + root.shadowPrint = true + root.shadowMask = fn + return c +} + +// ShadowPrintFormatter sets a formatter for ShadowPrint output. +// @group User Feedback +// +// Example: shadow print formatter +// +// formatter := func(ev execx.ShadowEvent) string { +// return fmt.Sprintf("shadow: %s %s", ev.Phase, ev.Command) +// } +// _, _ = execx.Command("printf", "hi").ShadowPrintFormatter(formatter).Run() +// // shadow: before printf hi +// // shadow: after printf hi +func (c *Cmd) ShadowPrintFormatter(fn func(ShadowEvent) string) *Cmd { + root := c.rootCmd() + root.shadowPrint = true + root.shadowFormatter = fn + return c +} + // Run executes the command and returns the result and any error. // @group Execution // @@ -536,10 +617,12 @@ func (c *Cmd) ShellEscaped() string { // fmt.Println(res.ExitCode == 0) // // #bool true func (c *Cmd) Run() (Result, error) { + shadow := c.shadowPrintStart(false) pipe := c.newPipeline(false) pipe.start() pipe.wait() result, _ := pipe.primaryResult(c.rootCmd().pipeMode) + shadow.finish() return result, result.Err } @@ -548,9 +631,9 @@ func (c *Cmd) Run() (Result, error) { // // Example: output // -// out, _ := execx.Command("go", "env", "GOOS").Output() -// fmt.Println(out != "") -// // #bool true +// out, _ := execx.Command("printf", "hello").Output() +// fmt.Print(out) +// // hello func (c *Cmd) Output() (string, error) { result, err := c.Run() return result.Stdout, err @@ -561,9 +644,9 @@ func (c *Cmd) Output() (string, error) { // // Example: output bytes // -// out, _ := execx.Command("go", "env", "GOOS").OutputBytes() -// fmt.Println(len(out) > 0) -// // #bool true +// out, _ := execx.Command("printf", "hello").OutputBytes() +// fmt.Println(string(out)) +// // #string hello func (c *Cmd) OutputBytes() ([]byte, error) { result, err := c.Run() return []byte(result.Stdout), err @@ -574,9 +657,9 @@ func (c *Cmd) OutputBytes() ([]byte, error) { // // Example: output trimmed // -// out, _ := execx.Command("go", "env", "GOOS").OutputTrimmed() -// fmt.Println(out != "") -// // #bool true +// out, _ := execx.Command("printf", "hello\n").OutputTrimmed() +// fmt.Println(out) +// // #string hello func (c *Cmd) OutputTrimmed() (string, error) { result, err := c.Run() return strings.TrimSpace(result.Stdout), err @@ -587,14 +670,20 @@ func (c *Cmd) OutputTrimmed() (string, error) { // // Example: combined output // -// out, _ := execx.Command("go", "env", "GOOS").CombinedOutput() -// fmt.Println(out != "") -// // #bool true +// out, err := execx.Command("go", "env", "-badflag").CombinedOutput() +// fmt.Print(out) +// fmt.Println(err == nil) +// // flag provided but not defined: -badflag +// // usage: go env [-json] [-changed] [-u] [-w] [var ...] +// // Run 'go help env' for details. +// // false func (c *Cmd) CombinedOutput() (string, error) { + shadow := c.shadowPrintStart(false) pipe := c.newPipeline(true) pipe.start() pipe.wait() result, combined := pipe.primaryResult(c.rootCmd().pipeMode) + shadow.finish() return combined, result.Err } @@ -603,16 +692,21 @@ func (c *Cmd) CombinedOutput() (string, error) { // // Example: pipeline results // -// results, err := execx.Command("printf", "go"). +// results, _ := execx.Command("printf", "go"). // Pipe("tr", "a-z", "A-Z"). // PipelineResults() -// fmt.Println(err == nil && len(results) == 2) -// // #bool true +// fmt.Printf("%+v", results) +// // [ +// // {Stdout:go Stderr: ExitCode:0 Err: Duration:6.367208ms signal:} +// // {Stdout:GO Stderr: ExitCode:0 Err: Duration:4.976291ms signal:} +// // ] func (c *Cmd) PipelineResults() ([]Result, error) { + shadow := c.shadowPrintStart(false) pipe := c.newPipeline(false) pipe.start() pipe.wait() results := pipe.results() + shadow.finish() return results, firstResultErr(results) } @@ -626,6 +720,7 @@ func (c *Cmd) PipelineResults() ([]Result, error) { // fmt.Println(res.ExitCode == 0) // // #bool true func (c *Cmd) Start() *Process { + shadow := c.shadowPrintStart(true) pipe := c.newPipeline(false) pipe.start() @@ -633,6 +728,7 @@ func (c *Cmd) Start() *Process { pipeline: pipe, mode: c.rootCmd().pipeMode, done: make(chan struct{}), + shadow: shadow, } go func() { pipe.wait() @@ -769,6 +865,110 @@ func shellEscape(arg string) string { return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" } +type shadowContext struct { + cmd *Cmd + start time.Time + async bool +} + +// ShadowPhase describes whether the shadow print is before or after execution. +type ShadowPhase string + +const ( + ShadowBefore ShadowPhase = "before" + ShadowAfter ShadowPhase = "after" +) + +// ShadowEvent captures details for ShadowPrint formatting. +type ShadowEvent struct { + Command string + RawCommand string + Phase ShadowPhase + Duration time.Duration + Async bool +} + +func (c *Cmd) shadowPrintStart(async bool) *shadowContext { + root := c.rootCmd() + if root == nil || !root.shadowPrint { + return nil + } + ctx := &shadowContext{ + cmd: root, + start: time.Now(), + async: async, + } + shadowPrintLine(root, ShadowBefore, 0, async) + return ctx +} + +func (c *Cmd) shadowCommand() string { + root := c.rootCmd() + parts := []string{} + for stage := root; stage != nil; stage = stage.next { + parts = append(parts, stage.ShellEscaped()) + } + return strings.Join(parts, " | ") +} + +func (s *shadowContext) finish() { + if s == nil || s.cmd == nil { + return + } + duration := time.Since(s.start).Round(time.Millisecond) + shadowPrintLine(s.cmd, ShadowAfter, duration, s.async) +} + +func shadowPrintLine(cmd *Cmd, phase ShadowPhase, duration time.Duration, async bool) { + if cmd == nil { + return + } + rawCommand := cmd.shadowCommand() + commandLine := rawCommand + if cmd.shadowMask != nil { + commandLine = cmd.shadowMask(commandLine) + } + event := ShadowEvent{ + Command: commandLine, + RawCommand: rawCommand, + Phase: phase, + Duration: duration, + Async: async, + } + if cmd.shadowFormatter != nil { + line := cmd.shadowFormatter(event) + if line != "" { + fmt.Fprintln(os.Stderr, line) + } + return + } + prefix := cmd.shadowPrefix + if prefix == "" { + prefix = "execx" + } + timing := "" + if phase == ShadowAfter { + timing = " (" + duration.String() + if async { + timing += ", async" + } + timing += ")" + } else if async { + timing = " (async)" + } + fmt.Fprintf( + os.Stderr, + "%s%s > %s%s%s%s%s\n", + shadowBoldHighIntensityBlack, + prefix, + shadowReset, + shadowHighIntensityBlack, + commandLine, + shadowFadedGray, + timing+shadowReset, + ) +} + // Process represents an asynchronously running command. type Process struct { pipeline *pipeline @@ -776,6 +976,8 @@ type Process struct { done chan struct{} result Result + shadow *shadowContext + resultOnce sync.Once mu sync.Mutex killTimer *time.Timer @@ -788,8 +990,9 @@ type Process struct { // // proc := execx.Command("go", "env", "GOOS").Start() // res, _ := proc.Wait() -// fmt.Println(res.ExitCode == 0) -// // #bool true +// fmt.Printf("%+v", res) +// // {Stdout:darwin +// // Stderr: ExitCode:0 Err: Duration:1.234ms signal:} func (p *Process) Wait() (Result, error) { <-p.done return p.result, p.result.Err @@ -802,9 +1005,9 @@ func (p *Process) Wait() (Result, error) { // // proc := execx.Command("sleep", "2").Start() // proc.KillAfter(100 * time.Millisecond) -// res, err := proc.Wait() -// fmt.Println(err != nil || res.ExitCode != 0) -// // #bool true +// res, _ := proc.Wait() +// fmt.Printf("%+v", res) +// // {Stdout: Stderr: ExitCode:-1 Err: Duration:100.456ms signal:killed} func (p *Process) KillAfter(d time.Duration) { p.mu.Lock() if p.killTimer != nil { @@ -823,9 +1026,9 @@ func (p *Process) KillAfter(d time.Duration) { // // proc := execx.Command("sleep", "2").Start() // _ = proc.Send(os.Interrupt) -// res, err := proc.Wait() -// fmt.Println(err != nil || res.ExitCode != 0) -// // #bool true +// res, _ := proc.Wait() +// fmt.Printf("%+v", res) +// // {Stdout: Stderr: ExitCode:-1 Err: Duration:80.123ms signal:interrupt} func (p *Process) Send(sig os.Signal) error { return p.signalAll(func(proc *os.Process) error { return proc.Signal(sig) @@ -839,9 +1042,9 @@ func (p *Process) Send(sig os.Signal) error { // // proc := execx.Command("sleep", "2").Start() // _ = proc.Interrupt() -// res, err := proc.Wait() -// fmt.Println(err != nil || res.ExitCode != 0) -// // #bool true +// res, _ := proc.Wait() +// fmt.Printf("%+v", res) +// // {Stdout: Stderr: ExitCode:-1 Err: Duration:75.987ms signal:interrupt} func (p *Process) Interrupt() error { return p.Send(os.Interrupt) } @@ -853,9 +1056,9 @@ func (p *Process) Interrupt() error { // // proc := execx.Command("sleep", "2").Start() // _ = proc.Terminate() -// res, err := proc.Wait() -// fmt.Println(err != nil || res.ExitCode != 0) -// // #bool true +// res, _ := proc.Wait() +// fmt.Printf("%+v", res) +// // {Stdout: Stderr: ExitCode:-1 Err: Duration:70.654ms signal:killed} func (p *Process) Terminate() error { return p.signalAll(func(proc *os.Process) error { return proc.Kill() @@ -869,8 +1072,8 @@ func (p *Process) Terminate() error { // // proc := execx.Command("sleep", "2").Start() // _ = proc.GracefulShutdown(os.Interrupt, 100*time.Millisecond) -// res, err := proc.Wait() -// fmt.Println(err != nil || res.ExitCode != 0) +// res, _ := proc.Wait() +// fmt.Println(res.IsSignal(os.Interrupt)) // // #bool true func (p *Process) GracefulShutdown(sig os.Signal, timeout time.Duration) error { if timeout <= 0 { @@ -892,6 +1095,7 @@ func (p *Process) GracefulShutdown(sig os.Signal, timeout time.Duration) error { func (p *Process) finish(result Result) { p.resultOnce.Do(func() { p.result = result + p.shadow.finish() close(p.done) }) } diff --git a/execx_shadow_test.go b/execx_shadow_test.go new file mode 100644 index 0000000..c2fffd4 --- /dev/null +++ b/execx_shadow_test.go @@ -0,0 +1,143 @@ +package execx + +import ( + "bytes" + "io" + "os" + "regexp" + "strings" + "sync" + "testing" +) + +var stderrMu sync.Mutex + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + + stderrMu.Lock() + defer stderrMu.Unlock() + + orig := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stderr = w + + fn() + + _ = w.Close() + os.Stderr = orig + + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + _ = r.Close() + + return buf.String() +} + +func stripANSI(s string) string { + re := regexp.MustCompile(`\x1b\[[0-9;]*m`) + return re.ReplaceAllString(s, "") +} + +func TestShadowPrintDefault(t *testing.T) { + out := captureStderr(t, func() { + _, _ = Command("printf", "hi").ShadowPrint().Run() + }) + plain := stripANSI(out) + if !strings.Contains(plain, "execx > printf hi") { + t.Fatalf("expected shadow print, got %q", plain) + } + if !strings.Contains(plain, "execx > printf hi (") { + t.Fatalf("expected duration line, got %q", plain) + } +} + +func TestShadowPrintPrefix(t *testing.T) { + out := captureStderr(t, func() { + _, _ = Command("printf", "hi").ShadowPrintPrefix("run").Run() + }) + plain := stripANSI(out) + if !strings.Contains(plain, "run > printf hi") { + t.Fatalf("expected prefix, got %q", plain) + } +} + +func TestShadowPrintOff(t *testing.T) { + out := captureStderr(t, func() { + _, _ = Command("printf", "hi").ShadowPrint().ShadowPrintOff().Run() + }) + if strings.TrimSpace(out) != "" { + t.Fatalf("expected no output, got %q", out) + } +} + +func TestShadowPrintMask(t *testing.T) { + out := captureStderr(t, func() { + mask := func(cmd string) string { + return strings.ReplaceAll(cmd, "secret", "***") + } + _, _ = Command("printf", "secret").ShadowPrintMask(mask).Run() + }) + plain := stripANSI(out) + if !strings.Contains(plain, "printf ***") { + t.Fatalf("expected masked output, got %q", plain) + } +} + +func TestShadowPrintFormatter(t *testing.T) { + out := captureStderr(t, func() { + formatter := func(ev ShadowEvent) string { + return "shadow:" + string(ev.Phase) + ":" + ev.RawCommand + } + _, _ = Command("printf", "hi").ShadowPrintFormatter(formatter).Run() + }) + lines := strings.FieldsFunc(strings.TrimSpace(out), func(r rune) bool { + return r == '\n' || r == '\r' + }) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), out) + } + if !strings.HasPrefix(lines[0], "shadow:before:printf hi") { + t.Fatalf("unexpected before line: %q", lines[0]) + } + if !strings.HasPrefix(lines[1], "shadow:after:printf hi") { + t.Fatalf("unexpected after line: %q", lines[1]) + } +} + +func TestShadowPrintFormatterEmpty(t *testing.T) { + out := captureStderr(t, func() { + formatter := func(ev ShadowEvent) string { + return "" + } + _, _ = Command("printf", "hi").ShadowPrintFormatter(formatter).Run() + }) + if strings.TrimSpace(out) != "" { + t.Fatalf("expected no output, got %q", out) + } +} + +func TestShadowCommandPipeline(t *testing.T) { + cmd := Command("printf", "go").Pipe("tr", "a-z", "A-Z") + if got := cmd.shadowCommand(); got != "printf go | tr a-z A-Z" { + t.Fatalf("unexpected shadow command: %q", got) + } +} + +func TestShadowPrintAsync(t *testing.T) { + out := captureStderr(t, func() { + proc := Command("sleep", "0.01").ShadowPrint().Start() + _, _ = proc.Wait() + }) + plain := stripANSI(out) + if !strings.Contains(plain, "(async)") { + t.Fatalf("expected async marker, got %q", plain) + } +} + +func TestShadowPrintLineNil(t *testing.T) { + shadowPrintLine(nil, ShadowBefore, 0, false) +} diff --git a/shadow_colors.go b/shadow_colors.go new file mode 100644 index 0000000..c8a5e85 --- /dev/null +++ b/shadow_colors.go @@ -0,0 +1,8 @@ +package execx + +const ( + shadowBoldHighIntensityBlack = "\033[1;90m" + shadowHighIntensityBlack = "\033[90m" + shadowFadedGray = "\u001B[38;5;236m" + shadowReset = "\033[0m" +) diff --git a/sysproc_linux.go b/sysproc_linux.go index 3fe434f..9b2ca29 100644 --- a/sysproc_linux.go +++ b/sysproc_linux.go @@ -4,39 +4,42 @@ package execx import "syscall" -// Setpgid sets the process group ID behavior. +// Setpgid places the child in a new process group for group signals. // @group OS Controls // // Example: setpgid // -// fmt.Println(execx.Command("go", "env", "GOOS").Setpgid(true) != nil) -// // #bool true +// out, _ := execx.Command("printf", "ok").Setpgid(true).Output() +// fmt.Print(out) +// // ok func (c *Cmd) Setpgid(on bool) *Cmd { c.ensureSysProcAttr() c.sysProcAttr.Setpgid = on return c } -// Setsid sets the session ID behavior. +// Setsid starts the child in a new session, detaching it from the terminal. // @group OS Controls // // Example: setsid // -// fmt.Println(execx.Command("go", "env", "GOOS").Setsid(true) != nil) -// // #bool true +// out, _ := execx.Command("printf", "ok").Setsid(true).Output() +// fmt.Print(out) +// // ok func (c *Cmd) Setsid(on bool) *Cmd { c.ensureSysProcAttr() c.sysProcAttr.Setsid = on return c } -// Pdeathsig sets a parent-death signal on Linux. +// Pdeathsig sets a parent-death signal on Linux so the child is signaled if the parent exits. // @group OS Controls // // Example: pdeathsig // -// fmt.Println(execx.Command("go", "env", "GOOS").Pdeathsig(0) != nil) -// // #bool true +// out, _ := execx.Command("printf", "ok").Pdeathsig(syscall.SIGTERM).Output() +// fmt.Print(out) +// // ok func (c *Cmd) Pdeathsig(sig syscall.Signal) *Cmd { c.ensureSysProcAttr() c.sysProcAttr.Pdeathsig = sig diff --git a/sysproc_nonwindows.go b/sysproc_nonwindows.go index dc0f306..1391b59 100644 --- a/sysproc_nonwindows.go +++ b/sysproc_nonwindows.go @@ -2,24 +2,14 @@ package execx -// CreationFlags is a no-op on non-Windows platforms. +// CreationFlags is a no-op on non-Windows platforms; on Windows it sets process creation flags. // @group OS Controls -// -// Example: creation flags -// -// fmt.Println(execx.Command("go", "env", "GOOS").CreationFlags(0) != nil) -// // #bool true func (c *Cmd) CreationFlags(_ uint32) *Cmd { return c } -// HideWindow is a no-op on non-Windows platforms. +// HideWindow is a no-op on non-Windows platforms; on Windows it hides console windows. // @group OS Controls -// -// Example: hide window -// -// fmt.Println(execx.Command("go", "env", "GOOS").HideWindow(true) != nil) -// // #bool true func (c *Cmd) HideWindow(_ bool) *Cmd { return c } diff --git a/sysproc_unix.go b/sysproc_unix.go index 087c38b..bb45185 100644 --- a/sysproc_unix.go +++ b/sysproc_unix.go @@ -4,39 +4,24 @@ package execx import "syscall" -// Setpgid sets the process group ID behavior. +// Setpgid places the child in a new process group for group signals. // @group OS Controls -// -// Example: setpgid -// -// fmt.Println(execx.Command("go", "env", "GOOS").Setpgid(true) != nil) -// // #bool true func (c *Cmd) Setpgid(on bool) *Cmd { c.ensureSysProcAttr() c.sysProcAttr.Setpgid = on return c } -// Setsid sets the session ID behavior. +// Setsid starts the child in a new session, detaching it from the terminal. // @group OS Controls -// -// Example: setsid -// -// fmt.Println(execx.Command("go", "env", "GOOS").Setsid(true) != nil) -// // #bool true func (c *Cmd) Setsid(on bool) *Cmd { c.ensureSysProcAttr() c.sysProcAttr.Setsid = on return c } -// Pdeathsig is a no-op on non-Linux Unix platforms. +// Pdeathsig is a no-op on non-Linux Unix platforms; on Linux it signals the child when the parent exits. // @group OS Controls -// -// Example: pdeathsig -// -// fmt.Println(execx.Command("go", "env", "GOOS").Pdeathsig(0) != nil) -// // #bool true func (c *Cmd) Pdeathsig(_ syscall.Signal) *Cmd { return c } diff --git a/sysproc_windows.go b/sysproc_windows.go index c84abb3..e6d999b 100644 --- a/sysproc_windows.go +++ b/sysproc_windows.go @@ -4,59 +4,48 @@ package execx import "syscall" -// Setpgid is a no-op on Windows. +// Setpgid is a no-op on Windows; on Unix it places the child in a new process group. // @group OS Controls -// -// Example: setpgid -// -// fmt.Println(execx.Command("go", "env", "GOOS").Setpgid(true) != nil) -// // #bool true func (c *Cmd) Setpgid(_ bool) *Cmd { return c } -// Setsid is a no-op on Windows. +// Setsid is a no-op on Windows; on Unix it starts a new session. // @group OS Controls -// -// Example: setsid -// -// fmt.Println(execx.Command("go", "env", "GOOS").Setsid(true) != nil) -// // #bool true func (c *Cmd) Setsid(_ bool) *Cmd { return c } -// Pdeathsig is a no-op on Windows. +// Pdeathsig is a no-op on Windows; on Linux it signals the child when the parent exits. // @group OS Controls -// -// Example: pdeathsig -// -// fmt.Println(execx.Command("go", "env", "GOOS").Pdeathsig(0) != nil) -// // #bool true func (c *Cmd) Pdeathsig(_ syscall.Signal) *Cmd { return c } -// CreationFlags sets Windows creation flags. +// CreationFlags sets Windows process creation flags (for example, create a new process group). // @group OS Controls // +// Common flags: execx.CreateNewProcessGroup, execx.CreateNewConsole, execx.CreateNoWindow. +// // Example: creation flags // -// fmt.Println(execx.Command("go", "env", "GOOS").CreationFlags(0) != nil) -// // #bool true +// out, _ := execx.Command("printf", "ok").CreationFlags(execx.CreateNewProcessGroup).Output() +// fmt.Print(out) +// // ok func (c *Cmd) CreationFlags(flags uint32) *Cmd { c.ensureSysProcAttr() c.sysProcAttr.CreationFlags = flags return c } -// HideWindow controls window visibility and sets CREATE_NO_WINDOW for console apps. +// HideWindow hides console windows and sets CREATE_NO_WINDOW for console apps. // @group OS Controls // // Example: hide window // -// fmt.Println(execx.Command("go", "env", "GOOS").HideWindow(true) != nil) -// // #bool true +// out, _ := execx.Command("printf", "ok").HideWindow(true).Output() +// fmt.Print(out) +// // ok func (c *Cmd) HideWindow(on bool) *Cmd { c.ensureSysProcAttr() c.sysProcAttr.HideWindow = on