diff --git a/.beans/beans-bz15--migrate-from-creackpty-to-aymanbagabasgo-pty-for-c.md b/.beans/beans-bz15--migrate-from-creackpty-to-aymanbagabasgo-pty-for-c.md new file mode 100644 index 00000000..3c764236 --- /dev/null +++ b/.beans/beans-bz15--migrate-from-creackpty-to-aymanbagabasgo-pty-for-c.md @@ -0,0 +1,19 @@ +--- +# beans-bz15 +title: Migrate from creack/pty to aymanbagabas/go-pty for cross-platform PTY support +status: completed +type: task +priority: normal +created_at: 2026-03-20T17:50:59Z +updated_at: 2026-03-20T17:53:49Z +--- + +Replace creack/pty (Unix-only, unmaintained) with aymanbagabas/go-pty which provides a unified API for Unix PTY and Windows ConPTY. Only internal/terminal/terminal.go needs changes. + +## Summary of Changes + +Replaced `creack/pty` with `aymanbagabas/go-pty` in `internal/terminal/terminal.go`: +- Swapped `*os.File` PTY handle for `gopty.Pty` interface (`io.ReadWriteCloser` + `Resize`) +- Swapped `*exec.Cmd` for `*gopty.Cmd` (same API: `Process`, `Start`, `Wait`) +- Added `defaultShell()` helper with Windows support (`pwsh.exe`/`cmd.exe` fallback) +- All existing tests pass, no API changes to `Session` or `Manager` diff --git a/go.mod b/go.mod index 86c146bd..15d4a23e 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,12 @@ require ( github.com/99designs/gqlgen v0.17.84 github.com/adrg/frontmatter v0.2.0 github.com/atotto/clipboard v0.1.4 + github.com/aymanbagabas/go-pty v0.2.2 github.com/blevesearch/bleve/v2 v2.5.6 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/creack/pty v1.1.24 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-gonic/gin v1.11.0 github.com/google/uuid v1.6.0 @@ -62,6 +62,7 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect @@ -99,6 +100,7 @@ require ( github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/u-root/u-root v0.11.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/urfave/cli/v3 v3.6.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index 77e67ef8..3d5c31b9 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-pty v0.2.2 h1:YZREB4eSj+1xdbbItIokX0ekjjeifgJOA+ZvxU4/WM8= +github.com/aymanbagabas/go-pty v0.2.2/go.mod h1:gfvlwH+0U66BCwxJREjJaAOEs9H1OFf3YFjI9WSiZ04= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -225,6 +227,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90 h1:zTk5683I9K62wtZ6eUa6vu6IWwVHXPnoKK5n2unAwv0= +github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90/go.mod h1:lYt+LVfZBBwDZ3+PHk4k/c/TnKOkjJXiJO73E32Mmpc= +github.com/u-root/u-root v0.11.0 h1:6gCZLOeRyevw7gbTwMj3fKxnr9+yHFlgF3N7udUVNO8= +github.com/u-root/u-root v0.11.0/go.mod h1:DBkDtiZyONk9hzVEdB/PWI9B4TxDkElWlVTHseglrZY= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index c0c51097..38b1efc0 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -4,9 +4,10 @@ import ( "fmt" "os" "os/exec" + "runtime" "sync" - "github.com/creack/pty" + gopty "github.com/aymanbagabas/go-pty" ) const scrollbackSize = 64 * 1024 // 64KB @@ -67,10 +68,10 @@ func (r *RingBuffer) Bytes() []byte { // Session represents an active PTY session with scrollback buffering. type Session struct { - id string - cmd *exec.Cmd - ptyF *os.File // PTY master file descriptor - mu sync.Mutex + id string + pty gopty.Pty + cmd *gopty.Cmd + mu sync.Mutex scrollback *RingBuffer scrollMu sync.Mutex @@ -85,14 +86,14 @@ type Session struct { // Write sends input to the PTY. func (s *Session) Write(data []byte) (int, error) { - return s.ptyF.Write(data) + return s.pty.Write(data) } // Resize changes the PTY window size. func (s *Session) Resize(cols, rows uint16) error { s.mu.Lock() defer s.mu.Unlock() - return pty.Setsize(s.ptyF, &pty.Winsize{Cols: cols, Rows: rows}) + return s.pty.Resize(int(cols), int(rows)) } // Attach connects a client to receive PTY output. @@ -149,7 +150,7 @@ func (s *Session) readLoop() { defer close(s.done) buf := make([]byte, 4096) for { - n, err := s.ptyF.Read(buf) + n, err := s.pty.Read(buf) if n > 0 { data := make([]byte, n) copy(data, buf[:n]) @@ -183,10 +184,33 @@ func (s *Session) Close() { if s.cmd.Process != nil { _ = s.cmd.Process.Kill() } - _ = s.ptyF.Close() + _ = s.pty.Close() _ = s.cmd.Wait() } +// closeSlave closes the slave end of a Unix PTY in the parent process after +// the child has been started. This is necessary on Linux so the master gets +// EOF when the child exits. On non-Unix platforms this is a no-op. +func closeSlave(p gopty.Pty) { + if up, ok := p.(gopty.UnixPty); ok { + up.Slave().Close() + } +} + +// defaultShell returns the default shell for the current platform. +func defaultShell() string { + if runtime.GOOS == "windows" { + if ps, err := exec.LookPath("pwsh.exe"); err == nil { + return ps + } + return "cmd.exe" + } + if shell := os.Getenv("SHELL"); shell != "" { + return shell + } + return "/bin/sh" +} + // EnvFunc returns extra environment variables for a given session ID. // Used to inject workspace-specific env vars (e.g. BEANS_WORKSPACE_PORT). type EnvFunc func(sessionID string) []string @@ -241,28 +265,38 @@ func (m *Manager) GetOrCreate(sessionID, workDir string, cols, rows uint16) (*Se } func (m *Manager) createLocked(sessionID, workDir string, cols, rows uint16) (*Session, error) { - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/sh" + shell := defaultShell() + + p, err := gopty.New() + if err != nil { + return nil, fmt.Errorf("failed to create PTY: %w", err) + } + + if err := p.Resize(int(cols), int(rows)); err != nil { + p.Close() + return nil, fmt.Errorf("failed to resize PTY: %w", err) } - cmd := exec.Command(shell, "-l") - cmd.Dir = workDir env := append(os.Environ(), "TERM=xterm-256color") if m.envFunc != nil { env = append(env, m.envFunc(sessionID)...) } + + cmd := p.Command(shell, "-l") + cmd.Dir = workDir cmd.Env = env - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows}) - if err != nil { + if err := cmd.Start(); err != nil { + p.Close() return nil, fmt.Errorf("failed to start PTY: %w", err) } + closeSlave(p) + sess := &Session{ id: sessionID, + pty: p, cmd: cmd, - ptyF: ptmx, scrollback: NewRingBuffer(scrollbackSize), done: make(chan struct{}), } @@ -285,28 +319,38 @@ func (m *Manager) CreateWithCommand(sessionID, workDir string, cols, rows uint16 delete(m.sessions, sessionID) } - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/sh" + shell := defaultShell() + + p, err := gopty.New() + if err != nil { + return nil, fmt.Errorf("failed to create PTY: %w", err) + } + + if err := p.Resize(int(cols), int(rows)); err != nil { + p.Close() + return nil, fmt.Errorf("failed to resize PTY: %w", err) } - cmd := exec.Command(shell, "-l", "-c", command) - cmd.Dir = workDir env := append(os.Environ(), "TERM=xterm-256color") if m.envFunc != nil { env = append(env, m.envFunc(sessionID)...) } + + cmd := p.Command(shell, "-l", "-c", command) + cmd.Dir = workDir cmd.Env = env - ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows}) - if err != nil { + if err := cmd.Start(); err != nil { + p.Close() return nil, fmt.Errorf("failed to start PTY: %w", err) } + closeSlave(p) + sess := &Session{ id: sessionID, + pty: p, cmd: cmd, - ptyF: ptmx, scrollback: NewRingBuffer(scrollbackSize), done: make(chan struct{}), }