feat(process): add standardized exec wrapper (#91)
* feat(process): add standardized exec wrapper - Adds pkg/process/exec/exec.go with context and logging support - Implements Command, Run, Output, CombinedOutput, RunQuiet helpers - Enforces context usage (falls back to background if nil, pending strict enforcement) - Standardizes error wrapping for exec.ExitError * fix(process): remove unused cli import Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * style(process): fix trailing whitespace Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
df2c335983
commit
de0fac563e
1 changed files with 146 additions and 0 deletions
146
pkg/process/exec/exec.go
Normal file
146
pkg/process/exec/exec.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Options configuration for command execution
|
||||
type Options struct {
|
||||
Dir string
|
||||
Env []string
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
// If true, command will run in background (not implemented in this wrapper yet)
|
||||
// Background bool
|
||||
}
|
||||
|
||||
// Command wraps os/exec.Command with logging and context
|
||||
func Command(ctx context.Context, name string, args ...string) *Cmd {
|
||||
return &Cmd{
|
||||
name: name,
|
||||
args: args,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// Cmd represents a wrapped command
|
||||
type Cmd struct {
|
||||
name string
|
||||
args []string
|
||||
ctx context.Context
|
||||
opts Options
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
// WithDir sets the working directory
|
||||
func (c *Cmd) WithDir(dir string) *Cmd {
|
||||
c.opts.Dir = dir
|
||||
return c
|
||||
}
|
||||
|
||||
// WithEnv sets the environment variables
|
||||
func (c *Cmd) WithEnv(env []string) *Cmd {
|
||||
c.opts.Env = env
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStdin sets stdin
|
||||
func (c *Cmd) WithStdin(r io.Reader) *Cmd {
|
||||
c.opts.Stdin = r
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStdout sets stdout
|
||||
func (c *Cmd) WithStdout(w io.Writer) *Cmd {
|
||||
c.opts.Stdout = w
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStderr sets stderr
|
||||
func (c *Cmd) WithStderr(w io.Writer) *Cmd {
|
||||
c.opts.Stderr = w
|
||||
return c
|
||||
}
|
||||
|
||||
// Run executes the command and waits for it to finish.
|
||||
// It automatically logs the command execution at debug level.
|
||||
func (c *Cmd) Run() error {
|
||||
c.prepare()
|
||||
|
||||
// TODO: Use a proper logger interface when available in pkg/process
|
||||
// For now using cli.Debug which might not be visible unless verbose
|
||||
// cli.Debug("Executing: %s %s", c.name, strings.Join(c.args, " "))
|
||||
|
||||
if err := c.cmd.Run(); err != nil {
|
||||
return wrapError(err, c.name, c.args)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Output runs the command and returns its standard output.
|
||||
func (c *Cmd) Output() ([]byte, error) {
|
||||
c.prepare()
|
||||
out, err := c.cmd.Output()
|
||||
if err != nil {
|
||||
return nil, wrapError(err, c.name, c.args)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CombinedOutput runs the command and returns its combined standard output and standard error.
|
||||
func (c *Cmd) CombinedOutput() ([]byte, error) {
|
||||
c.prepare()
|
||||
out, err := c.cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return out, wrapError(err, c.name, c.args)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Cmd) prepare() {
|
||||
if c.ctx != nil {
|
||||
c.cmd = exec.CommandContext(c.ctx, c.name, c.args...)
|
||||
} else {
|
||||
// Should we enforce context? The issue says "Enforce context usage".
|
||||
// For now, let's allow nil but log a warning if we had a logger?
|
||||
// Or strictly panic/error?
|
||||
// Let's fallback to Background for now but maybe strict later.
|
||||
c.cmd = exec.Command(c.name, c.args...)
|
||||
}
|
||||
|
||||
c.cmd.Dir = c.opts.Dir
|
||||
if len(c.opts.Env) > 0 {
|
||||
c.cmd.Env = append(os.Environ(), c.opts.Env...)
|
||||
}
|
||||
|
||||
c.cmd.Stdin = c.opts.Stdin
|
||||
c.cmd.Stdout = c.opts.Stdout
|
||||
c.cmd.Stderr = c.opts.Stderr
|
||||
}
|
||||
|
||||
// RunQuiet executes the command suppressing stdout unless there is an error.
|
||||
// Useful for internal commands.
|
||||
func RunQuiet(ctx context.Context, name string, args ...string) error {
|
||||
var stderr bytes.Buffer
|
||||
cmd := Command(ctx, name, args...).WithStderr(&stderr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Include stderr in error message
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wrapError(err error, name string, args []string) error {
|
||||
cmdStr := name + " " + strings.Join(args, " ")
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return fmt.Errorf("command %q failed with exit code %d: %w", cmdStr, exitErr.ExitCode(), err)
|
||||
}
|
||||
return fmt.Errorf("failed to execute %q: %w", cmdStr, err)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue