| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325 |
- // Package shell provides cross-platform shell execution capabilities.
- //
- // This package offers two main types:
- // - Shell: A general-purpose shell executor for one-off or managed commands
- // - PersistentShell: A singleton shell that maintains state across the application
- //
- // WINDOWS COMPATIBILITY:
- // This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
- // native Windows shell support (cmd.exe/PowerShell) for optimal compatibility.
- package shell
- import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "runtime"
- "strings"
- "sync"
- "mvdan.cc/sh/v3/expand"
- "mvdan.cc/sh/v3/interp"
- "mvdan.cc/sh/v3/syntax"
- )
- // ShellType represents the type of shell to use
- type ShellType int
- const (
- ShellTypePOSIX ShellType = iota
- ShellTypeCmd
- ShellTypePowerShell
- )
- // Logger interface for optional logging
- type Logger interface {
- InfoPersist(msg string, keysAndValues ...interface{})
- }
- // noopLogger is a logger that does nothing
- type noopLogger struct{}
- func (noopLogger) InfoPersist(msg string, keysAndValues ...interface{}) {}
- // Shell provides cross-platform shell execution with optional state persistence
- type Shell struct {
- env []string
- cwd string
- mu sync.Mutex
- logger Logger
- }
- // Options for creating a new shell
- type Options struct {
- WorkingDir string
- Env []string
- Logger Logger
- }
- // NewShell creates a new shell instance with the given options
- func NewShell(opts *Options) *Shell {
- if opts == nil {
- opts = &Options{}
- }
- cwd := opts.WorkingDir
- if cwd == "" {
- cwd, _ = os.Getwd()
- }
- env := opts.Env
- if env == nil {
- env = os.Environ()
- }
- logger := opts.Logger
- if logger == nil {
- logger = noopLogger{}
- }
- return &Shell{
- cwd: cwd,
- env: env,
- logger: logger,
- }
- }
- // Exec executes a command in the shell
- func (s *Shell) Exec(ctx context.Context, command string) (string, string, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- // Determine which shell to use based on platform and command
- shellType := s.determineShellType(command)
- switch shellType {
- case ShellTypeCmd:
- return s.execWindows(ctx, command, "cmd")
- case ShellTypePowerShell:
- return s.execWindows(ctx, command, "powershell")
- default:
- return s.execPOSIX(ctx, command)
- }
- }
- // GetWorkingDir returns the current working directory
- func (s *Shell) GetWorkingDir() string {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.cwd
- }
- // SetWorkingDir sets the working directory
- func (s *Shell) SetWorkingDir(dir string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- // Verify the directory exists
- if _, err := os.Stat(dir); err != nil {
- return fmt.Errorf("directory does not exist: %w", err)
- }
- s.cwd = dir
- return nil
- }
- // GetEnv returns a copy of the environment variables
- func (s *Shell) GetEnv() []string {
- s.mu.Lock()
- defer s.mu.Unlock()
- env := make([]string, len(s.env))
- copy(env, s.env)
- return env
- }
- // SetEnv sets an environment variable
- func (s *Shell) SetEnv(key, value string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- // Update or add the environment variable
- keyPrefix := key + "="
- for i, env := range s.env {
- if strings.HasPrefix(env, keyPrefix) {
- s.env[i] = keyPrefix + value
- return
- }
- }
- s.env = append(s.env, keyPrefix+value)
- }
- // Windows-specific commands that should use native shell
- var windowsNativeCommands = map[string]bool{
- "dir": true,
- "type": true,
- "copy": true,
- "move": true,
- "del": true,
- "md": true,
- "mkdir": true,
- "rd": true,
- "rmdir": true,
- "cls": true,
- "where": true,
- "tasklist": true,
- "taskkill": true,
- "net": true,
- "sc": true,
- "reg": true,
- "wmic": true,
- }
- // determineShellType decides which shell to use based on platform and command
- func (s *Shell) determineShellType(command string) ShellType {
- if runtime.GOOS != "windows" {
- return ShellTypePOSIX
- }
- // Extract the first command from the command line
- parts := strings.Fields(command)
- if len(parts) == 0 {
- return ShellTypePOSIX
- }
- firstCmd := strings.ToLower(parts[0])
- // Check if it's a Windows-specific command
- if windowsNativeCommands[firstCmd] {
- return ShellTypeCmd
- }
- // Check for PowerShell-specific syntax
- if strings.Contains(command, "Get-") || strings.Contains(command, "Set-") ||
- strings.Contains(command, "New-") || strings.Contains(command, "$_") ||
- strings.Contains(command, "| Where-Object") || strings.Contains(command, "| ForEach-Object") {
- return ShellTypePowerShell
- }
- // Default to POSIX emulation for cross-platform compatibility
- return ShellTypePOSIX
- }
- // execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
- func (s *Shell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
- var cmd *exec.Cmd
- // Handle directory changes specially to maintain persistent shell behavior
- if strings.HasPrefix(strings.TrimSpace(command), "cd ") {
- return s.handleWindowsCD(command)
- }
- switch shell {
- case "cmd":
- // Use cmd.exe for Windows commands
- // Add current directory context to maintain state
- fullCommand := fmt.Sprintf("cd /d \"%s\" && %s", s.cwd, command)
- cmd = exec.CommandContext(ctx, "cmd", "/C", fullCommand)
- case "powershell":
- // Use PowerShell for PowerShell commands
- // Add current directory context to maintain state
- fullCommand := fmt.Sprintf("Set-Location '%s'; %s", s.cwd, command)
- cmd = exec.CommandContext(ctx, "powershell", "-Command", fullCommand)
- default:
- return "", "", fmt.Errorf("unsupported Windows shell: %s", shell)
- }
- // Set environment variables
- cmd.Env = s.env
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
- err := cmd.Run()
- s.logger.InfoPersist("Windows command finished", "shell", shell, "command", command, "err", err)
- return stdout.String(), stderr.String(), err
- }
- // handleWindowsCD handles directory changes for Windows shells
- func (s *Shell) handleWindowsCD(command string) (string, string, error) {
- // Extract the target directory from the cd command
- parts := strings.Fields(command)
- if len(parts) < 2 {
- return "", "cd: missing directory argument", fmt.Errorf("missing directory argument")
- }
- targetDir := parts[1]
- // Handle relative paths
- if !strings.Contains(targetDir, ":") && !strings.HasPrefix(targetDir, "\\") {
- // Relative path - resolve against current directory
- if targetDir == ".." {
- // Go up one directory
- if len(s.cwd) > 3 { // Don't go above drive root (C:\)
- lastSlash := strings.LastIndex(s.cwd, "\\")
- if lastSlash > 2 { // Keep drive letter
- s.cwd = s.cwd[:lastSlash]
- }
- }
- } else if targetDir != "." {
- // Go to subdirectory
- s.cwd = s.cwd + "\\" + targetDir
- }
- } else {
- // Absolute path
- s.cwd = targetDir
- }
- // Verify the directory exists
- if _, err := os.Stat(s.cwd); err != nil {
- return "", fmt.Sprintf("cd: %s: No such file or directory", targetDir), err
- }
- return "", "", nil
- }
- // execPOSIX executes commands using POSIX shell emulation (cross-platform)
- func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string, error) {
- line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
- if err != nil {
- return "", "", fmt.Errorf("could not parse command: %w", err)
- }
- var stdout, stderr bytes.Buffer
- runner, err := interp.New(
- interp.StdIO(nil, &stdout, &stderr),
- interp.Interactive(false),
- interp.Env(expand.ListEnviron(s.env...)),
- interp.Dir(s.cwd),
- )
- if err != nil {
- return "", "", fmt.Errorf("could not run command: %w", err)
- }
- err = runner.Run(ctx, line)
- s.cwd = runner.Dir
- s.env = []string{}
- for name, vr := range runner.Vars {
- s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
- }
- s.logger.InfoPersist("POSIX command finished", "command", command, "err", err)
- return stdout.String(), stderr.String(), err
- }
- // IsInterrupt checks if an error is due to interruption
- func IsInterrupt(err error) bool {
- return errors.Is(err, context.Canceled) ||
- errors.Is(err, context.DeadlineExceeded)
- }
- // ExitCode extracts the exit code from an error
- func ExitCode(err error) int {
- if err == nil {
- return 0
- }
- status, ok := interp.IsExitStatus(err)
- if ok {
- return int(status)
- }
- return 1
- }
|