commander

package module
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: MIT Imports: 16 Imported by: 1

README

Commander

Go Reference CI coverage version license imported by

Go's os/exec made me want to shart on my laptop's screen. Every time I had to wire up pipes, babysit goroutines, and pray that cmd.Wait() wouldn't give me digital hemorrhoids, I died a little inside. So I built this instead.

Commander wraps os/exec into something that doesn't make you want to fucking quit programming — proper process lifecycle management, real-time output streaming, graceful termination, and a mock system for testing. No more hanging processes, no more race conditions, no more timeout bullshit that makes you question your career choices.

Install

go get github.com/psyb0t/commander

Quick Start

cmd := commander.New()
ctx := context.Background()

// Run and forget
err := cmd.Run(ctx, "echo", []string{"hello world"})

// Get output like a civilized person
stdout, stderr, err := cmd.Output(
    ctx, "ls", []string{"-la", "/tmp"},
)

// Combined stdout+stderr when you don't give a shit
output, err := cmd.CombinedOutput(
    ctx, "git", []string{"status"},
)

That's it. No pipe juggling, no goroutine babysitting, no 47 lines of boilerplate just to run a fucking command and get its output.

Interfaces

Commander
type Commander interface {
    Run(
        ctx context.Context,
        name string,
        args []string,
        opts ...Option,
    ) error

    Output(
        ctx context.Context,
        name string,
        args []string,
        opts ...Option,
    ) (stdout []byte, stderr []byte, err error)

    CombinedOutput(
        ctx context.Context,
        name string,
        args []string,
        opts ...Option,
    ) (output []byte, err error)

    Start(
        ctx context.Context,
        name string,
        args []string,
        opts ...Option,
    ) (Process, error)
}
Process
type Process interface {
    Start() error
    Wait() error
    StdinPipe() (io.WriteCloser, error)
    Stream(stdout, stderr chan<- string)
    Stop(ctx context.Context) error
    Kill(ctx context.Context) error
    PID() int
}
Options
// feed input to the command
commander.WithStdin(reader)

// set environment variables
commander.WithEnv([]string{})

// set working directory
commander.WithDir("/path")

// subscribe stdout/stderr channels before the process starts (Start only)
commander.WithStream(stdout, stderr)

Real-time Streaming

Stream stdout and stderr as they come in, not after the process is done like some caveman asshole reading yesterday's newspaper:

proc, err := cmd.Start(
    ctx, "ping", []string{"-c", "10", "google.com"},
)
if err != nil {
    log.Fatal(err)
}

stdout := make(chan string, 100)
stderr := make(chan string, 100)
proc.Stream(stdout, stderr)

go func() {
    for line := range stdout {
        fmt.Printf("[OUT] %s\n", line)
    }
}()

go func() {
    for line := range stderr {
        fmt.Printf("[ERR] %s\n", line)
    }
}()

err = proc.Wait()

Multiple listeners can subscribe to the same process — each call to Stream() adds a new broadcast subscriber. Pass nil for channels you don't care about.

Stream() only sees lines read after you call it, and the process is already running by then. A fast command can print and exit first. To get every line, pass the channels to Start with WithStream, which subscribes before the process launches:

stdout := make(chan string, 100)

proc, err := cmd.Start(
    ctx, "sh", []string{"-c", "echo hello"},
    commander.WithStream(stdout, nil),
)
if err != nil {
    log.Fatal(err)
}

go func() { _ = proc.Wait() }()

for line := range stdout {
    fmt.Println(line) // hello
}

The channels are closed when the process is done. Wait() returns after the output is fully delivered, but waits at most 2 seconds for it. A grandchild that inherited the pipes and outlives the process can otherwise hold them open forever.

Process Control

Graceful Stop

Stop sends SIGTERM first, then SIGKILL if the stubborn bastard doesn't exit within the context deadline. No more zombie processes haunting your system like the ghosts of shitty code past.

proc, _ := cmd.Start(
    ctx, "tail", []string{"-f", "/var/log/syslog"},
)

// 5 second grace period before we get violent
stopCtx, cancel := context.WithTimeout(
    ctx, 5*time.Second,
)
defer cancel()

err := proc.Stop(stopCtx)

If the context has no deadline, a default 3-second timeout is used before SIGKILL.

Child processes get killed too — the whole process group gets the signal, so nothing escapes. No more orphaned shit lurking in your process table.

Immediate Kill

When you're done being polite and just want the fucker dead:

err := proc.Kill(ctx) // straight to SIGKILL, no negotiation

Timeouts

Context controls everything. No redundant timeout parameters, no bullshit — just standard Go context patterns:

ctx, cancel := context.WithTimeout(
    context.Background(), 2*time.Second,
)
defer cancel()

err := cmd.Run(ctx, "sleep", []string{"10"})
if errors.Is(err, commonerrors.ErrTimeout) {
    fmt.Println("timed out, as expected")
}

Error Handling

You get specific error types so you know exactly what went wrong instead of parsing error strings like a fucking animal:

import commonerrors "github.com/psyb0t/common-go/errors"

// context deadline exceeded
errors.Is(err, commonerrors.ErrTimeout)

// killed by SIGTERM
errors.Is(err, commonerrors.ErrTerminated)

// killed by SIGKILL
errors.Is(err, commonerrors.ErrKilled)

// non-zero exit code (includes stderr + exit code)
errors.Is(err, commonerrors.ErrFailed)

Testing with Mocks

The whole thing is designed to be testable. The Commander interface means you can mock everything without actually spawning processes in your tests like some kind of hemorrhoid-inducing integration test nightmare.

Basic
func TestDeploy(t *testing.T) {
    mock := commander.NewMock()

    mock.Expect("git", "status").
        ReturnOutput([]byte("On branch main"))
    mock.Expect("git", "push").
        ReturnError(errors.New("push failed"))

    err := deploy(mock)
    assert.Error(t, err)
    require.NoError(t, mock.VerifyExpectations())
}
Argument Matchers

For when you don't want to match every argument exactly because you're not a fucking psychopath:

mock.ExpectWithMatchers(
    "grep",
    commander.Regex("^error.*"),
    commander.Exact("logfile.txt"),
)

mock.ExpectWithMatchers(
    "find",
    commander.Any(),
    commander.Any(),
)
Process Mocking

Mock streaming processes too — the mock will feed lines through the stream channels just like a real process would:

mock.Expect("tail", "-f", "/var/log/messages").
    ReturnOutput([]byte("line 1\nline 2\nline 3"))

proc, err := mock.Start(
    ctx,
    "tail",
    []string{"-f", "/var/log/messages"},
)
require.NoError(t, err)

stdout := make(chan string, 10)
proc.Stream(stdout, nil)

var lines []string
for line := range stdout {
    lines = append(lines, line)
}
// lines == ["line 1", "line 2", "line 3"]
Mock Utilities
// ordered list of commands that were called
mock.CallOrder()

// clears all expectations and history
mock.Reset()

// fails if expected commands weren't called
mock.VerifyExpectations()

Thread Safety

Everything is safe for concurrent use. Use the same Commander from multiple goroutines, run commands concurrently, attach multiple stream subscribers per process, use mocks in parallel tests. It all works because the internals use proper synchronization instead of the stdlib's "fuck you, figure it out yourself" approach.

Why This Exists

Because os/exec is a hemorrhoid factory. Look at this shit:

Before (stdlib os/exec)
cmd := exec.CommandContext(
    ctx, "some-command", "arg1", "arg2",
)
stdout, err := cmd.StdoutPipe()
if err != nil { /* ... */ }
stderr, err := cmd.StderrPipe()
if err != nil { /* ... */ }
err = cmd.Start()
if err != nil { /* ... */ }
// now read from pipes in goroutines
// handle timeouts manually
// figure out why your process is hanging
// write your own mocks
// question your life choices
// cry
After (Commander)
cmd := commander.New()
stdout, stderr, err := cmd.Output(
    ctx,
    "some-command",
    []string{"arg1", "arg2"},
)
// done. go get a fucking coffee.

Dependencies

  • log/slog — debug logging (configure however you want)
  • github.com/psyb0t/ctxerrors — error wrapping with context
  • github.com/psyb0t/common-go — common error types

Development

Docker and Make are the only host requirements. Every target runs its Go commands in a container built from Dockerfile.dev, as your user, with the repo mounted at /work. Nothing is installed on the host. The library needs Go 1.26.9 or newer.

  • make help lists every target.
  • make dev-image builds the development image. The other targets build it when needed.
  • make dep tidies and vendors dependencies.
  • make lint, make lint-fix and make format check, fix and format the Go code.
  • make test runs the tests with the race detector.
  • make test-coverage runs them with a coverage floor of 85% (MIN_TEST_COVERAGE) and writes coverage-percent.txt.
  • make sec runs govulncheck and semgrep and writes sec.sarif.
  • make all runs dep, lint-fix and test-coverage.

The containers use all host CPUs except two. Override with DEV_CPUS=4, change the reserve with DEV_CPUS_RESERVED, or pass extra docker run flags with DEV_RUN_EXTRA_ARGS.

License

MIT — do whatever you want with it.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrUnexpectedCommand        = errors.New("unexpected command")
	ErrExpectedCommandNotCalled = errors.New("expected command not called")
)

Execution errors

View Source
var (
	ErrProcessStartFailed = errors.New("process start failed")
	ErrProcessWaitFailed  = errors.New("process wait failed")
	ErrPipeCreationFailed = errors.New("pipe creation failed")
)

Process errors

View Source
var (
	ErrCommandFailed = errors.New("command failed")
)

Command execution errors

Functions

This section is empty.

Types

type AnyMatcher

type AnyMatcher struct{}

Any matcher

func (*AnyMatcher) Matches

func (m *AnyMatcher) Matches(_ string) bool

func (*AnyMatcher) String

func (m *AnyMatcher) String() string

type ArgumentMatcher

type ArgumentMatcher interface {
	Matches(arg string) bool
	String() string
}

ArgumentMatcher interface for flexible argument matching

func Any

func Any() ArgumentMatcher

func Exact

func Exact(s string) ArgumentMatcher

Helper functions for creating matchers

func Regex

func Regex(pattern string) ArgumentMatcher

type Commander

type Commander interface {
	// Run executes a command and waits for completion
	Run(
		ctx context.Context,
		name string,
		args []string,
		opts ...Option,
	) error

	// Output executes a command and returns stdout, stderr, and error
	Output(
		ctx context.Context,
		name string,
		args []string,
		opts ...Option,
	) (stdout []byte, stderr []byte, err error)

	// CombinedOutput executes a command and returns combined stdout+stderr and error
	CombinedOutput(
		ctx context.Context,
		name string,
		args []string,
		opts ...Option,
	) (output []byte, err error)

	// Start creates a command that can be controlled manually
	Start(
		ctx context.Context,
		name string,
		args []string,
		opts ...Option,
	) (Process, error)
}

func New

func New() Commander

type ExactMatcher

type ExactMatcher struct {
	// contains filtered or unexported fields
}

Exact matcher

func (*ExactMatcher) Matches

func (m *ExactMatcher) Matches(arg string) bool

func (*ExactMatcher) String

func (m *ExactMatcher) String() string

type Expectation

type Expectation struct {
	Name     string
	Args     []string
	Matchers []ArgumentMatcher
	Output   []byte
	Error    error
	Called   bool
}

func (*Expectation) ReturnError

func (e *Expectation) ReturnError(err error) *Expectation

func (*Expectation) ReturnOutput

func (e *Expectation) ReturnOutput(output []byte) *Expectation

Expectation methods

type MockCommander

type MockCommander struct {
	// contains filtered or unexported fields
}

MockCommander for testing

func NewMock

func NewMock() *MockCommander

func (*MockCommander) CallOrder

func (m *MockCommander) CallOrder() []string

func (*MockCommander) CombinedOutput

func (m *MockCommander) CombinedOutput(
	_ context.Context,
	name string,
	args []string,
	_ ...Option,
) ([]byte, error)

func (*MockCommander) Expect

func (m *MockCommander) Expect(
	name string,
	args ...string,
) *Expectation

func (*MockCommander) ExpectWithMatchers

func (m *MockCommander) ExpectWithMatchers(
	name string,
	matchers ...ArgumentMatcher,
) *Expectation

func (*MockCommander) Output

func (m *MockCommander) Output(
	_ context.Context,
	name string,
	args []string,
	_ ...Option,
) ([]byte, []byte, error)

func (*MockCommander) Reset

func (m *MockCommander) Reset()

func (*MockCommander) Run

func (m *MockCommander) Run(
	_ context.Context,
	name string,
	args []string,
	_ ...Option,
) error

func (*MockCommander) Start

func (m *MockCommander) Start(
	_ context.Context,
	name string,
	args []string,
	_ ...Option,
) (Process, error)

func (*MockCommander) VerifyExpectations

func (m *MockCommander) VerifyExpectations() error

type Option

type Option func(*Options)

func WithDir

func WithDir(dir string) Option

func WithEnv

func WithEnv(env []string) Option

func WithStdin

func WithStdin(stdin io.Reader) Option

func WithStream added in v0.6.0

func WithStream(stdout, stderr chan<- string) Option

WithStream subscribes stdout and stderr channels to Start before the process launches, so no line can be read before the subscriber exists. Process.Stream called after Start can miss lines a fast command already wrote. Pass nil for a stream you do not want. The channels are closed when the process is done, as with Process.Stream. Run, Output and CombinedOutput ignore this option.

type Options

type Options struct {
	Stdin io.Reader
	Env   []string
	Dir   string

	StreamStdout chan<- string
	StreamStderr chan<- string
}

type Process

type Process interface {
	Start() error
	Wait() error
	StdinPipe() (io.WriteCloser, error)
	// Starts streaming from the current moment, not from the beginning
	// Multiple streams can be active simultaneously (broadcast)
	// Pass nil for channels you don't want to listen to
	Stream(stdout, stderr chan<- string)
	Stop(ctx context.Context) error
	Kill(ctx context.Context) error
	PID() int
}

type RegexMatcher

type RegexMatcher struct {
	// contains filtered or unexported fields
}

Regex matcher

func (*RegexMatcher) Matches

func (m *RegexMatcher) Matches(arg string) bool

func (*RegexMatcher) String

func (m *RegexMatcher) String() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL