package api // Cross-platform terminal session abstraction. // // On Linux / macOS we have a real PTY via creack/pty: full TTY semantics, // resize support, interactive apps (vim, top…) work. On Windows the same // package returns "operating system not supported" at pty.Start time, so we // fall back to plain pipes (stdin / stdout merged with stderr). Pipes don't // give a real TTY — interactive TUIs misbehave — but `wsl`, `pwsh`, `cmd`, // and most CLI tools emit usable line-buffered output, which is what the // user actually clicks for. import ( "io" "os" "os/exec" "runtime" "sync" "github.com/creack/pty/v2" ) // termSession is the read/write/resize/close surface used by handleTerminalWS. type termSession interface { Read([]byte) (int, error) Write([]byte) (int, error) Resize(rows, cols uint16) error Close() error Wait() error Pid() int } // startTermSession tries a real PTY first; on Windows or any pty.Start failure // it falls back to a pipe-based session. func startTermSession(cmd *exec.Cmd) (termSession, error) { if runtime.GOOS != "windows" { ptmx, err := pty.Start(cmd) if err == nil { return &ptySession{ptmx: ptmx, cmd: cmd}, nil } // On unix, a pty.Start error is fatal — pipes won't help interactive // shells without a TTY, and the unix build is the supported path. return nil, err } return startPipeSession(cmd) } // ptySession wraps creack/pty's *os.File-backed PTY. type ptySession struct { ptmx *os.File cmd *exec.Cmd } func (s *ptySession) Read(p []byte) (int, error) { return s.ptmx.Read(p) } func (s *ptySession) Write(p []byte) (int, error) { return s.ptmx.Write(p) } func (s *ptySession) Resize(rows, cols uint16) error { return pty.Setsize(s.ptmx, &pty.Winsize{Rows: rows, Cols: cols}) } func (s *ptySession) Close() error { err := s.ptmx.Close() if s.cmd.Process != nil { s.cmd.Process.Kill() } return err } func (s *ptySession) Wait() error { if s.cmd.Process == nil { return nil } return s.cmd.Wait() } func (s *ptySession) Pid() int { if s.cmd.Process == nil { return 0 } return s.cmd.Process.Pid } // pipeSession is the Windows fallback: stdin pipe + merged stdout/stderr pipe, // running concurrently. Resize is a no-op (no TTY to send TIOCSWINSZ to). type pipeSession struct { cmd *exec.Cmd stdin io.WriteCloser stdout io.ReadCloser stderr io.ReadCloser mu sync.Mutex merged chan []byte closed bool closeCh chan struct{} } func startPipeSession(cmd *exec.Cmd) (termSession, error) { stdin, err := cmd.StdinPipe() if err != nil { return nil, err } stdout, err := cmd.StdoutPipe() if err != nil { stdin.Close() return nil, err } stderr, err := cmd.StderrPipe() if err != nil { stdin.Close() stdout.Close() return nil, err } if err := cmd.Start(); err != nil { stdin.Close() stdout.Close() stderr.Close() return nil, err } s := &pipeSession{ cmd: cmd, stdin: stdin, stdout: stdout, stderr: stderr, merged: make(chan []byte, 32), closeCh: make(chan struct{}), } go s.pump(stdout) go s.pump(stderr) return s, nil } func (s *pipeSession) pump(r io.ReadCloser) { buf := make([]byte, 4096) for { n, err := r.Read(buf) if n > 0 { chunk := make([]byte, n) copy(chunk, buf[:n]) select { case s.merged <- chunk: case <-s.closeCh: return } } if err != nil { return } } } func (s *pipeSession) Read(p []byte) (int, error) { select { case chunk, ok := <-s.merged: if !ok { return 0, io.EOF } n := copy(p, chunk) return n, nil case <-s.closeCh: return 0, io.EOF } } func (s *pipeSession) Write(p []byte) (int, error) { return s.stdin.Write(p) } func (s *pipeSession) Resize(rows, cols uint16) error { // No real TTY → resize is a no-op; the child won't get SIGWINCH. return nil } func (s *pipeSession) Close() error { s.mu.Lock() if s.closed { s.mu.Unlock() return nil } s.closed = true close(s.closeCh) s.mu.Unlock() s.stdin.Close() s.stdout.Close() s.stderr.Close() if s.cmd.Process != nil { s.cmd.Process.Kill() } return nil } func (s *pipeSession) Wait() error { if s.cmd.Process == nil { return nil } return s.cmd.Wait() } func (s *pipeSession) Pid() int { if s.cmd.Process == nil { return 0 } return s.cmd.Process.Pid }