11package handlers
22
33import (
4+ "bufio"
45 "context"
6+ "encoding/base64"
7+ "encoding/json"
58 "fmt"
9+ "io"
610
711 "github.com/hdresearch/vers-cli/internal/app"
812 "github.com/hdresearch/vers-cli/internal/presenters"
@@ -13,8 +17,28 @@ import (
1317)
1418
1519type ExecuteReq struct {
16- Target string
17- Command []string
20+ Target string
21+ Command []string
22+ WorkingDir string
23+ Env map [string ]string
24+ TimeoutSec uint64
25+ UseSSH bool
26+ }
27+
28+ // streamResponse represents a single NDJSON line from the exec stream.
29+ // The orchestrator flattens the agent protocol into:
30+ //
31+ // {"type":"chunk","stream":"stdout","data_b64":"...","cursor":N,"exec_id":"..."}
32+ // {"type":"exit","exit_code":0,"cursor":N,"exec_id":"..."}
33+ // {"type":"error","code":"...","message":"..."}
34+ type streamResponse struct {
35+ Type string `json:"type"`
36+ Stream string `json:"stream,omitempty"`
37+ DataB64 string `json:"data_b64,omitempty"`
38+ ExitCode * int `json:"exit_code,omitempty"`
39+ Cursor uint64 `json:"cursor,omitempty"`
40+ Code string `json:"code,omitempty"`
41+ Message string `json:"message,omitempty"`
1842}
1943
2044func HandleExecute (ctx context.Context , a * app.App , r ExecuteReq ) (presenters.ExecuteView , error ) {
@@ -27,21 +51,106 @@ func HandleExecute(ctx context.Context, a *app.App, r ExecuteReq) (presenters.Ex
2751 v .UsedHEAD = t .UsedHEAD
2852 v .HeadID = t .HeadID
2953
54+ if r .UseSSH {
55+ return handleExecuteSSH (ctx , a , r , t , v )
56+ }
57+
58+ return handleExecuteAPI (ctx , a , r , t , v )
59+ }
60+
61+ // handleExecuteAPI runs the command via the orchestrator exec/stream API.
62+ func handleExecuteAPI (ctx context.Context , a * app.App , r ExecuteReq , t utils.TargetResult , v presenters.ExecuteView ) (presenters.ExecuteView , error ) {
63+ // Wrap the command in bash -c so shell features work
64+ command := []string {"bash" , "-c" , utils .ShellJoin (r .Command )}
65+
66+ body , err := vmSvc .ExecStream (ctx , t .Ident , vmSvc.ExecRequest {
67+ Command : command ,
68+ Env : r .Env ,
69+ WorkingDir : r .WorkingDir ,
70+ TimeoutSec : r .TimeoutSec ,
71+ })
72+ if err != nil {
73+ return v , fmt .Errorf ("exec: %w" , err )
74+ }
75+ defer body .Close ()
76+
77+ exitCode , err := streamExecOutput (body , a .IO .Out , a .IO .Err )
78+ if err != nil {
79+ return v , fmt .Errorf ("exec stream: %w" , err )
80+ }
81+
82+ v .ExitCode = exitCode
83+ return v , nil
84+ }
85+
86+ // handleExecuteSSH runs the command via direct SSH (legacy fallback).
87+ func handleExecuteSSH (ctx context.Context , a * app.App , r ExecuteReq , t utils.TargetResult , v presenters.ExecuteView ) (presenters.ExecuteView , error ) {
3088 info , err := vmSvc .GetConnectInfo (ctx , a .Client , t .Ident )
3189 if err != nil {
3290 return v , fmt .Errorf ("failed to get VM information: %w" , err )
3391 }
3492
35- sshHost := info .Host
3693 cmdStr := utils .ShellJoin (r .Command )
37-
38- client := sshutil .NewClient (sshHost , info .KeyPath , info .VMDomain )
94+ client := sshutil .NewClient (info .Host , info .KeyPath , info .VMDomain )
3995 err = client .Execute (ctx , cmdStr , a .IO .Out , a .IO .Err )
4096 if err != nil {
4197 if exitErr , ok := err .(* ssh.ExitError ); ok {
42- return v , fmt .Errorf ("command exited with code %d" , exitErr .ExitStatus ())
98+ v .ExitCode = exitErr .ExitStatus ()
99+ return v , nil
43100 }
44101 return v , fmt .Errorf ("failed to execute command: %w" , err )
45102 }
46103 return v , nil
47104}
105+
106+ // streamExecOutput reads NDJSON from the exec stream, writes stdout/stderr
107+ // to the provided writers, and returns the exit code.
108+ func streamExecOutput (body io.Reader , stdout , stderr io.Writer ) (int , error ) {
109+ scanner := bufio .NewScanner (body )
110+ // Allow large lines (agent can send up to 10MB of output)
111+ scanner .Buffer (make ([]byte , 0 , 64 * 1024 ), 16 * 1024 * 1024 )
112+
113+ exitCode := 0
114+
115+ for scanner .Scan () {
116+ line := scanner .Bytes ()
117+ if len (line ) == 0 {
118+ continue
119+ }
120+
121+ var resp streamResponse
122+ if err := json .Unmarshal (line , & resp ); err != nil {
123+ // Skip unparseable lines
124+ continue
125+ }
126+
127+ switch resp .Type {
128+ case "chunk" :
129+ data , err := base64 .StdEncoding .DecodeString (resp .DataB64 )
130+ if err != nil {
131+ continue
132+ }
133+ switch resp .Stream {
134+ case "stdout" :
135+ stdout .Write (data )
136+ case "stderr" :
137+ stderr .Write (data )
138+ }
139+
140+ case "exit" :
141+ if resp .ExitCode != nil {
142+ exitCode = * resp .ExitCode
143+ }
144+ return exitCode , nil
145+
146+ case "error" :
147+ return 1 , fmt .Errorf ("exec error [%s]: %s" , resp .Code , resp .Message )
148+ }
149+ }
150+
151+ if err := scanner .Err (); err != nil {
152+ return 1 , fmt .Errorf ("stream read error: %w" , err )
153+ }
154+
155+ return exitCode , nil
156+ }
0 commit comments