233 lines
5.7 KiB
Go
233 lines
5.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"iop/apps/node/internal/terminal"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
func startProfileSession(_ context.Context, key sessionKey, profile config.CLIProfileConf, workspace string, logger *zap.Logger) (*profileSession, error) {
|
|
if profile.Command == "" {
|
|
return nil, fmt.Errorf("target %q has no command", key.target)
|
|
}
|
|
|
|
dir, err := prepareWorkspaceDir(workspace)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
outputCh := make(chan cliOutput, 1024)
|
|
doneCh := make(chan error, 1)
|
|
|
|
sess := &profileSession{
|
|
key: key,
|
|
name: key.target,
|
|
profile: profile,
|
|
output: outputCh,
|
|
done: doneCh,
|
|
}
|
|
|
|
if profile.Terminal {
|
|
err = startTerminalProfileSession(sess, profile, dir, outputCh, doneCh)
|
|
} else {
|
|
err = startNonTerminalProfileSession(sess, profile, dir, outputCh, doneCh)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if profile.StartupIdleTimeoutMS > 0 {
|
|
drainUntilIdle(sess.output, sess.input, time.Duration(profile.StartupIdleTimeoutMS)*time.Millisecond, logger, key, profile)
|
|
}
|
|
|
|
if err := checkEarlyExit(sess); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return sess, nil
|
|
}
|
|
|
|
func startTerminalProfileSession(sess *profileSession, profile config.CLIProfileConf, dir string, outputCh chan cliOutput, doneCh chan error) error {
|
|
opts := terminal.Options{
|
|
Command: profile.Command,
|
|
Args: profile.Args,
|
|
Env: profile.Env,
|
|
Rows: terminalRows,
|
|
Cols: terminalCols,
|
|
Dir: dir,
|
|
}
|
|
core, err := terminal.StartSession(context.Background(), opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sess.core = core
|
|
sess.input = sessionWriter{sess: core}
|
|
sess.closeFn = core.Close
|
|
|
|
go func() {
|
|
for out := range core.Output() {
|
|
outputCh <- cliOutput{text: out.Text}
|
|
}
|
|
err := <-core.Done()
|
|
doneCh <- err
|
|
close(outputCh)
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func startNonTerminalProfileSession(sess *profileSession, profile config.CLIProfileConf, dir string, outputCh chan cliOutput, doneCh chan error) error {
|
|
cmd, err := buildCmdWithoutContext(profile.Command, profile.Args, profile.Env, dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("stdin pipe: %w", err)
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
_ = stdin.Close()
|
|
return fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
_ = stdin.Close()
|
|
return fmt.Errorf("start: %w", err)
|
|
}
|
|
sess.cmd = cmd
|
|
sess.input = stdin
|
|
sess.closeFn = stdin.Close
|
|
|
|
go func() {
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
text := line + "\n"
|
|
sess.appendTail(text)
|
|
outputCh <- cliOutput{text: text, markerLine: line}
|
|
}
|
|
doneCh <- cmd.Wait()
|
|
close(outputCh)
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func checkEarlyExit(sess *profileSession) error {
|
|
select {
|
|
case err := <-sess.done:
|
|
_ = sess.closeFn()
|
|
if sess.cmd != nil && sess.cmd.Process != nil {
|
|
_ = sess.cmd.Process.Kill()
|
|
}
|
|
tail := sess.getTail()
|
|
if err == nil {
|
|
if tail != "" {
|
|
return fmt.Errorf("process exited during startup: %s", tail)
|
|
}
|
|
return fmt.Errorf("process exited during startup")
|
|
}
|
|
if tail != "" {
|
|
return fmt.Errorf("process exited during startup: %w (recent output: %q)", err, tail)
|
|
}
|
|
return fmt.Errorf("process exited during startup: %w", err)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func drainUntilIdle(outputCh <-chan cliOutput, input io.Writer, timeout time.Duration, logger *zap.Logger, key sessionKey, profile config.CLIProfileConf) {
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
var startupBuf strings.Builder
|
|
acceptedClaudeWorkspaceTrust := false
|
|
acceptedClaudeBypassWarning := false
|
|
for {
|
|
select {
|
|
case out, ok := <-outputCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
if profile.Terminal && out.text != "" {
|
|
appendBounded(&startupBuf, out.text, 8192)
|
|
rawStr := startupBuf.String()
|
|
|
|
if !acceptedClaudeWorkspaceTrust && shouldAcceptClaudeWorkspaceTrust(rawStr) {
|
|
if _, err := io.WriteString(input, "\r"); err != nil {
|
|
logger.Warn("cli adapter: accept claude workspace trust", zap.String("target", key.target), zap.Error(err))
|
|
} else {
|
|
acceptedClaudeWorkspaceTrust = true
|
|
logger.Info("cli adapter: accepted claude workspace trust", zap.String("target", key.target))
|
|
startupBuf.Reset()
|
|
}
|
|
} else if !acceptedClaudeBypassWarning && shouldAcceptClaudeBypassWarning(rawStr) {
|
|
if _, err := io.WriteString(input, "\x1b[B\r"); err != nil {
|
|
logger.Warn("cli adapter: accept claude bypass warning", zap.String("target", key.target), zap.Error(err))
|
|
} else {
|
|
acceptedClaudeBypassWarning = true
|
|
logger.Info("cli adapter: accepted claude bypass warning", zap.String("target", key.target))
|
|
}
|
|
}
|
|
}
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
timer.Reset(timeout)
|
|
logger.Debug("cli adapter: startup drain", zap.String("target", key.target), zap.String("session", key.sessionID))
|
|
case <-timer.C:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func drainSessionUntilIdle(outputCh <-chan cliOutput, timeout time.Duration, logger *zap.Logger, key sessionKey) {
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case _, ok := <-outputCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
if !timer.Stop() {
|
|
select {
|
|
case <-timer.C:
|
|
default:
|
|
}
|
|
}
|
|
timer.Reset(timeout)
|
|
logger.Debug("cli adapter: cancel drain", zap.String("target", key.target), zap.String("session", key.sessionID))
|
|
case <-timer.C:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func drainPersistentDone(sess *profileSession) error {
|
|
select {
|
|
case err := <-sess.done:
|
|
return err
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
type sessionWriter struct {
|
|
sess terminal.Session
|
|
}
|
|
|
|
func (w sessionWriter) Write(p []byte) (n int, err error) {
|
|
err = w.sess.WriteInput(context.Background(), p)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return len(p), nil
|
|
}
|