shell.go

 1// Package shell provides cross-platform shell execution capabilities.
 2// 
 3// WINDOWS COMPATIBILITY NOTE:
 4// This implementation uses mvdan.cc/sh/v3 which provides POSIX shell emulation
 5// on Windows. While this works for basic commands, it has limitations:
 6// - Windows-specific commands (dir, type, copy) are not available
 7// - PowerShell and cmd.exe specific features are not supported
 8// - Some Windows path handling may be inconsistent
 9// 
10// For full Windows compatibility, consider adding native Windows shell support
11// using os/exec with cmd.exe or PowerShell for Windows-specific commands.
12package shell
13
14import (
15	"bytes"
16	"context"
17	"errors"
18	"fmt"
19	"os"
20	"strings"
21	"sync"
22
23	"github.com/charmbracelet/crush/internal/logging"
24	"mvdan.cc/sh/v3/expand"
25	"mvdan.cc/sh/v3/interp"
26	"mvdan.cc/sh/v3/syntax"
27)
28
29type PersistentShell struct {
30	env []string
31	cwd string
32	mu  sync.Mutex
33}
34
35var (
36	once          sync.Once
37	shellInstance *PersistentShell
38)
39
40func GetPersistentShell(cwd string) *PersistentShell {
41	once.Do(func() {
42		shellInstance = newPersistentShell(cwd)
43	})
44	return shellInstance
45}
46
47func newPersistentShell(cwd string) *PersistentShell {
48	return &PersistentShell{
49		cwd: cwd,
50		env: os.Environ(),
51	}
52}
53
54func (s *PersistentShell) Exec(ctx context.Context, command string) (string, string, error) {
55	s.mu.Lock()
56	defer s.mu.Unlock()
57
58	line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
59	if err != nil {
60		return "", "", fmt.Errorf("could not parse command: %w", err)
61	}
62
63	var stdout, stderr bytes.Buffer
64	runner, err := interp.New(
65		interp.StdIO(nil, &stdout, &stderr),
66		interp.Interactive(false),
67		interp.Env(expand.ListEnviron(s.env...)),
68		interp.Dir(s.cwd),
69	)
70	if err != nil {
71		return "", "", fmt.Errorf("could not run command: %w", err)
72	}
73
74	err = runner.Run(ctx, line)
75	s.cwd = runner.Dir
76	s.env = []string{}
77	for name, vr := range runner.Vars {
78		s.env = append(s.env, fmt.Sprintf("%s=%s", name, vr.Str))
79	}
80	logging.InfoPersist("Command finished", "command", command, "err", err)
81	return stdout.String(), stderr.String(), err
82}
83
84func IsInterrupt(err error) bool {
85	return errors.Is(err, context.Canceled) ||
86		errors.Is(err, context.DeadlineExceeded)
87}
88
89func ExitCode(err error) int {
90	if err == nil {
91		return 0
92	}
93	status, ok := interp.IsExitStatus(err)
94	if ok {
95		return int(status)
96	}
97	return 1
98}