Skip to content
Draft
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
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,71 @@
# shx
A shell-like command runner for go, compatible with mage.

A context-aware shell helper package for Go, compatible with [mage](https://magefile.org).

`shx` is a fork of [`github.com/magefile/mage/sh`](https://pkg.go.dev/github.com/magefile/mage/sh) that adds a `context.Context` parameter to every exported function that runs a command. The context is passed through to `exec.CommandContext`, giving callers control over timeouts, cancellation, and deadlines.

## Installation

```bash
go get github.com/magefile/shx
```

## Usage

```go
package main

import (
"context"
"log"
"time"

"github.com/magefile/shx"
)

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

// Run a command (output goes to stdout when MAGEFILE_VERBOSE=1)
if err := shx.Run(ctx, "go", "build", "./..."); err != nil {
log.Fatal(err)
}

// Capture output
out, err := shx.Output(ctx, "git", "rev-parse", "HEAD")
if err != nil {
log.Fatal(err)
}
log.Println("HEAD:", out)

// Create reusable command aliases
goTest := shx.RunCmd("go", "test")
if err := goTest(ctx, "-v", "./..."); err != nil {
log.Fatal(err)
}
}
```

## API

All command-running functions from `mage/sh` are available with the same names. The only difference is that each accepts a `context.Context` as its first parameter:

| Function | Description |
|---|---|
| `Run(ctx, cmd, args...)` | Run a command |
| `RunV(ctx, cmd, args...)` | Run a command, always printing stdout |
| `RunWith(ctx, env, cmd, args...)` | Run with extra environment variables |
| `RunWithV(ctx, env, cmd, args...)` | RunWith, always printing stdout |
| `Output(ctx, cmd, args...)` | Run and capture stdout |
| `OutputWith(ctx, env, cmd, args...)` | Output with extra environment variables |
| `Exec(ctx, env, stdout, stderr, cmd, args...)` | Full control over I/O |
| `RunCmd(cmd, args...)` | Returns a reusable `func(ctx, args...) error` |
| `OutCmd(cmd, args...)` | Returns a reusable `func(ctx, args...) (string, error)` |

File helpers that don't execute commands are unchanged:

| Function | Description |
|---|---|
| `Copy(dst, src)` | Copy a file |
| `Rm(path)` | Remove a file or directory |
219 changes: 219 additions & 0 deletions cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Package shx provides helpers for running shell commands with context support.
//
// It is a context-aware fork of github.com/magefile/mage/sh. Every exported
// function that executes a command accepts a [context.Context] as its first
// parameter and passes it through to [exec.CommandContext].
package shx

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"strconv"
"strings"
)

// VerboseEnv is the environment variable that indicates the user requested
// verbose mode when running a magefile.
const VerboseEnv = "MAGEFILE_VERBOSE"

func verbose() bool {
b, _ := strconv.ParseBool(os.Getenv(VerboseEnv))
return b
}

// RunCmd returns a function that will call Run with the given command. This is
// useful for creating command aliases to make your scripts easier to read, like
// this:
//
// // in a helper file somewhere
// var g0 = shx.RunCmd("go") // go is a keyword :(
//
// // somewhere in your main code
// if err := g0(ctx, "install", "github.com/gohugo/hugo"); err != nil {
// return err
// }
//
// Args passed to command get baked in as args to the command when you run it.
// Any args passed in when you run the returned function will be appended to the
// original args. For example, this is equivalent to the above:
//
// var goInstall = shx.RunCmd("go", "install")
// goInstall(ctx, "github.com/gohugo/hugo")
//
// RunCmd uses Exec underneath, so see those docs for more details.
func RunCmd(cmd string, args ...string) func(ctx context.Context, args ...string) error {
return func(ctx context.Context, args2 ...string) error {
return Run(ctx, cmd, append(args, args2...)...)
}
}

// OutCmd is like RunCmd except the command returns the output of the
// command.
func OutCmd(cmd string, args ...string) func(ctx context.Context, args ...string) (string, error) {
return func(ctx context.Context, args2 ...string) (string, error) {
return Output(ctx, cmd, append(args, args2...)...)
}
}

// Run is like RunWith, but doesn't specify any environment variables.
func Run(ctx context.Context, cmd string, args ...string) error {
return RunWith(ctx, nil, cmd, args...)
}

// RunV is like Run, but always sends the command's stdout to os.Stdout.
func RunV(ctx context.Context, cmd string, args ...string) error {
_, err := Exec(ctx, nil, os.Stdout, os.Stderr, cmd, args...)
return err
}

// RunWith runs the given command, directing stderr to this program's stderr and
// printing stdout to os.Stdout if MAGEFILE_VERBOSE is set. It adds env to the
// environment variables for the command being run. Environment variables should
// be in the format name=value.
func RunWith(ctx context.Context, env map[string]string, cmd string, args ...string) error {
var output io.Writer
if verbose() {
output = os.Stdout
}
_, err := Exec(ctx, env, output, os.Stderr, cmd, args...)
return err
}

// RunWithV is like RunWith, but always sends the command's stdout to os.Stdout.
func RunWithV(ctx context.Context, env map[string]string, cmd string, args ...string) error {
_, err := Exec(ctx, env, os.Stdout, os.Stderr, cmd, args...)
return err
}

// Output runs the command and returns the text from stdout.
func Output(ctx context.Context, cmd string, args ...string) (string, error) {
buf := &bytes.Buffer{}
_, err := Exec(ctx, nil, buf, os.Stderr, cmd, args...)
return strings.TrimSuffix(buf.String(), "\n"), err
}

// OutputWith is like RunWith, but returns what is written to stdout.
func OutputWith(ctx context.Context, env map[string]string, cmd string, args ...string) (string, error) {
buf := &bytes.Buffer{}
_, err := Exec(ctx, env, buf, os.Stderr, cmd, args...)
return strings.TrimSuffix(buf.String(), "\n"), err
}

// Exec executes the command, piping its stdout and stderr to the given
// writers. If the command fails, it will return an error that contains the
// exit code of the failed command. Env is a list of environment variables to set when
// running the command, these override the current environment variables set
// (which are also passed to the command). cmd and args may include references
// to environment variables in $FOO format, in which case these will be
// expanded before the command is run.
//
// Ran reports if the command ran (rather than was not found or not executable).
// Code reports the exit code the command returned if it ran. If err == nil, ran
// is always true and code is always 0.
func Exec(ctx context.Context, env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, err error) {
expand := func(s string) string {
s2, ok := env[s]
if ok {
return s2
}
return os.Getenv(s)
}
cmd = os.Expand(cmd, expand)
for i := range args {
args[i] = os.Expand(args[i], expand)
}
ran, code, err := doRun(ctx, env, stdout, stderr, cmd, args...)
if err == nil {
return true, nil
}
if ran {
return ran, fatalf(code, `running "%s %s" failed with exit code %d`, cmd, strings.Join(args, " "), code)
}
return ran, fmt.Errorf(`failed to run "%s %s: %w"`, cmd, strings.Join(args, " "), err)
}

func doRun(ctx context.Context, env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, code int, err error) {
c := exec.CommandContext(ctx, cmd, args...)
c.Env = os.Environ()
for k, v := range env {
c.Env = append(c.Env, k+"="+v)
}
c.Stderr = stderr
c.Stdout = stdout
c.Stdin = os.Stdin

var quoted []string
for i := range args {
quoted = append(quoted, fmt.Sprintf("%q", args[i]))
}
// To protect against logging from doing exec in global variables
if verbose() {
log.Println("exec:", cmd, strings.Join(quoted, " "))
}
err = c.Run()
return CmdRan(err), ExitStatus(err), err
}

// CmdRan examines the error to determine if it was generated as a result of a
// command running via os/exec.Command. If the error is nil, or the command ran
// (even if it exited with a non-zero exit code), CmdRan reports true. If the
// error is an unrecognized type, or it is an error from exec.Command that says
// the command failed to run (usually due to the command not existing or not
// being executable), it reports false.
func CmdRan(err error) bool {
if err == nil {
return true
}
var ee *exec.ExitError
if errors.As(err, &ee) {
return ee.Exited()
}
return false
}

type exitStatus interface {
ExitStatus() int
}

// ExitStatus returns the exit status of the error if it is an exec.ExitError
// or if it implements ExitStatus() int.
// 0 if it is nil or 1 if it is a different error.
func ExitStatus(err error) int {
if err == nil {
return 0
}
if e, ok := err.(exitStatus); ok {
return e.ExitStatus()
}
var e *exec.ExitError
if errors.As(err, &e) {
if ex, ok := e.Sys().(exitStatus); ok {
return ex.ExitStatus()
}
}
return 1
}

// fatalError is an error with an associated exit code, compatible with
// mage's mg.Fatalf return type.
type fatalError struct {
error
code int
}

func (f fatalError) ExitStatus() int {
return f.code
}

func fatalf(code int, format string, args ...interface{}) error {
return fatalError{
code: code,
error: fmt.Errorf(format, args...),
}
}
Loading