Automation that feels
like programming

Meet command buffers. Composable, testable, type-safe automation that works on local systems, containers, and remote hosts.

ctx, m := context.Background(), sys.Machine()

db := command.Shell(ssh.Machine(m, "postgres@prod"))
sh := command.Shell(m)

// Back up production database to local file.
_, err := io.Copy(
    sh.CreateBuffer(ctx, "backup.sql"),
    db.NewReader(ctx, "pg_dump", "myapp"),
)
if err != nil {
    log.Fatalf("backup failed: %v", err)
}

The Problem

Shell Scripts

Portable in theory. Full of sharp edges in practice. BSD vs GNU tools. No modules. No types. "Works on my machine."

YAML Configs

Learning yet another configuration language is frustrating when you could solve the same problems with if statements.

Platform Lock-in

Code that runs locally looks completely different from code that runs remotely. Vendor-specific abstractions everywhere.

The solution? Use a real programming language.

Go is the standout choice for automation. The go1compat promise means your automation keeps working, just like trusty shell scripts you return to years later.

Commands as io.Reader

Command buffers are lazy-executing io.Readers. They start on first Read, complete at EOF. No explicit Start() or Wait().

Composable with Standard I/O

Because commands are standard io.Reader and io.Writer, they compose naturally with the rest of Go's ecosystem.

deployment := `apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: app
        image: myapp:latest`

sh := command.Shell(sys.Machine(), "kubectl")
_, err := command.Copy(
    sh.NewWriter(ctx, "kubectl", "apply", "-f", "-"),
    strings.NewReader(deployment),
)

One Machine Interface, Multiple Backends

A command.Machine is anything that can execute commands. Same code, different execution context.

Local System

m := sys.Machine()

Container

m := ctr.Machine(sys.Machine(), "ubuntu:latest")

Remote SSH

m := ssh.Machine(sys.Machine(), "user@host.com")

BusyBox

m := sub.Machine(sys.Machine(), "busybox")

Switch contexts without changing code

m := sys.Machine()
if *useContainer {
    m = ctr.Machine(m, "golang:latest")
    defer command.Shutdown(ctx, m)
}

local := command.Shell(sys.Machine(), "git")
build := command.Shell(m, "go")

tmp, err := fs.Temp(ctx, build, "workdir/")
if err != nil {
    log.Fatal(err)
}
defer build.RemoveAll(ctx, tmp.Path())

// Test using only the committed code.
_, err = io.Copy(tmp, local.NewReader(ctx, "git", "archive", "HEAD"))
if err != nil {
    log.Fatal(err)
}
tmp.Close()

ctx = command.WithDir(ctx, tmp.Path())
if err := build.Exec(ctx, "go", "test", "./..."); err != nil {
    log.Fatal(err)
}

Declarative Shell, Imperative Filling

command.Shell wraps a Machine with a declarative layer: you declare which commands you need upfront. The rest is pure imperative control.

sh := command.Shell(sys.Machine(), "go", "git")

if err := sh.Exec(ctx, "go", "mod", "tidy"); err != nil {
    log.Fatal(err)
}

if err := sh.Exec(ctx, "go", "test", "./..."); err != nil {
    log.Fatal(err)
}

// "tee" would fail here. It's not a registered command.
// Use ergonomic shell methods to manage files instead.
if err := sh.WriteFile(ctx, ".gitignore", []byte("*.test\n")); err != nil {
    log.Fatal(err)
}

Self-documenting

The list of required commands is explicit and visible.

Catch errors early

Know immediately if a command isn't available rather than discovering it halfway through.

Portable by default

Encourages using portable abstractions (like fs.ReadFile) instead of shell commands.

Unified Filesystem Access

Work with files using the same patterns, whether local or remote.

const config = `port: 8080
database: postgres://prod-db:5432/app`

local := command.Shell(sys.Machine())
remote := command.Shell(
    ssh.Machine(sys.Machine(), "deploy@prod.example.com"),
)

if err := remote.MkdirAll(ctx, "/opt/myapp"); err != nil {
    log.Fatal(err)
}

_, err = io.Copy(
    remote.CreateBuffer(ctx, "/opt/myapp/server"),
    local.OpenBuffer(ctx, "./bin/server"),
)
if err != nil {
    log.Fatal(err)
}

err = remote.WriteFile(ctx, "/opt/myapp/config.yaml", []byte(config))
if err != nil {
    log.Fatal(err)
}

The lesiw.io/fs package extends Go's fs.FS with context-aware operations. Files open lazily on first Read or Write, making them composable with command pipelines.

Built for Testing

Mock machines make automation code testable. Program responses, verify calls, run tests without touching the real system.

func TestDeploy(t *testing.T) {
    ctx, m := t.Context(), new(mock.Machine)
    m.Return(strings.NewReader("main"), "git", "branch", "--show-current")
    sh := command.Shell(m, "git")

    if err := deploy(ctx, sh); err != nil {
        t.Fatal(err)
    }

    calls := mock.Calls(sh, "git")
    want := []mock.Call{
        {Args: []string{"git", "branch", "--show-current"}},
        {Args: []string{"git", "push", "origin", "main"}},
    }
    if !cmp.Equal(want, calls) {
        t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(want, calls))
    }
}

Simple Remote Management

Install cron jobs, services, and configurations to remote servers.

const autopatch = `#!/bin/sh
set -e
DEBIAN_FRONTEND=noninteractive
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
apt-get update
apt-get -o Dpkg::Options::=--force-confold \
  -o Dpkg::Options::=--force-confdef -y dist-upgrade
apt-get autoremove -y
shutdown -r 0`

servers := []string{"web1.prod", "web2.prod", "db1.prod"}

for _, host := range servers {
    remote := command.Shell(
        ssh.Machine(sys.Machine(), "root@"+host),
    )

    if err := remote.WriteFile(
        fs.WithFileMode(ctx, 0755),
        "/usr/local/bin/autopatch",
        []byte(autopatch),
    ); err != nil {
        log.Printf("%s: failed to install: %v", host, err)
        continue
    }

    cron := "0 2 * * 6 root /usr/local/bin/autopatch >> /var/log/autopatch.log\n"
    remote.WriteFile(ctx, "/etc/cron.d/autopatch", []byte(cron))

    log.Printf("%s: installed", host)
}

Getting Started

go get lesiw.io/command

Quick Example

package main

import (
    "context"
    "fmt"
    "log"

    "lesiw.io/command"
    "lesiw.io/command/sys"
)

func main() {
    ctx, sh := context.Background(), command.Shell(sys.Machine(), "go")

    if err := sh.Exec(ctx, "go", "version"); err != nil {
        log.Fatal(err)
    }

    version, err := sh.Read(ctx, "go", "version")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Go version:", version)
}