// Package handlers provides HTTP handlers for the rdev API. package handlers import ( "context" "errors" "fmt" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/sanitize" "github.com/orchard9/rdev/internal/service" "github.com/orchard9/rdev/internal/validate" "github.com/orchard9/rdev/pkg/api" ) // ClaudeRequest is the request body for POST /projects/{id}/claude. type ClaudeRequest struct { Prompt string `json:"prompt"` StreamID string `json:"stream_id,omitempty"` SessionID string `json:"session_id,omitempty"` // Resume a previous session Model string `json:"model,omitempty"` // Model override (OpenCode only) AllowedTools []string `json:"allowed_tools,omitempty"` // Restrict tool access } // RunClaude executes a Claude command in the project's claudebox. // POST /projects/{id}/claude func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req ClaudeRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Use new service if available if h.projectService != nil { result, err := h.projectService.ExecuteClaude(r.Context(), service.ExecuteClaudeRequest{ ProjectID: domain.ProjectID(id), Prompt: req.Prompt, StreamID: req.StreamID, SessionID: req.SessionID, Model: req.Model, AllowedTools: req.AllowedTools, Audit: getAuditContext(r), }) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { api.WriteBadRequest(w, r, err.Error()) return } api.WriteInternalError(w, r, "failed to execute command") return } api.WriteCreated(w, r, map[string]any{ "id": result.CommandID, "project": id, "type": "claude", "status": "running", "stream_url": result.StreamURL, }) return } // Legacy path using hexagonal types if h.projectRepo == nil || h.executor == nil { api.WriteInternalError(w, r, "no project service configured") return } project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } if err := validate.Required(req.Prompt, "prompt"); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Sanitize prompt if err := sanitize.ClaudePrompt(req.Prompt); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Validate stream ID if err := sanitize.StreamID(req.StreamID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Generate command ID cmdNum := h.cmdID.Add(1) cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) if req.StreamID != "" { cmdID = req.StreamID } // Create the command using domain types cmd := &domain.Command{ ID: domain.CommandID(cmdID), ProjectID: domain.ProjectID(id), Type: domain.CommandTypeClaude, Args: []string{req.Prompt}, StartedAt: time.Now(), } // Execute in background go h.executeCommand(cmd, project.PodName) api.WriteCreated(w, r, map[string]any{ "id": cmdID, "project": id, "type": "claude", "status": "running", "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), }) } // ShellRequest is the request body for POST /projects/{id}/shell. type ShellRequest struct { Command string `json:"command"` StreamID string `json:"stream_id,omitempty"` } // RunShell executes a shell command in the project's claudebox. // POST /projects/{id}/shell func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req ShellRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Use new service if available if h.projectService != nil { result, err := h.projectService.ExecuteShell(r.Context(), service.ExecuteShellRequest{ ProjectID: domain.ProjectID(id), Command: req.Command, StreamID: req.StreamID, Audit: getAuditContext(r), }) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { api.WriteBadRequest(w, r, err.Error()) return } api.WriteInternalError(w, r, "failed to execute command") return } api.WriteCreated(w, r, map[string]any{ "id": result.CommandID, "project": id, "type": "shell", "status": "running", "stream_url": result.StreamURL, }) return } // Legacy path using hexagonal types if h.projectRepo == nil || h.executor == nil { api.WriteInternalError(w, r, "no project service configured") return } project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } if err := validate.Required(req.Command, "command"); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Sanitize command if err := sanitize.ShellCommand(req.Command); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Validate stream ID if err := sanitize.StreamID(req.StreamID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Generate command ID cmdNum := h.cmdID.Add(1) cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) if req.StreamID != "" { cmdID = req.StreamID } // Create the command using domain types cmd := &domain.Command{ ID: domain.CommandID(cmdID), ProjectID: domain.ProjectID(id), Type: domain.CommandTypeShell, Args: []string{req.Command}, StartedAt: time.Now(), } // Execute in background go h.executeCommand(cmd, project.PodName) api.WriteCreated(w, r, map[string]any{ "id": cmdID, "project": id, "type": "shell", "status": "running", "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), }) } // GitRequest is the request body for POST /projects/{id}/git. type GitRequest struct { Args []string `json:"args"` StreamID string `json:"stream_id,omitempty"` } // RunGit executes a git command in the project's claudebox. // POST /projects/{id}/git func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") var req GitRequest if err := api.DecodeJSON(r, &req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } // Use new service if available if h.projectService != nil { result, err := h.projectService.ExecuteGit(r.Context(), service.ExecuteGitRequest{ ProjectID: domain.ProjectID(id), Args: req.Args, StreamID: req.StreamID, Audit: getAuditContext(r), }) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) { api.WriteBadRequest(w, r, err.Error()) return } api.WriteInternalError(w, r, "failed to execute command") return } api.WriteCreated(w, r, map[string]any{ "id": result.CommandID, "project": id, "type": "git", "status": "running", "stream_url": result.StreamURL, }) return } // Legacy path using hexagonal types if h.projectRepo == nil || h.executor == nil { api.WriteInternalError(w, r, "no project service configured") return } project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id)) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id)) return } api.WriteInternalError(w, r, "failed to get project") return } if err := validate.RequiredSlice(req.Args, "args"); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Sanitize git args if err := sanitize.GitArgs(req.Args); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Validate stream ID if err := sanitize.StreamID(req.StreamID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } // Generate command ID cmdNum := h.cmdID.Add(1) cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum) if req.StreamID != "" { cmdID = req.StreamID } // Create the command using domain types cmd := &domain.Command{ ID: domain.CommandID(cmdID), ProjectID: domain.ProjectID(id), Type: domain.CommandTypeGit, Args: req.Args, StartedAt: time.Now(), } // Execute in background go h.executeCommand(cmd, project.PodName) api.WriteCreated(w, r, map[string]any{ "id": cmdID, "project": id, "type": "git", "status": "running", "stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID), }) } // executeCommand runs a command and streams output to subscribers. func (h *ProjectsHandler) executeCommand(cmd *domain.Command, podName string) { ctx, cancel := context.WithTimeout(context.Background(), TimeoutLongRunning) defer cancel() cmdID := string(cmd.ID) result, _ := h.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) { h.streams.Send(cmdID, "output", map[string]any{ "line": line.Line, "stream": line.Stream, }) }) // Send completion event h.streams.Send(cmdID, "complete", map[string]any{ "exit_code": result.ExitCode, "duration_ms": result.DurationMs, }) // Clean up stream after a delay go func() { time.Sleep(30 * time.Second) h.streams.Close(cmdID) }() }