commander

package module
v0.5.8 Latest Latest
Warning

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

Go to latest
Published: Aug 8, 2026 License: MIT Imports: 15 Imported by: 1

README

Commander

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")

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.

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

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

type Options

type Options struct {
	Stdin io.Reader
	Env   []string
	Dir   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