Skip to content

Commit 0542167

Browse files
committed
Generate exec feature
1 parent 1a5aced commit 0542167

File tree

6 files changed

+349
-0
lines changed

6 files changed

+349
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
dist/
33
/hypeman
44
.env
5+
hypeman/**

cmd/hypeman/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import (
1717
func main() {
1818
app := cmd.Command
1919
if err := app.Run(context.Background(), os.Args); err != nil {
20+
// Handle exec exit codes specially - exit with the command's exit code
21+
var execErr *cmd.ExecExitError
22+
if errors.As(err, &execErr) {
23+
os.Exit(execErr.Code)
24+
}
25+
2026
var apierr *hypeman.Error
2127
if errors.As(err, &apierr) {
2228
fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode))

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ require (
2525
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
2626
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
2727
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
28+
github.com/gorilla/websocket v1.5.3 // indirect
2829
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
2930
github.com/mattn/go-isatty v0.0.20 // indirect
3031
github.com/mattn/go-localereader v0.0.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
2424
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2525
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
2626
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
27+
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
28+
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
2729
github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8=
2830
github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI=
2931
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=

pkg/cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func init() {
6767
},
6868
},
6969
Commands: []*cli.Command{
70+
&execCmd,
7071
{
7172
Name: "health",
7273
Category: "API RESOURCE",

pkg/cmd/exec.go

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"os"
12+
"os/signal"
13+
"strings"
14+
"syscall"
15+
16+
"github.com/gorilla/websocket"
17+
"github.com/urfave/cli/v3"
18+
"golang.org/x/term"
19+
)
20+
21+
// ExecExitError is returned when exec completes with a non-zero exit code
22+
type ExecExitError struct {
23+
Code int
24+
}
25+
26+
func (e *ExecExitError) Error() string {
27+
return fmt.Sprintf("exec exited with code %d", e.Code)
28+
}
29+
30+
// execRequest represents the JSON body for exec requests
31+
type execRequest struct {
32+
Command []string `json:"command"`
33+
TTY bool `json:"tty"`
34+
Env map[string]string `json:"env,omitempty"`
35+
Cwd string `json:"cwd,omitempty"`
36+
Timeout int32 `json:"timeout,omitempty"`
37+
}
38+
39+
var execCmd = cli.Command{
40+
Name: "exec",
41+
Usage: "Execute a command in a running instance",
42+
ArgsUsage: "<instance-id> [-- command...]",
43+
Flags: []cli.Flag{
44+
&cli.BoolFlag{
45+
Name: "it",
46+
Aliases: []string{"i", "t"},
47+
Usage: "Enable interactive TTY mode",
48+
},
49+
&cli.BoolFlag{
50+
Name: "no-tty",
51+
Aliases: []string{"T"},
52+
Usage: "Disable TTY allocation",
53+
},
54+
&cli.StringSliceFlag{
55+
Name: "env",
56+
Aliases: []string{"e"},
57+
Usage: "Set environment variable (KEY=VALUE, can be repeated)",
58+
},
59+
&cli.StringFlag{
60+
Name: "cwd",
61+
Usage: "Working directory inside the instance",
62+
},
63+
&cli.IntFlag{
64+
Name: "timeout",
65+
Usage: "Execution timeout in seconds (0 = no timeout)",
66+
},
67+
},
68+
Action: handleExec,
69+
HideHelpCommand: true,
70+
}
71+
72+
func handleExec(ctx context.Context, cmd *cli.Command) error {
73+
args := cmd.Args().Slice()
74+
if len(args) < 1 {
75+
return fmt.Errorf("instance ID required\nUsage: hypeman exec [flags] <instance-id> [-- command...]")
76+
}
77+
78+
instanceID := args[0]
79+
var command []string
80+
81+
// Parse command after -- separator or remaining args
82+
if len(args) > 1 {
83+
command = args[1:]
84+
}
85+
86+
// Determine TTY mode
87+
tty := true // default
88+
if cmd.Bool("no-tty") {
89+
tty = false
90+
} else if cmd.Bool("it") {
91+
tty = true
92+
} else {
93+
// Auto-detect: enable TTY if stdin and stdout are terminals
94+
tty = term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
95+
}
96+
97+
// Parse environment variables
98+
env := make(map[string]string)
99+
for _, e := range cmd.StringSlice("env") {
100+
parts := strings.SplitN(e, "=", 2)
101+
if len(parts) == 2 {
102+
env[parts[0]] = parts[1]
103+
} else {
104+
fmt.Fprintf(os.Stderr, "Warning: ignoring malformed env var: %s\n", e)
105+
}
106+
}
107+
108+
// Build exec request
109+
execReq := execRequest{
110+
Command: command,
111+
TTY: tty,
112+
}
113+
if len(env) > 0 {
114+
execReq.Env = env
115+
}
116+
if cwd := cmd.String("cwd"); cwd != "" {
117+
execReq.Cwd = cwd
118+
}
119+
if timeout := cmd.Int("timeout"); timeout > 0 {
120+
execReq.Timeout = int32(timeout)
121+
}
122+
123+
reqBody, err := json.Marshal(execReq)
124+
if err != nil {
125+
return fmt.Errorf("failed to marshal request: %w", err)
126+
}
127+
128+
// Get base URL and API key
129+
baseURL := cmd.Root().String("base-url")
130+
if baseURL == "" {
131+
baseURL = os.Getenv("HYPEMAN_BASE_URL")
132+
}
133+
if baseURL == "" {
134+
baseURL = "https://api.onkernel.com"
135+
}
136+
137+
apiKey := os.Getenv("HYPEMAN_API_KEY")
138+
if apiKey == "" {
139+
return fmt.Errorf("HYPEMAN_API_KEY environment variable required")
140+
}
141+
142+
// Build WebSocket URL
143+
u, err := url.Parse(baseURL)
144+
if err != nil {
145+
return fmt.Errorf("invalid base URL: %w", err)
146+
}
147+
u.Path = fmt.Sprintf("/instances/%s/exec", instanceID)
148+
149+
// Convert scheme to WebSocket
150+
switch u.Scheme {
151+
case "https":
152+
u.Scheme = "wss"
153+
case "http":
154+
u.Scheme = "ws"
155+
}
156+
157+
// Connect WebSocket with auth header
158+
headers := http.Header{}
159+
headers.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey))
160+
161+
dialer := &websocket.Dialer{}
162+
ws, resp, err := dialer.DialContext(ctx, u.String(), headers)
163+
if err != nil {
164+
if resp != nil {
165+
body, _ := io.ReadAll(resp.Body)
166+
return fmt.Errorf("websocket connect failed (HTTP %d): %s", resp.StatusCode, string(body))
167+
}
168+
return fmt.Errorf("websocket connect failed: %w", err)
169+
}
170+
defer ws.Close()
171+
172+
// Send JSON request as first message
173+
if err := ws.WriteMessage(websocket.TextMessage, reqBody); err != nil {
174+
return fmt.Errorf("failed to send exec request: %w", err)
175+
}
176+
177+
// Run interactive or non-interactive mode
178+
var exitCode int
179+
if tty {
180+
exitCode, err = runExecInteractive(ws)
181+
} else {
182+
exitCode, err = runExecNonInteractive(ws)
183+
}
184+
185+
if err != nil {
186+
return err
187+
}
188+
189+
if exitCode != 0 {
190+
return &ExecExitError{Code: exitCode}
191+
}
192+
193+
return nil
194+
}
195+
196+
func runExecInteractive(ws *websocket.Conn) (int, error) {
197+
// Put terminal in raw mode
198+
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
199+
if err != nil {
200+
return 255, fmt.Errorf("failed to set raw mode: %w", err)
201+
}
202+
defer term.Restore(int(os.Stdin.Fd()), oldState)
203+
204+
// Handle signals gracefully
205+
sigCh := make(chan os.Signal, 1)
206+
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
207+
defer signal.Stop(sigCh)
208+
209+
errCh := make(chan error, 2)
210+
exitCodeCh := make(chan int, 1)
211+
212+
// Forward stdin to WebSocket
213+
go func() {
214+
buf := make([]byte, 32*1024)
215+
for {
216+
n, err := os.Stdin.Read(buf)
217+
if err != nil {
218+
if err != io.EOF {
219+
errCh <- fmt.Errorf("stdin read error: %w", err)
220+
}
221+
return
222+
}
223+
if n > 0 {
224+
if err := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
225+
errCh <- fmt.Errorf("websocket write error: %w", err)
226+
return
227+
}
228+
}
229+
}
230+
}()
231+
232+
// Forward WebSocket to stdout
233+
go func() {
234+
for {
235+
msgType, message, err := ws.ReadMessage()
236+
if err != nil {
237+
if !websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
238+
exitCodeCh <- 0
239+
}
240+
return
241+
}
242+
243+
// Check for exit code message
244+
if msgType == websocket.TextMessage && bytes.Contains(message, []byte("exitCode")) {
245+
var exitMsg struct {
246+
ExitCode int `json:"exitCode"`
247+
}
248+
if json.Unmarshal(message, &exitMsg) == nil {
249+
exitCodeCh <- exitMsg.ExitCode
250+
return
251+
}
252+
}
253+
254+
// Write binary messages to stdout (actual output)
255+
if msgType == websocket.BinaryMessage {
256+
os.Stdout.Write(message)
257+
}
258+
}
259+
}()
260+
261+
select {
262+
case err := <-errCh:
263+
return 255, err
264+
case exitCode := <-exitCodeCh:
265+
return exitCode, nil
266+
case <-sigCh:
267+
return 130, nil // 128 + SIGINT
268+
}
269+
}
270+
271+
func runExecNonInteractive(ws *websocket.Conn) (int, error) {
272+
errCh := make(chan error, 2)
273+
exitCodeCh := make(chan int, 1)
274+
doneCh := make(chan struct{})
275+
276+
// Forward stdin to WebSocket
277+
go func() {
278+
buf := make([]byte, 32*1024)
279+
for {
280+
n, err := os.Stdin.Read(buf)
281+
if err != nil {
282+
if err != io.EOF {
283+
errCh <- fmt.Errorf("stdin read error: %w", err)
284+
}
285+
return
286+
}
287+
if n > 0 {
288+
if err := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
289+
errCh <- fmt.Errorf("websocket write error: %w", err)
290+
return
291+
}
292+
}
293+
}
294+
}()
295+
296+
// Forward WebSocket to stdout
297+
go func() {
298+
defer close(doneCh)
299+
for {
300+
msgType, message, err := ws.ReadMessage()
301+
if err != nil {
302+
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) ||
303+
err == io.EOF {
304+
exitCodeCh <- 0
305+
return
306+
}
307+
errCh <- fmt.Errorf("websocket read error: %w", err)
308+
return
309+
}
310+
311+
// Check for exit code message
312+
if msgType == websocket.TextMessage && bytes.Contains(message, []byte("exitCode")) {
313+
var exitMsg struct {
314+
ExitCode int `json:"exitCode"`
315+
}
316+
if json.Unmarshal(message, &exitMsg) == nil {
317+
exitCodeCh <- exitMsg.ExitCode
318+
return
319+
}
320+
}
321+
322+
// Write to stdout (binary messages contain actual output)
323+
if msgType == websocket.BinaryMessage {
324+
os.Stdout.Write(message)
325+
}
326+
}
327+
}()
328+
329+
select {
330+
case err := <-errCh:
331+
return 255, err
332+
case exitCode := <-exitCodeCh:
333+
return exitCode, nil
334+
case <-doneCh:
335+
return 0, nil
336+
}
337+
}
338+

0 commit comments

Comments
 (0)