summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-08 20:40:21 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-08 20:40:21 +0000
commit181a37698410b68e00a885593b6f2b7acf21f4b4 (patch)
tree2e61d2485eaed8300882834152315cc2be5de6a1 /internal
parent363fc9ead6276cba51b4a72b4349d49ce7ca0f3d (diff)
api: generic ScriptRegistry; collapse script endpoints
Replace hardcoded handleStartNextTask/handleDeploy with a single handleScript handler keyed by name from a ScriptRegistry map. Scripts are now configured via Server.SetScripts() rather than individual setter fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/api/scripts.go64
-rw-r--r--internal/api/scripts_test.go83
2 files changed, 92 insertions, 55 deletions
diff --git a/internal/api/scripts.go b/internal/api/scripts.go
index 9afbb75..822bd32 100644
--- a/internal/api/scripts.go
+++ b/internal/api/scripts.go
@@ -5,62 +5,33 @@ import (
"context"
"net/http"
"os/exec"
- "path/filepath"
"time"
)
const scriptTimeout = 30 * time.Second
-func (s *Server) startNextTaskScriptPath() string {
- if s.startNextTaskScript != "" {
- return s.startNextTaskScript
- }
- return filepath.Join(s.workDir, "scripts", "start-next-task")
-}
+// ScriptRegistry maps endpoint names to executable script paths.
+// Only registered scripts are exposed via POST /api/scripts/{name}.
+type ScriptRegistry map[string]string
-func (s *Server) deployScriptPath() string {
- if s.deployScript != "" {
- return s.deployScript
- }
- return filepath.Join(s.workDir, "scripts", "deploy")
+// SetScripts configures the script registry. The mux is not re-registered;
+// the handler looks up the registry at request time, so this may be called
+// after NewServer but before the first request.
+func (s *Server) SetScripts(r ScriptRegistry) {
+ s.scripts = r
}
-func (s *Server) handleStartNextTask(w http.ResponseWriter, r *http.Request) {
- ctx, cancel := context.WithTimeout(r.Context(), scriptTimeout)
- defer cancel()
-
- scriptPath := s.startNextTaskScriptPath()
- cmd := exec.CommandContext(ctx, scriptPath)
-
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
-
- err := cmd.Run()
- exitCode := 0
- if err != nil {
- if exitErr, ok := err.(*exec.ExitError); ok {
- exitCode = exitErr.ExitCode()
- } else {
- s.logger.Error("start-next-task: script execution failed", "error", err, "path", scriptPath)
- writeJSON(w, http.StatusInternalServerError, map[string]string{
- "error": "script execution failed: " + err.Error(),
- })
- return
- }
+func (s *Server) handleScript(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ scriptPath, ok := s.scripts[name]
+ if !ok {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "script not found: " + name})
+ return
}
- writeJSON(w, http.StatusOK, map[string]interface{}{
- "output": stdout.String(),
- "exit_code": exitCode,
- })
-}
-
-func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), scriptTimeout)
defer cancel()
- scriptPath := s.deployScriptPath()
cmd := exec.CommandContext(ctx, scriptPath)
var stdout, stderr bytes.Buffer
@@ -72,17 +43,18 @@ func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) {
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
+ s.logger.Warn("script exited non-zero", "name", name, "exit_code", exitCode, "stderr", stderr.String())
} else {
- s.logger.Error("deploy: script execution failed", "error", err, "path", scriptPath)
+ s.logger.Error("script execution failed", "name", name, "error", err, "path", scriptPath)
writeJSON(w, http.StatusInternalServerError, map[string]string{
- "error": "script execution failed: " + err.Error(),
+ "error": "script execution failed",
})
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
- "output": stdout.String() + stderr.String(),
+ "output": stdout.String(),
"exit_code": exitCode,
})
}
diff --git a/internal/api/scripts_test.go b/internal/api/scripts_test.go
index 7da133e..f5ece20 100644
--- a/internal/api/scripts_test.go
+++ b/internal/api/scripts_test.go
@@ -6,20 +6,65 @@ import (
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
)
+func TestServer_NoScripts_Returns404(t *testing.T) {
+ srv, _ := testServer(t)
+ // No scripts configured — all /api/scripts/* should return 404.
+ for _, name := range []string{"deploy", "start-next-task", "unknown"} {
+ req := httptest.NewRequest("POST", "/api/scripts/"+name, nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+ if w.Code != http.StatusNotFound {
+ t.Errorf("POST /api/scripts/%s: want 404, got %d", name, w.Code)
+ }
+ }
+}
+
+func TestServer_WithScripts_RunsRegisteredScript(t *testing.T) {
+ srv, _ := testServer(t)
+
+ scriptDir := t.TempDir()
+ scriptPath := filepath.Join(scriptDir, "my-script")
+ if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho hello"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ srv.SetScripts(ScriptRegistry{"my-script": scriptPath})
+
+ req := httptest.NewRequest("POST", "/api/scripts/my-script", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestServer_WithScripts_UnregisteredReturns404(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetScripts(ScriptRegistry{"deploy": "/some/path"})
+
+ req := httptest.NewRequest("POST", "/api/scripts/other", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("want 404, got %d", w.Code)
+ }
+}
+
func TestHandleDeploy_Success(t *testing.T) {
srv, _ := testServer(t)
- // Create a fake deploy script that exits 0 and prints output.
scriptDir := t.TempDir()
scriptPath := filepath.Join(scriptDir, "deploy")
- script := "#!/bin/sh\necho 'deployed successfully'"
- if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
+ if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho 'deployed successfully'"), 0o755); err != nil {
t.Fatal(err)
}
- srv.deployScript = scriptPath
+ srv.SetScripts(ScriptRegistry{"deploy": scriptPath})
req := httptest.NewRequest("POST", "/api/scripts/deploy", nil)
w := httptest.NewRecorder()
@@ -35,8 +80,7 @@ func TestHandleDeploy_Success(t *testing.T) {
if body["exit_code"] != float64(0) {
t.Errorf("exit_code: want 0, got %v", body["exit_code"])
}
- output, _ := body["output"].(string)
- if output == "" {
+ if output, _ := body["output"].(string); output == "" {
t.Errorf("expected non-empty output")
}
}
@@ -46,11 +90,10 @@ func TestHandleDeploy_ScriptFails(t *testing.T) {
scriptDir := t.TempDir()
scriptPath := filepath.Join(scriptDir, "deploy")
- script := "#!/bin/sh\necho 'build failed' && exit 1"
- if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
+ if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho 'build failed' && exit 1"), 0o755); err != nil {
t.Fatal(err)
}
- srv.deployScript = scriptPath
+ srv.SetScripts(ScriptRegistry{"deploy": scriptPath})
req := httptest.NewRequest("POST", "/api/scripts/deploy", nil)
w := httptest.NewRecorder()
@@ -67,3 +110,25 @@ func TestHandleDeploy_ScriptFails(t *testing.T) {
t.Errorf("expected non-zero exit_code")
}
}
+
+func TestHandleScript_StderrNotLeakedToResponse(t *testing.T) {
+ srv, _ := testServer(t)
+
+ scriptDir := t.TempDir()
+ scriptPath := filepath.Join(scriptDir, "deploy")
+ // Script writes sensitive info to stderr and exits non-zero.
+ script := "#!/bin/sh\necho 'stdout output'\necho 'SECRET_TOKEN=abc123' >&2\nexit 1"
+ if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ srv.SetScripts(ScriptRegistry{"deploy": scriptPath})
+
+ req := httptest.NewRequest("POST", "/api/scripts/deploy", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ body := w.Body.String()
+ if strings.Contains(body, "SECRET_TOKEN") {
+ t.Errorf("response must not contain stderr content; got: %s", body)
+ }
+}