1// Package shell provides cross-platform shell execution capabilities.
3// This package offers two main types:
4// - Shell: A general-purpose shell executor for one-off or managed commands
5// - PersistentShell: A singleton shell that maintains state across the application
7// WINDOWS COMPATIBILITY:
8// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3),
9// even on Windows. Some caution has to be taken: commands should have forward
10// slashes (/) as path separators to work, even on Windows.
22 "mvdan.cc/sh/v3/expand"
23 "mvdan.cc/sh/v3/interp"
24 "mvdan.cc/sh/v3/syntax"
27// ShellType represents the type of shell to use
31 ShellTypePOSIX ShellType = iota
36// Logger interface for optional logging
37type Logger interface {
38 InfoPersist(msg string, keysAndValues ...any)
41// noopLogger is a logger that does nothing
42type noopLogger struct{}
44func (noopLogger) InfoPersist(msg string, keysAndValues ...any) {}
46// BlockFunc is a function that determines if a command should be blocked
47type BlockFunc func(args []string) bool
49// Shell provides cross-platform shell execution with optional state persistence
55 blockFuncs []BlockFunc
58// Options for creating a new shell
63 BlockFuncs []BlockFunc
66// NewShell creates a new shell instance with the given options
67func NewShell(opts *Options) *Shell {
72 cwd := opts.WorkingDir
91 blockFuncs: opts.BlockFuncs,
95// Exec executes a command in the shell
96func (s *Shell) Exec(ctx context.Context, command string) (string, string, error) {
100 return s.execPOSIX(ctx, command)
103// GetWorkingDir returns the current working directory
104func (s *Shell) GetWorkingDir() string {
110// SetWorkingDir sets the working directory
111func (s *Shell) SetWorkingDir(dir string) error {
115 // Verify the directory exists
116 if _, err := os.Stat(dir); err != nil {
117 return fmt.Errorf("directory does not exist: %w", err)
124// GetEnv returns a copy of the environment variables
125func (s *Shell) GetEnv() []string {
129 env := make([]string, len(s.env))
134// SetEnv sets an environment variable
135func (s *Shell) SetEnv(key, value string) {
139 // Update or add the environment variable
140 keyPrefix := key + "="
141 for i, env := range s.env {
142 if strings.HasPrefix(env, keyPrefix) {
143 s.env[i] = keyPrefix + value
147 s.env = append(s.env, keyPrefix+value)
150// SetBlockFuncs sets the command block functions for the shell
151func (s *Shell) SetBlockFuncs(blockFuncs []BlockFunc) {
154 s.blockFuncs = blockFuncs
157// CommandsBlocker creates a BlockFunc that blocks exact command matches
158func CommandsBlocker(bannedCommands []string) BlockFunc {
159 bannedSet := make(map[string]bool)
160 for _, cmd := range bannedCommands {
161 bannedSet[cmd] = true
164 return func(args []string) bool {
168 return bannedSet[args[0]]
172// ArgumentsBlocker creates a BlockFunc that blocks specific subcommands
173func ArgumentsBlocker(blockedSubCommands [][]string) BlockFunc {
174 return func(args []string) bool {
175 for _, blocked := range blockedSubCommands {
176 if len(args) >= len(blocked) {
178 for i, part := range blocked {
193func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
194 return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
195 return func(ctx context.Context, args []string) error {
197 return next(ctx, args)
200 for _, blockFunc := range s.blockFuncs {
202 return fmt.Errorf("command is not allowed for security reasons: %s", strings.Join(args, " "))
206 return next(ctx, args)
211// execPOSIX executes commands using POSIX shell emulation (cross-platform)
212func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string, error) {
213 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
215 return "", "", fmt.Errorf("could not parse command: %w", err)
218 var stdout, stderr bytes.Buffer
219 runner, err := interp.New(
220 interp.StdIO(nil, &stdout, &stderr),
221 interp.Interactive(false),
222 interp.Env(expand.ListEnviron(s.env...)),
224 interp.ExecHandlers(s.blockHandler(), s.coreUtilsHandler()),
227 return "", "", fmt.Errorf("could not run command: %w", err)
230 err = runner.Run(ctx, line)
233 for name, vr := range runner.Vars {
234 s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
236 s.logger.InfoPersist("POSIX command finished", "command", command, "err", err)
237 return stdout.String(), stderr.String(), err
240// IsInterrupt checks if an error is due to interruption
241func IsInterrupt(err error) bool {
242 return errors.Is(err, context.Canceled) ||
243 errors.Is(err, context.DeadlineExceeded)
246// ExitCode extracts the exit code from an error
247func ExitCode(err error) int {
251 var exitErr interp.ExitStatus
252 if errors.As(err, &exitErr) {
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list