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/moreinterp/coreutils"
23 "mvdan.cc/sh/v3/expand"
24 "mvdan.cc/sh/v3/interp"
25 "mvdan.cc/sh/v3/syntax"
28// ShellType represents the type of shell to use
32 ShellTypePOSIX ShellType = iota
37// Logger interface for optional logging
38type Logger interface {
39 InfoPersist(msg string, keysAndValues ...any)
42// noopLogger is a logger that does nothing
43type noopLogger struct{}
45func (noopLogger) InfoPersist(msg string, keysAndValues ...any) {}
47// BlockFunc is a function that determines if a command should be blocked
48type BlockFunc func(args []string) bool
50// Shell provides cross-platform shell execution with optional state persistence
56 blockFuncs []BlockFunc
59// Options for creating a new shell
64 BlockFuncs []BlockFunc
67// NewShell creates a new shell instance with the given options
68func NewShell(opts *Options) *Shell {
73 cwd := opts.WorkingDir
92 blockFuncs: opts.BlockFuncs,
96// Exec executes a command in the shell
97func (s *Shell) Exec(ctx context.Context, command string) (string, string, error) {
101 return s.execPOSIX(ctx, command)
104// GetWorkingDir returns the current working directory
105func (s *Shell) GetWorkingDir() string {
111// SetWorkingDir sets the working directory
112func (s *Shell) SetWorkingDir(dir string) error {
116 // Verify the directory exists
117 if _, err := os.Stat(dir); err != nil {
118 return fmt.Errorf("directory does not exist: %w", err)
125// GetEnv returns a copy of the environment variables
126func (s *Shell) GetEnv() []string {
130 env := make([]string, len(s.env))
135// SetEnv sets an environment variable
136func (s *Shell) SetEnv(key, value string) {
140 // Update or add the environment variable
141 keyPrefix := key + "="
142 for i, env := range s.env {
143 if strings.HasPrefix(env, keyPrefix) {
144 s.env[i] = keyPrefix + value
148 s.env = append(s.env, keyPrefix+value)
151// SetBlockFuncs sets the command block functions for the shell
152func (s *Shell) SetBlockFuncs(blockFuncs []BlockFunc) {
155 s.blockFuncs = blockFuncs
158// CommandsBlocker creates a BlockFunc that blocks exact command matches
159func CommandsBlocker(cmds []string) BlockFunc {
160 bannedSet := make(map[string]struct{})
161 for _, cmd := range cmds {
162 bannedSet[cmd] = struct{}{}
165 return func(args []string) bool {
169 _, ok := bannedSet[args[0]]
174// ArgumentsBlocker creates a BlockFunc that blocks specific subcommands
175func ArgumentsBlocker(blockedSubCommands [][]string) BlockFunc {
176 return func(args []string) bool {
177 for _, blocked := range blockedSubCommands {
178 if len(args) >= len(blocked) {
180 for i, part := range blocked {
195func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
196 return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
197 return func(ctx context.Context, args []string) error {
199 return next(ctx, args)
202 for _, blockFunc := range s.blockFuncs {
204 return fmt.Errorf("command is not allowed for security reasons: %s", strings.Join(args, " "))
208 return next(ctx, args)
213// execPOSIX executes commands using POSIX shell emulation (cross-platform)
214func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string, error) {
215 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
217 return "", "", fmt.Errorf("could not parse command: %w", err)
220 var stdout, stderr bytes.Buffer
221 runner, err := interp.New(
222 interp.StdIO(nil, &stdout, &stderr),
223 interp.Interactive(false),
224 interp.Env(expand.ListEnviron(s.env...)),
226 interp.ExecHandlers(s.blockHandler(), coreutils.ExecHandler),
229 return "", "", fmt.Errorf("could not run command: %w", err)
232 err = runner.Run(ctx, line)
235 for name, vr := range runner.Vars {
236 s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
238 s.logger.InfoPersist("POSIX command finished", "command", command, "err", err)
239 return stdout.String(), stderr.String(), err
242// IsInterrupt checks if an error is due to interruption
243func IsInterrupt(err error) bool {
244 return errors.Is(err, context.Canceled) ||
245 errors.Is(err, context.DeadlineExceeded)
248// ExitCode extracts the exit code from an error
249func ExitCode(err error) int {
253 var exitErr interp.ExitStatus
254 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