diff --git a/docs/plans/2026-03-18-absorb-sail-program.md b/docs/plans/2026-03-18-absorb-sail-program.md new file mode 100644 index 0000000..7df7f38 --- /dev/null +++ b/docs/plans/2026-03-18-absorb-sail-program.md @@ -0,0 +1,62 @@ +# Plan: Absorb Sail Program + +**Date:** 2026-03-18 +**Branch:** agent/implement-the-plan-at-docs-plans-2026-03 + +## Overview + +Add a `Program` struct to the `process` package that locates a named binary on PATH and provides lightweight run helpers. This absorbs the "sail program" pattern — a simple way to find and invoke a known CLI tool without wiring the full Core IPC machinery. + +## API + +```go +// Program represents a named executable located on the system PATH. +type Program struct { + Name string // binary name, e.g. "go", "node" + Path string // absolute path resolved by Find() +} + +// Find resolves the program's absolute path via exec.LookPath. +func (p *Program) Find() error + +// Run executes the program with args in the current working directory. +// Returns combined stdout+stderr output and any error. +func (p *Program) Run(ctx context.Context, args ...string) (string, error) + +// RunDir executes the program with args in dir. +// Returns combined stdout+stderr output and any error. +func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) +``` + +## Tasks + +### Task 1: Implement Program in program.go + +- Create `program.go` in the root `process` package +- Add `ErrProgramNotFound` sentinel error using `coreerr.E` +- Add `Program` struct with exported `Name` and `Path` fields +- Implement `Find() error` using `exec.LookPath`; if `Name` is empty return error +- Implement `RunDir(ctx, dir, args...) (string, error)` using `exec.CommandContext` + - Capture combined stdout+stderr into a `bytes.Buffer` + - Set `cmd.Dir` if `dir` is non-empty + - Wrap run errors with `coreerr.E` + - Trim trailing whitespace from output +- Implement `Run(ctx, args...) (string, error)` as `p.RunDir(ctx, "", args...)` +- Commit: `feat(process): add Program struct` + +### Task 2: Write tests in program_test.go + +- Create `program_test.go` in the root `process` package +- Test `Find()` succeeds for a binary that exists on PATH (`echo`) +- Test `Find()` fails for a binary that does not exist +- Test `Run()` executes and returns output +- Test `RunDir()` runs in the specified directory (verify via `pwd` or `ls`) +- Test `Run()` before `Find()` still works (falls back to `Name`) +- Commit: `test(process): add Program tests` + +## Acceptance Criteria + +- `go test ./...` passes with zero failures +- No `fmt.Errorf` or `errors.New` — only `coreerr.E` +- `Program` is in the root `process` package (not exec subpackage) +- `Run` delegates to `RunDir` — no duplication diff --git a/program.go b/program.go new file mode 100644 index 0000000..1370b2f --- /dev/null +++ b/program.go @@ -0,0 +1,68 @@ +package process + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + + coreerr "forge.lthn.ai/core/go-log" +) + +// ErrProgramNotFound is returned when Find cannot locate the binary on PATH. +// Callers may use errors.Is to detect this condition. +var ErrProgramNotFound = coreerr.E("", "program: binary not found in PATH", nil) + +// Program represents a named executable located on the system PATH. +// Create one with a Name, call Find to resolve its path, then Run or RunDir. +type Program struct { + // Name is the binary name (e.g. "go", "node", "git"). + Name string + // Path is the absolute path resolved by Find. + // If empty, Run and RunDir fall back to Name for OS PATH resolution. + Path string +} + +// Find resolves the program's absolute path using exec.LookPath. +// Returns ErrProgramNotFound (wrapped) if the binary is not on PATH. +func (p *Program) Find() error { + if p.Name == "" { + return coreerr.E("Program.Find", "program name is empty", nil) + } + path, err := exec.LookPath(p.Name) + if err != nil { + return coreerr.E("Program.Find", fmt.Sprintf("%q: not found in PATH", p.Name), ErrProgramNotFound) + } + p.Path = path + return nil +} + +// Run executes the program with args in the current working directory. +// Returns trimmed combined stdout+stderr output and any error. +func (p *Program) Run(ctx context.Context, args ...string) (string, error) { + return p.RunDir(ctx, "", args...) +} + +// RunDir executes the program with args in dir. +// Returns trimmed combined stdout+stderr output and any error. +// If dir is empty, the process inherits the caller's working directory. +func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) { + binary := p.Path + if binary == "" { + binary = p.Name + } + + var out bytes.Buffer + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Stdout = &out + cmd.Stderr = &out + if dir != "" { + cmd.Dir = dir + } + + if err := cmd.Run(); err != nil { + return strings.TrimSpace(out.String()), coreerr.E("Program.RunDir", fmt.Sprintf("%q: command failed", p.Name), err) + } + return strings.TrimSpace(out.String()), nil +} diff --git a/program_test.go b/program_test.go new file mode 100644 index 0000000..0687906 --- /dev/null +++ b/program_test.go @@ -0,0 +1,80 @@ +package process_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + process "forge.lthn.ai/core/go-process" +) + +func testCtx(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + t.Cleanup(cancel) + return ctx +} + +func TestProgram_Find_KnownBinary(t *testing.T) { + p := &process.Program{Name: "echo"} + require.NoError(t, p.Find()) + assert.NotEmpty(t, p.Path) +} + +func TestProgram_Find_UnknownBinary(t *testing.T) { + p := &process.Program{Name: "no-such-binary-xyzzy-42"} + err := p.Find() + require.Error(t, err) + assert.ErrorIs(t, err, process.ErrProgramNotFound) +} + +func TestProgram_Find_EmptyName(t *testing.T) { + p := &process.Program{} + require.Error(t, p.Find()) +} + +func TestProgram_Run_ReturnsOutput(t *testing.T) { + p := &process.Program{Name: "echo"} + require.NoError(t, p.Find()) + + out, err := p.Run(testCtx(t), "hello") + require.NoError(t, err) + assert.Equal(t, "hello", out) +} + +func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) { + // Path is empty; RunDir should fall back to Name for OS PATH resolution. + p := &process.Program{Name: "echo"} + + out, err := p.Run(testCtx(t), "fallback") + require.NoError(t, err) + assert.Equal(t, "fallback", out) +} + +func TestProgram_RunDir_UsesDirectory(t *testing.T) { + p := &process.Program{Name: "pwd"} + require.NoError(t, p.Find()) + + dir := t.TempDir() + + out, err := p.RunDir(testCtx(t), dir) + require.NoError(t, err) + // Resolve symlinks on both sides for portability (macOS uses /private/ prefix). + canonicalDir, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + canonicalOut, err := filepath.EvalSymlinks(out) + require.NoError(t, err) + assert.Equal(t, canonicalDir, canonicalOut) +} + +func TestProgram_Run_FailingCommand(t *testing.T) { + p := &process.Program{Name: "false"} + require.NoError(t, p.Find()) + + _, err := p.Run(testCtx(t)) + require.Error(t, err) +}