feat(process): add Program struct with Find, Run, RunDir
Add Program to the process package as a lightweight tool-finder and runner. Find() resolves a binary via exec.LookPath (wrapping ErrProgramNotFound), Run() and RunDir() execute the binary and return trimmed combined output. Includes 7 tests covering happy paths, error paths, and the errors.Is contract. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
e0df0d3b34
commit
87b16ca41c
3 changed files with 210 additions and 0 deletions
62
docs/plans/2026-03-18-absorb-sail-program.md
Normal file
62
docs/plans/2026-03-18-absorb-sail-program.md
Normal file
|
|
@ -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
|
||||
68
program.go
Normal file
68
program.go
Normal file
|
|
@ -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
|
||||
}
|
||||
80
program_test.go
Normal file
80
program_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue