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
}