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.
- Modules with minimum version selection - no dependency hell
- Formatter, linter, and test framework included
- Type checking prevents whole classes of bugs
- Simple - 25 keywords, learn in an afternoon
- Goroutines for concurrency without colored functions
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)
}