Connor McCutcheon
/ SkyCode
terminal.go
go
package controllers
import (
	"bufio"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"
	"github.com/google/uuid"
	"theskyscape.com/repo/skycode/models"
	"theskyscape.com/repo/skykit"
)
func Terminal() (string, skykit.Handler) {
	return "terminal", &TerminalController{}
}
type TerminalController struct {
	skykit.Controller
}
func (c *TerminalController) Setup(app *skykit.Application) {
	c.Controller.Setup(app)
	// API routes for terminal
	http.HandleFunc("POST /api/workspace/init", c.Protect(c.initWorkspace, c.requireAuth))
	http.HandleFunc("POST /api/exec", c.Protect(c.execCommand, c.requireAuth))
}
func (c TerminalController) Handle(r *http.Request) skykit.Handler {
	c.Request = r
	return &c
}
// requireAuth checks authentication for API routes
func (c *TerminalController) requireAuth(app *skykit.Application, w http.ResponseWriter, r *http.Request) bool {
	user, err := app.Users.Authenticate(r)
	if err != nil || user == nil {
		jsonError(w, "unauthorized", http.StatusUnauthorized)
		return false
	}
	return true
}
// POST /api/workspace/init - Initialize workspace with files from DB
func (c *TerminalController) initWorkspace(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	// Get or create session ID
	sessionID := c.getOrCreateSessionID(w, r)
	sess, err := models.GetOrCreateSession(sessionID, user.ID)
	if err != nil {
		jsonError(w, err.Error(), http.StatusInternalServerError)
		return
	}
	// Materialize files if not already done (thread-safe, runs only once)
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"ok":      true,
		"workdir": sess.WorkDir,
	})
}
// POST /api/exec - Execute command with SSE streaming output
func (c *TerminalController) execCommand(w http.ResponseWriter, r *http.Request) {
	user, _ := c.Users.Authenticate(r)
	// Parse command from form or JSON
	var command string
	contentType := r.Header.Get("Content-Type")
	if contentType == "application/json" {
		var body struct {
			Command string `json:"command"`
		}
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			jsonError(w, "invalid JSON", http.StatusBadRequest)
			return
		}
		command = body.Command
	} else {
		r.ParseForm()
		command = r.FormValue("command")
	}
	if command == "" {
		jsonError(w, "command required", http.StatusBadRequest)
		return
	}
	// Get or create session
	sessionID := c.getOrCreateSessionID(w, r)
	sess, err := models.GetOrCreateSession(sessionID, user.ID)
	if err != nil {
		jsonError(w, err.Error(), http.StatusInternalServerError)
		return
	}
	// Initialize workspace if needed (thread-safe, runs only once)
	if err := sess.InitializeOnce(); err != nil {
		jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
		return
	}
	// Set SSE headers
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming not supported", http.StatusInternalServerError)
		return
	}
	// Create command with timeout (60 seconds)
	ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
	defer cancel()
	// Set up user tool directories for persistent installs
	userToolDir, err := models.EnsureUserToolDirs(user.ID)
	if err != nil {
		userToolDir = sess.WorkDir // Fallback to workspace
	}
	// Build PATH with user tool directories
	userPath := strings.Join([]string{
		filepath.Join(userToolDir, ".local/bin"),
		filepath.Join(userToolDir, ".npm-global/bin"),
		filepath.Join(userToolDir, "go/bin"),
		filepath.Join(userToolDir, ".cargo/bin"),
		os.Getenv("PATH"),
	}, ":")
	cmd := exec.CommandContext(ctx, "bash", "-c", command)
	cmd.Dir = sess.WorkDir
	cmd.Env = append(os.Environ(),
		"HOME="+sess.WorkDir,
		"PATH="+userPath,
		"NPM_CONFIG_PREFIX="+filepath.Join(userToolDir, ".npm-global"),
		"NPM_CONFIG_CACHE="+filepath.Join(models.CacheDir, "npm"),
		"GOPATH="+filepath.Join(userToolDir, "go"),
		"GOMODCACHE="+filepath.Join(models.CacheDir, "go/mod"),
		"CARGO_HOME="+filepath.Join(userToolDir, ".cargo"),
	)
	// Create pipes for stdout and stderr
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		fmt.Fprintf(w, "data: {\"error\": \"failed to create stdout pipe\"}\n\n")
		flusher.Flush()
		return
	}
	stderr, err := cmd.StderrPipe()
	if err != nil {
		fmt.Fprintf(w, "data: {\"error\": \"failed to create stderr pipe\"}\n\n")
		flusher.Flush()
		return
	}
	// Start the command
	if err := cmd.Start(); err != nil {
		fmt.Fprintf(w, "data: %s\n\n", err.Error())
		fmt.Fprintf(w, "event: done\ndata: {\"exitCode\":-1}\n\n")
		flusher.Flush()
		return
	}
	// Stream output from both stdout and stderr
	done := make(chan bool)
	go func() {
		c.streamOutput(w, flusher, stdout)
		done <- true
	}()
	go func() {
		c.streamOutput(w, flusher, stderr)
		done <- true
	}()
	// Wait for both streams to complete
	<-done
	<-done
	// Wait for command to finish and get exit code
	err = cmd.Wait()
	exitCode := 0
	if err != nil {
		if exitErr, ok := err.(*exec.ExitError); ok {
			exitCode = exitErr.ExitCode()
		} else {
			exitCode = -1
		}
	}
	fmt.Fprintf(w, "event: done\ndata: {\"exitCode\":%d}\n\n", exitCode)
	flusher.Flush()
}
func (c *TerminalController) streamOutput(w http.ResponseWriter, flusher http.Flusher, reader io.Reader) {
	scanner := bufio.NewScanner(reader)
	// Increase buffer size for long lines
	buf := make([]byte, 64*1024)
	scanner.Buffer(buf, 1024*1024)
	for scanner.Scan() {
		line := scanner.Text()
		// Line is already newline-free from scanner, safe for SSE
		fmt.Fprintf(w, "data: %s\n\n", line)
		flusher.Flush()
	}
}
func (c *TerminalController) getOrCreateSessionID(w http.ResponseWriter, r *http.Request) string {
	cookie, err := r.Cookie("skycode_session")
	if err == nil && cookie.Value != "" {
		return cookie.Value
	}
	// Create new session ID
	sessionID := uuid.NewString()
	http.SetCookie(w, &http.Cookie{
		Name:     "skycode_session",
		Value:    sessionID,
		Path:     "/",
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   30 * 60, // 30 minutes
	})
	return sessionID
}
No comments yet.