mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-28 09:55:53 +00:00
* fix: update Go CI workflow and fix formatting issues - Update Go version from 1.21.x to 1.24.x to match go.mod requirements - Fix Go module cache path to use linux/go.sum instead of **/go.sum - Run gofmt on all Go files to fix formatting issues - Fix benchmark files formatting - Fix linux/pkg/api/server.go formatting This resolves the GitHub Actions CI failures related to: - Missing go.sum file (wrong cache path) - Go version mismatch - Code formatting violations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add platform-specific syscall.Select wrappers for Linux/Darwin compatibility - Create select_linux.go: handles syscall.Select returning (n int, err error) - Create select_darwin.go: handles syscall.Select returning (err error) - Update select.go to use platform-agnostic selectCall function - Resolves typecheck errors while maintaining compatibility on both platforms Tested on both macOS and Linux targets successfully. --------- Co-authored-by: Claude <noreply@anthropic.com>
358 lines
8.6 KiB
Go
358 lines
8.6 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// VibeTunnelClient implements the VibeTunnel HTTP API protocol
|
|
type VibeTunnelClient struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
authToken string
|
|
}
|
|
|
|
// SessionConfig represents session creation parameters
|
|
type SessionConfig struct {
|
|
Name string `json:"name"`
|
|
Command []string `json:"command"`
|
|
WorkingDir string `json:"workingDir"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Term string `json:"term"`
|
|
Env map[string]string `json:"env"`
|
|
}
|
|
|
|
// SessionInfo represents session metadata
|
|
type SessionInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Created time.Time `json:"created"`
|
|
ExitCode *int `json:"exit_code"`
|
|
Cmdline string `json:"cmdline"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Cwd string `json:"cwd"`
|
|
Term string `json:"term"`
|
|
}
|
|
|
|
// AsciinemaEvent represents terminal output events
|
|
type AsciinemaEvent struct {
|
|
Time float64 `json:"time"`
|
|
Type string `json:"type"`
|
|
Data string `json:"data"`
|
|
}
|
|
|
|
// StreamEvent represents SSE stream events
|
|
type StreamEvent struct {
|
|
Type string `json:"type"`
|
|
Event *AsciinemaEvent `json:"event,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// NewClient creates a new VibeTunnel API client
|
|
func NewClient(hostname string, port int) *VibeTunnelClient {
|
|
return &VibeTunnelClient{
|
|
baseURL: fmt.Sprintf("http://%s:%d", hostname, port),
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// SetAuth sets authentication token for requests
|
|
func (c *VibeTunnelClient) SetAuth(token string) {
|
|
c.authToken = token
|
|
}
|
|
|
|
// CreateSession creates a new terminal session
|
|
func (c *VibeTunnelClient) CreateSession(config SessionConfig) (*SessionInfo, error) {
|
|
data, err := json.Marshal(config)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal config: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", c.baseURL+"/api/sessions", bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.authToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var session SessionInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return &session, nil
|
|
}
|
|
|
|
// GetSession retrieves session information by ID
|
|
func (c *VibeTunnelClient) GetSession(sessionID string) (*SessionInfo, error) {
|
|
req, err := http.NewRequest("GET", c.baseURL+"/api/sessions/"+sessionID, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
if c.authToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var session SessionInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return &session, nil
|
|
}
|
|
|
|
// ListSessions retrieves all sessions
|
|
func (c *VibeTunnelClient) ListSessions() ([]SessionInfo, error) {
|
|
req, err := http.NewRequest("GET", c.baseURL+"/api/sessions", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
if c.authToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var sessions []SessionInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
// SendInput sends input to a session
|
|
func (c *VibeTunnelClient) SendInput(sessionID, input string) error {
|
|
data := map[string]string{"input": input}
|
|
jsonData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal input: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", c.baseURL+"/api/sessions/"+sessionID+"/input", bytes.NewReader(jsonData))
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.authToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SSEStream represents an SSE connection for streaming events
|
|
type SSEStream struct {
|
|
resp *http.Response
|
|
Events chan StreamEvent
|
|
Errors chan error
|
|
done chan struct{}
|
|
}
|
|
|
|
// StreamSession opens an SSE connection to stream session events
|
|
func (c *VibeTunnelClient) StreamSession(sessionID string) (*SSEStream, error) {
|
|
req, err := http.NewRequest("GET", c.baseURL+"/api/sessions/"+sessionID+"/stream", nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
req.Header.Set("Cache-Control", "no-cache")
|
|
if c.authToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("do request: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
stream := &SSEStream{
|
|
resp: resp,
|
|
Events: make(chan StreamEvent, 100),
|
|
Errors: make(chan error, 10),
|
|
done: make(chan struct{}),
|
|
}
|
|
|
|
go stream.readLoop()
|
|
|
|
return stream, nil
|
|
}
|
|
|
|
// Close closes the SSE stream
|
|
func (s *SSEStream) Close() error {
|
|
close(s.done)
|
|
return s.resp.Body.Close()
|
|
}
|
|
|
|
// readLoop processes SSE events from the stream
|
|
func (s *SSEStream) readLoop() {
|
|
defer close(s.Events)
|
|
defer close(s.Errors)
|
|
|
|
buf := make([]byte, 4096)
|
|
var buffer strings.Builder
|
|
|
|
for {
|
|
select {
|
|
case <-s.done:
|
|
return
|
|
default:
|
|
}
|
|
|
|
n, err := s.resp.Body.Read(buf)
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
s.Errors <- fmt.Errorf("read stream: %w", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
buffer.Write(buf[:n])
|
|
content := buffer.String()
|
|
|
|
// Process complete SSE events
|
|
for {
|
|
eventEnd := strings.Index(content, "\n\n")
|
|
if eventEnd == -1 {
|
|
break
|
|
}
|
|
|
|
eventData := content[:eventEnd]
|
|
content = content[eventEnd+2:]
|
|
|
|
if strings.HasPrefix(eventData, "data: ") {
|
|
jsonData := strings.TrimPrefix(eventData, "data: ")
|
|
|
|
var event StreamEvent
|
|
if err := json.Unmarshal([]byte(jsonData), &event); err != nil {
|
|
s.Errors <- fmt.Errorf("unmarshal event: %w", err)
|
|
continue
|
|
}
|
|
|
|
select {
|
|
case s.Events <- event:
|
|
case <-s.done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
buffer.Reset()
|
|
buffer.WriteString(content)
|
|
}
|
|
}
|
|
|
|
// DeleteSession deletes a session
|
|
func (c *VibeTunnelClient) DeleteSession(sessionID string) error {
|
|
req, err := http.NewRequest("DELETE", c.baseURL+"/api/sessions/"+sessionID, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
if c.authToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Ping tests server connectivity
|
|
func (c *VibeTunnelClient) Ping() error {
|
|
req, err := http.NewRequest("GET", c.baseURL+"/api/sessions", nil)
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
|
|
if c.authToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.authToken)
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("do request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|