invoke

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: May 23, 2026 License: MIT Imports: 12 Imported by: 3

README

invoke

A Go library for running commands across Local, SSH, and Docker environments using a single interface.

Instead of writing separate logic for os/exec, crypto/ssh, and the Docker SDK, write it once and swap the provider.

Install

go get github.com/ruffel/invoke

Providers are separate modules:

go get github.com/ruffel/invoke/providers/local
go get github.com/ruffel/invoke/providers/ssh
go get github.com/ruffel/invoke/providers/docker

Support

invoke is currently being hardened as a POSIX-focused library.

  • supported host environments for this cycle: Linux and macOS
  • first-party providers in scope: Local, SSH, Docker
  • primary target assumptions: POSIX-style shells, Unix-like process semantics, Linux containers
  • out of scope for this cycle: Windows support and new first-party backends
  • first-party providers are expected to support documented features or return ErrNotSupported explicitly

Getting Started

Provider Setup Target
Local local.New() Host machine
SSH ssh.New(ssh.WithHost("10.0.0.1"), ...) Remote server
Docker docker.New(docker.WithContainerID("abc")) Running container

Every provider implements invoke.Environment — the basic non-interactive execution examples below work with any of them. Provider-specific caveats (TTY, signals, file transfer details) are documented in docs/provider-caveats.md.

Usage

Run a command
env, err := local.New()
if err != nil {
    return err
}
defer env.Close()

exec := invoke.NewExecutor(env)
result, err := exec.RunBuffered(ctx, &invoke.Command{Cmd: "uptime"})
if err != nil {
    return err
}
fmt.Println(string(result.Stdout))

Commands are argv-style by default. Use RunShell or ShellCommand only when you need shell features such as pipes, redirects, globbing, or variable expansion.

Shell one-liners
result, err := exec.RunShell(ctx, "ls -la | grep foo")

Or construct the command directly:

cmd := invoke.ShellCommand("cat /etc/os-release | head -5")
result, err := exec.RunBuffered(ctx, cmd)
Stream output
cmd := &invoke.Command{
    Cmd:    "make",
    Args:   []string{"build"},
    Stdout: os.Stdout,
    Stderr: os.Stderr,
}

_, err := env.Run(ctx, cmd)
Live line-by-line streaming
err := exec.RunLineStream(ctx, &invoke.Command{Cmd: "deploy"}, func(line string) {
    log.Println(line)
})

RunBuffered keeps stdout and stderr in memory. For long-running commands or unbounded output, stream to writers or use RunLineStream for stdout-only line logs.

SSH
env, err := ssh.New(
    ssh.WithHost("10.0.0.1"),
    ssh.WithUser("deploy"),
    ssh.WithKeyPath("/home/deploy/.ssh/id_ed25519"),
    ssh.WithDefaultKnownHosts(),
)
if err != nil {
    // handle error
}
defer env.Close()

// Same API as local
exec := invoke.NewExecutor(env)
result, err := exec.RunBuffered(ctx, &invoke.Command{Cmd: "uptime"})

For testing only, you can disable host key checking (not recommended for production):

env, err := ssh.New(
    ssh.WithHost("10.0.0.1"),
    ssh.WithUser("deploy"),
    ssh.WithPassword("secret"),
    ssh.WithInsecureSkipVerify(true),
)
Docker
env, err := docker.New(docker.WithContainerID("my-app"))
if err != nil {
    return err
}
defer env.Close()

result, err := env.Run(ctx, &invoke.Command{Cmd: "cat", Args: []string{"/etc/os-release"}})
Sudo
exec.Run(ctx, cmd, invoke.WithSudo())

// With options
exec.Run(ctx, cmd, invoke.WithSudo(
    invoke.WithSudoUser("postgres"),
    invoke.WithSudoPreserveEnv(),
))

Sudo is intentionally non-interactive (sudo -n). Configure passwordless sudo or run a TTY session yourself if the target command expects prompts.

File transfers
// Upload with permissions
env.Upload(ctx, "./configs/nginx", "/etc/nginx", invoke.WithPermissions(0o644))

// Download
env.Download(ctx, "/var/log/app.log", "./app.log")
Interactive TTY
cmd := invoke.ShellCommand("bash")
exec.RunInteractiveTTY(ctx, cmd)
Retry on transport errors
result, err := exec.Run(ctx, cmd, invoke.WithRetry(3, time.Second))

Error Model

invoke distinguishes between two failure modes:

  • ExitError — the command ran but exited non-zero. Contains the exit code. Stderr is populated when using RunBuffered. Not retryable — this is a definitive result.
  • TransportError — the underlying transport failed (connection lost, daemon unreachable). Retryable via WithRetry.
_, err := exec.RunBuffered(ctx, cmd)
if err != nil {
    var exitErr *invoke.ExitError
    if errors.As(err, &exitErr) {
        fmt.Printf("exit code %d: %s\n", exitErr.ExitCode, exitErr.Stderr)
    }
}

When Wait() returns an error, the *Result is still populated with the exit code and duration.

Design

  • Streaming-first — interfaces use io.Reader and io.Writer, not buffers
  • Context-aware — all blocking operations respect context.Context
  • Secure defaults — SSH host key checking is enforced; you must explicitly opt out
  • Provider-agnostic — write once, deploy to local, SSH, or Docker

License

MIT

Documentation

Overview

Package invoke provides a unified interface for command execution and file transfer across Local, SSH, and Docker environments.

Core Interfaces

  • Environment: The connection to a system. Swap the provider to switch targets.
  • Process: A handle to a running command (Wait, Signal, Close).

Streaming

invoke is streaming-first. Output is not buffered by default — attach an io.Writer to your Command. For convenience, use Executor.RunBuffered to capture output.

Error Model

  • ExitError: The command ran but exited non-zero. Contains exit code and stderr.
  • TransportError: The underlying transport failed (connection lost, daemon unreachable). Retryable via WithRetry.

Privilege Escalation

Supported via WithSudo. Uses sudo -n for non-interactive execution.

Example (SshConfigReader)
package main

import (
	"fmt"
	"log"
	"strings"

	"github.com/ruffel/invoke/providers/ssh"
)

func main() {
	// Example of loading SSH config from a string (or file)
	configContent := `
Host prod-db
  HostName 10.0.0.5
  User admin
  Port 2222
  IdentityFile ~/.ssh/prod_key.pem
  StrictHostKeyChecking no
`
	// Parse the config
	cfg, err := ssh.NewFromSSHConfigReader("prod-db", strings.NewReader(configContent))
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Host: %s\n", cfg.Host)
	fmt.Printf("User: %s\n", cfg.User)
	fmt.Printf("Port: %d\n", cfg.Port)

}
Output:
Host: 10.0.0.5
User: admin
Port: 2222

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrEnvironmentClosed = errors.New("environment is closed")

ErrEnvironmentClosed indicates that an operation was attempted on a closed environment.

View Source
var ErrNotSupported = errors.New("operation not supported")

ErrNotSupported indicates that the requested feature (e.g., TTY) is not supported by the specific provider or OS.

Functions

This section is empty.

Types

type BufferedResult

type BufferedResult struct {
	Result

	Stdout []byte // Captured standard output
	Stderr []byte // Captured standard error
}

BufferedResult extends Result with captured stdout and stderr content. Returned by Executor.RunBuffered and Executor.RunShell.

type Command

type Command struct {
	Cmd  string   // Binary name or path to executable
	Args []string // Arguments to pass to the binary
	Env  []string // Environment variables in "KEY=VALUE" format
	Dir  string   // Working directory for execution

	// Standard streams. If nil, defaults to empty/discard.
	Stdin  io.Reader
	Stdout io.Writer
	Stderr io.Writer

	// Tty allocates a PTY. Useful for interactive commands (e.g. sudo).
	Tty bool
}

Command configures a process execution.

func NewCommand

func NewCommand(binary string, args ...string) *Command

NewCommand creates a new Command with the given binary and arguments.

func ParseCommand

func ParseCommand(cmdStr string) (*Command, error)

ParseCommand parses a shell command string into a Command struct using shlex. It handles quoted arguments correctly.

func ShellCommand added in v0.1.0

func ShellCommand(script string) *Command

ShellCommand constructs a command that runs the provided script inside the system shell. Equivalent to: sh -c "<script>".

Use this for one-liners, pipelines, and glob expansion:

cmd := invoke.ShellCommand("ls -la | grep foo")

func (*Command) String

func (c *Command) String() string

String returns a compact diagnostic representation of the command. It is intended for logs and errors, not for shell execution.

func (*Command) Validate added in v0.1.0

func (c *Command) Validate() error

Validate checks that the command is well-formed. Returns an error if the command is nil or has an empty binary.

type Environment

type Environment interface {
	io.Closer

	// Run executes a command synchronously.
	// Returns the result (exit code, duration) and any error.
	// Output is not captured by default; attach writers to Command.Stdout/Stderr.
	Run(ctx context.Context, cmd *Command) (*Result, error)

	// Start initiates a command asynchronously.
	// The caller manages the returned Process (Wait/Signal) and must ensure resources
	// are released via Wait() or Close().
	Start(ctx context.Context, cmd *Command) (Process, error)

	// TargetOS returns the operating system of the target environment.
	TargetOS() TargetOS

	// Upload copies a local file or directory to the remote destination.
	// Missing parent directories at the destination are created automatically.
	Upload(ctx context.Context, localPath, remotePath string, opts ...FileOption) error

	// Download copies a remote file or directory to the local destination.
	// Missing parent directories at the local destination are created automatically.
	Download(ctx context.Context, remotePath, localPath string, opts ...FileOption) error

	// LookPath searches for an executable named file in the PATH of the target environment.
	LookPath(ctx context.Context, file string) (string, error)
}

Environment abstracts the underlying system where commands are executed (e.g., Local, SSH, Docker).

Example (Upload)
package main

import (
	"context"
	"log"
	"time"

	"github.com/ruffel/invoke"
	"github.com/ruffel/invoke/providers/mock"

	testifymock "github.com/stretchr/testify/mock"
)

func main() {
	// Demonstrating the FileTransfer interface usage (now part of Environment)
	var env invoke.Environment = mock.New()

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	// Setup mock expectation
	env.(*mock.Environment).On("Upload", ctx, "./config.json", "/etc/app/config.json", testifymock.Anything).Return(nil)
	// Upload with permissions override
	err := env.Upload(ctx, "./config.json", "/etc/app/config.json",
		invoke.WithPermissions(0o600),
	)
	if err != nil {
		log.Printf("Upload failed: %v", err)
	}
}
Example (Upload_download)
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/ruffel/invoke"
	"github.com/ruffel/invoke/providers/local"
)

func main() {
	env, err := local.New()
	if err != nil {
		panic(err)
	}

	_ = os.WriteFile("localfile.txt", []byte("hello world"), 0o600)

	defer func() { _ = os.Remove("localfile.txt") }()
	defer func() { _ = os.Remove("localfile.bak") }()

	ctx := context.Background()

	err = env.Upload(ctx, "localfile.txt", "/tmp/localfile.txt", invoke.WithPermissions(0o644))
	if err != nil {
		panic(err)
	}

	err = env.Download(ctx, "/tmp/localfile.txt", "localfile.bak")
	if err != nil {
		panic(err)
	}

	content, err := os.ReadFile("localfile.bak")
	if err != nil {
		panic(err)
	}

	fmt.Printf("Downloaded content: %s\n", string(content))

}
Output:
Downloaded content: hello world
Example (WithProgress)
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/ruffel/invoke"
	"github.com/ruffel/invoke/providers/local"
)

func main() {
	env, err := local.New()
	if err != nil {
		panic(err)
	}

	_ = os.WriteFile("largefile.dat", []byte("1234567890"), 0o600)

	defer func() { _ = os.Remove("largefile.dat") }()
	defer func() { _ = os.Remove("largefile.dat.bak") }()

	ctx := context.Background()

	err = env.Upload(ctx, "largefile.dat", "largefile.dat.bak",
		invoke.WithProgress(func(current, total int64) {
			fmt.Printf("Transferred %d/%d bytes\n", current, total)
		}),
	)
	if err != nil {
		panic(err)
	}

}
Output:
Transferred 10/10 bytes

type ExecConfig

type ExecConfig struct {
	SudoConfig    *SudoConfig
	RetryAttempts int
	RetryDelay    time.Duration
}

ExecConfig holds configuration derived from options.

type ExecOption

type ExecOption func(*ExecConfig)

ExecOption defines a functional option for execution.

func WithRetry

func WithRetry(attempts int, delay time.Duration) ExecOption

WithRetry enables retry logic for the command execution using linear backoff. attempts: Total number of attempts (including the initial one). Must be >= 1. delay: Duration to wait between attempts.

func WithSudo

func WithSudo(opts ...SudoOption) ExecOption

WithSudo wraps the command in sudo.

type Executor

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

Executor handles command execution with retry logic, sudo support, and output buffering.

func NewExecutor

func NewExecutor(env Environment) *Executor

NewExecutor creates a new Executor with the given environment.

func (*Executor) Download

func (e *Executor) Download(ctx context.Context, remotePath, localPath string, opts ...FileOption) error

Download copies a remote file or directory to the local destination. It delegates directly to the underlying Environment.

func (*Executor) LookPath

func (e *Executor) LookPath(ctx context.Context, file string) (string, error)

LookPath resolves an executable path using the underlying environment's LookPath strategy.

func (*Executor) Run

func (e *Executor) Run(ctx context.Context, cmd *Command, opts ...ExecOption) (*Result, error)

Run executes a command, respecting context cancellation and configured retry policies.

Retry behavior:

  • Success (exit code 0) returns immediately.
  • Non-zero exit codes and *ExitError are terminal and never retried.
  • context.Canceled, context.DeadlineExceeded, ErrNotSupported, and ErrEnvironmentClosed are terminal and never retried.
  • Other errors are treated as transport-like and retried when attempts remain.
Example (Sudo)
package main

import (
	"context"
	"fmt"

	"github.com/ruffel/invoke"
	"github.com/ruffel/invoke/providers/mock"

	testifymock "github.com/stretchr/testify/mock"
)

func main() {
	// Example of using high-level options
	env := mock.New() // Using mock for safety in example

	// Mock the result
	// Match any command that has the right string components, ignoring pointers (Stdout/Stderr)
	matcher := testifymock.MatchedBy(func(c *invoke.Command) bool {
		return c.Cmd == "sudo" && len(c.Args) == 4 && c.Args[3] == "/root"
	})

	env.On("Run", context.Background(), matcher).Run(func(args testifymock.Arguments) {
		cmd := args.Get(1).(*invoke.Command)
		if cmd.Stdout != nil {
			_, _ = fmt.Fprint(cmd.Stdout, "secret.txt\n")
		}
	}).Return(&invoke.Result{ExitCode: 0}, nil)

	exec := invoke.NewExecutor(env)
	ctx := context.Background()

	// WithSudo() automatically wraps the command
	cmd := invoke.Command{Cmd: "ls", Args: []string{"/root"}}

	// We use RunBuffered to capture output
	res, err := exec.RunBuffered(ctx, &cmd, invoke.WithSudo())
	if err != nil {
		panic(err)
	}

	fmt.Printf("Sudo Output: %s", res.Stdout)
}
Output:
Sudo Output: secret.txt

func (*Executor) RunBuffered

func (e *Executor) RunBuffered(ctx context.Context, cmd *Command, opts ...ExecOption) (*BufferedResult, error)

RunBuffered executes a command and captures both Stdout and Stderr into byte slices.

It creates a shallow copy of cmd and overrides Stdout/Stderr with internal buffers. The caller's original Command is not mutated. If you need simultaneous streaming and buffering, compose with io.MultiWriter on the Command before calling Run directly. Output is held in memory, so prefer streaming for commands with large or unbounded output.

Example (Local)
package main

import (
	"context"
	"fmt"

	"github.com/ruffel/invoke"
	"github.com/ruffel/invoke/providers/local"
)

func main() {
	env, err := local.New()
	if err != nil {
		panic(err)
	}

	defer func() { _ = env.Close() }()

	exec := invoke.NewExecutor(env)

	cmd := invoke.Command{
		Cmd:  "echo",
		Args: []string{"hello", "world"},
	}

	ctx := context.Background()

	res, err := exec.RunBuffered(ctx, &cmd)
	if err != nil {
		panic(err)
	}

	fmt.Printf("%s\n", res.Stdout)
}
Output:
hello world

func (*Executor) RunInteractiveTTY added in v0.1.0

func (e *Executor) RunInteractiveTTY(ctx context.Context, cmd *Command, opts ...ExecOption) (*Result, error)

RunInteractiveTTY executes a command in interactive TTY mode.

If Stdin/Stdout/Stderr are nil they default to os.Stdin/os.Stdout/os.Stderr. This behavior is specific to RunInteractiveTTY and overrides the default Command behavior, where nil streams typically default to empty/discard.

When reading from the current terminal, stdin is switched to raw mode for the duration of the command. The original terminal state is restored via defer, ensuring cleanup even if the command panics.

func (*Executor) RunLineStream

func (e *Executor) RunLineStream(ctx context.Context, cmd *Command, onLine func(string)) error

RunLineStream streams stdout line-by-line to onLine.

It creates a shallow copy of cmd and overrides Stdout with an internal pipe. The caller's original Command is not mutated. Useful for live logging of build output or deployment progress. Stderr is not streamed by this helper, and lines are subject to bufio.Scanner's token size limit.

func (*Executor) RunShell

func (e *Executor) RunShell(ctx context.Context, cmdStr string, opts ...ExecOption) (*BufferedResult, error)

RunShell executes a shell command string using the target OS's default shell.

func (*Executor) Start

func (e *Executor) Start(ctx context.Context, cmd *Command) (Process, error)

Start initiates a command asynchronously. Caller is responsible for Process.Wait().

func (*Executor) TargetOS

func (e *Executor) TargetOS() TargetOS

TargetOS returns the operating system of the underlying environment.

func (*Executor) Upload

func (e *Executor) Upload(ctx context.Context, localPath, remotePath string, opts ...FileOption) error

Upload copies a local file or directory to the remote destination. It delegates directly to the underlying Environment.

type ExitError

type ExitError struct {
	Command  *Command
	ExitCode int
	Stderr   []byte // Populated by RunBuffered for convenience
	Cause    error  // Underlying error from the OS/transport layer
}

ExitError represents a command that ran but exited with a non-zero code.

When Wait() returns an ExitError, the accompanying *Result is still populated with the exit code and duration. Use errors.As to extract the exit code:

var exitErr *invoke.ExitError
if errors.As(err, &exitErr) {
    fmt.Println("exit code:", exitErr.ExitCode)
}

func (*ExitError) Error

func (e *ExitError) Error() string

func (*ExitError) Unwrap

func (e *ExitError) Unwrap() error

type FileConfig

type FileConfig struct {
	Permissions os.FileMode // Destination perms override (0 means preserve/default)
	Recursive   bool        // Default true for generic uploads
	Progress    ProgressFunc
}

FileConfig holds configuration for file transfers.

func DefaultFileConfig

func DefaultFileConfig() FileConfig

DefaultFileConfig returns defaults.

type FileOption

type FileOption func(*FileConfig)

FileOption defines a functional option for file transfers.

func WithPermissions

func WithPermissions(mode os.FileMode) FileOption

WithPermissions forces specific destination file mode.

func WithProgress

func WithProgress(fn ProgressFunc) FileOption

WithProgress calls fn with progress updates.

func WithRecursive added in v0.1.0

func WithRecursive(recursive bool) FileOption

WithRecursive enables or disables recursive directory transfers.

type Process

type Process interface {
	io.Closer

	// Wait blocks until the process exits and returns the result.
	//
	// On success (exit code 0), returns (*Result, nil).
	// On non-zero exit, returns (*Result, *ExitError) — the Result is always
	// populated with the exit code and duration, even when an error is returned.
	//
	// Wait is idempotent: calling it multiple times returns the same result.
	Wait() (*Result, error)

	// Signal sends an OS signal to the process.
	// Support for specific signals depends on the underlying provider.
	Signal(sig os.Signal) error
}

Process represents a command that has been started but not yet completed.

type ProgressFunc

type ProgressFunc func(current, total int64)

ProgressFunc is a callback for tracking file transfer progress.

type Result

type Result struct {
	ExitCode int           // Process exit code (0 indicates success)
	Duration time.Duration // Wall-clock time from start to exit
	Error    error         // Provider-level wait/transport error. Prefer the returned error for control flow.
}

Result contains metadata about a completed command execution. Always populated by Wait(), even when Wait returns an error.

func (*Result) Failed

func (r *Result) Failed() bool

Failed returns true if the command failed (non-zero exit code or transport error).

func (*Result) Success

func (r *Result) Success() bool

Success returns true if the command completed with exit code 0 and no transport error.

type SudoConfig added in v0.0.3

type SudoConfig struct {
	User        string   // Target user (-u)
	Group       string   // Target group (-g)
	PreserveEnv bool     // Preserve environment (-E)
	CustomFlags []string // Additional flags
}

SudoConfig defines advanced privilege escalation options.

type SudoOption added in v0.0.3

type SudoOption func(*SudoConfig)

SudoOption defines a functional option for sudo configuration.

func WithSudoCustomFlags added in v0.1.0

func WithSudoCustomFlags(flags ...string) SudoOption

WithSudoCustomFlags adds additional sudo flags.

func WithSudoGroup added in v0.1.0

func WithSudoGroup(group string) SudoOption

WithSudoGroup sets the target group.

func WithSudoPreserveEnv added in v0.0.3

func WithSudoPreserveEnv() SudoOption

WithSudoPreserveEnv preserves the environment.

func WithSudoUser added in v0.0.3

func WithSudoUser(user string) SudoOption

WithSudoUser sets the target user.

type TargetOS

type TargetOS int

TargetOS identifies the operating system of the target environment.

const (
	// OSUnknown represents an unidentified operating system.
	OSUnknown TargetOS = iota
	// OSLinux represents the Linux kernel.
	OSLinux
	// OSDarwin represents macOS (Darwin).
	OSDarwin
)

func DetectLocalOS

func DetectLocalOS() TargetOS

DetectLocalOS returns the TargetOS of the current running process.

func ParseTargetOS

func ParseTargetOS(osStr string) TargetOS

ParseTargetOS converts a typical OS string (e.g., "linux", "darwin") to a TargetOS.

func (TargetOS) String

func (os TargetOS) String() string

type TransportError added in v0.1.0

type TransportError struct {
	Command *Command
	Err     error
}

TransportError represents a failure in the underlying transport layer (e.g., connection lost, docker daemon unreachable, binary not found).

TransportErrors are retryable via WithRetry, unlike ExitErrors which represent a definitive command result.

func (*TransportError) Error added in v0.1.0

func (e *TransportError) Error() string

func (*TransportError) Unwrap added in v0.1.0

func (e *TransportError) Unwrap() error

Directories

Path Synopsis
internal
fileutil
Package fileutil provides shared file-transfer utilities for invoke providers.
Package fileutil provides shared file-transfer utilities for invoke providers.
Package invoketest provides a contract test suite for invoke providers.
Package invoketest provides a contract test suite for invoke providers.
providers
local module
mock module
ssh module

Jump to

Keyboard shortcuts

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