package cli import ( "context" "errors" "fmt" "io" "os" "os/exec" "sync" "time" "github.com/creack/pty" "iop/apps/node/internal/adapters/cli/status" ) type terminalSessionOptions struct { Command string Args []string Env []string } type terminalSessionCore struct { cmd *exec.Cmd input io.WriteCloser output io.ReadCloser tail *status.TailBuffer outputCh chan cliOutput doneCh chan error closed bool mu sync.Mutex } func startTerminalSessionCore(ctx context.Context, opts terminalSessionOptions) (*terminalSessionCore, error) { cmd := exec.Command(opts.Command, opts.Args...) if len(opts.Env) > 0 { cmd.Env = append(cmd.Environ(), opts.Env...) } ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{ Rows: terminalRows, Cols: terminalCols, }) if err != nil { return nil, fmt.Errorf("pty start: %w", err) } core := &terminalSessionCore{ cmd: cmd, input: ptmx, output: ptmx, tail: status.NewTailBuffer(2048), outputCh: make(chan cliOutput, 1024), doneCh: make(chan error, 1), } go core.readLoop() return core, nil } func (s *terminalSessionCore) readLoop() { buf := make([]byte, 4096) for { n, err := s.output.Read(buf) if n > 0 { text := string(buf[:n]) s.tail.Append(text) s.outputCh <- cliOutput{text: text} } if err != nil { s.doneCh <- s.cmd.Wait() close(s.outputCh) return } } } func (s *terminalSessionCore) WritePrompt(ctx context.Context, prompt string) error { s.mu.Lock() if s.closed { s.mu.Unlock() return errors.New("terminal session core closed") } s.mu.Unlock() for _, r := range prompt { s.mu.Lock() if s.closed { s.mu.Unlock() return errors.New("terminal session core closed") } _, err := io.WriteString(s.input, string(r)) s.mu.Unlock() if err != nil { return err } timer := time.NewTimer(terminalInputDelay) select { case <-ctx.Done(): timer.Stop() return ctx.Err() case <-timer.C: } } s.mu.Lock() defer s.mu.Unlock() if s.closed { return errors.New("terminal session core closed") } _, err := io.WriteString(s.input, "\r") return err } func (s *terminalSessionCore) Snapshot() status.Snapshot { return status.Snapshot{ Tail: s.tail.String(), } } func (s *terminalSessionCore) Resize(rows, cols uint16) error { s.mu.Lock() defer s.mu.Unlock() if s.closed { return errors.New("terminal session core closed") } if rows == 0 || cols == 0 { return errors.New("invalid terminal size: rows and cols must be greater than 0") } f, ok := s.input.(*os.File) if !ok { return errors.New("terminal input is not a file") } return pty.Setsize(f, &pty.Winsize{Rows: rows, Cols: cols}) } func (s *terminalSessionCore) WriteInput(ctx context.Context, data []byte) error { s.mu.Lock() defer s.mu.Unlock() if s.closed { return errors.New("terminal session core closed") } _, err := s.input.Write(data) return err } func (s *terminalSessionCore) Signal(sig os.Signal) error { s.mu.Lock() defer s.mu.Unlock() if s.closed { return errors.New("terminal session core closed") } if s.cmd == nil || s.cmd.Process == nil { return errors.New("no process to signal") } return s.cmd.Process.Signal(sig) } func (s *terminalSessionCore) Close() error { s.mu.Lock() defer s.mu.Unlock() if s.closed { return nil } s.closed = true err := s.input.Close() if s.cmd != nil && s.cmd.Process != nil { _ = s.cmd.Process.Kill() } if isAlreadyClosedError(err) { return nil } return err }