feat: scaffold codespace backend
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/util"
|
||||
"codespace/pkg/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// writeError maps a service-layer error to an HTTP JSON error response.
|
||||
func writeError(c *gin.Context, err error) {
|
||||
code := util.CodeOf(err)
|
||||
var status int
|
||||
switch code {
|
||||
case util.CodeBadRequest:
|
||||
status = http.StatusBadRequest
|
||||
case util.CodeNotFound:
|
||||
status = http.StatusNotFound
|
||||
case util.CodeConflict:
|
||||
status = http.StatusConflict
|
||||
default:
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
msg := http.StatusText(status)
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
c.JSON(status, response.ErrorBody{Error: response.ErrorDetail{
|
||||
Code: string(code),
|
||||
Message: msg,
|
||||
}})
|
||||
}
|
||||
|
||||
// writeBadRequest is a shorthand for 400 responses without a service error.
|
||||
func writeBadRequest(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusBadRequest, response.ErrorBody{Error: response.ErrorDetail{
|
||||
Code: string(util.CodeBadRequest),
|
||||
Message: message,
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/model"
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type fileHandler struct {
|
||||
svc *service.FileService
|
||||
}
|
||||
|
||||
func (h *fileHandler) list(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
infos, err := h.svc.List(id, path)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, infos)
|
||||
}
|
||||
|
||||
func (h *fileHandler) read(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
writeBadRequest(c, "path is required")
|
||||
return
|
||||
}
|
||||
data, err := h.svc.Read(id, path)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.ReadFileResponse{
|
||||
Path: path,
|
||||
Content: string(data),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *fileHandler) write(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req model.WriteFileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeBadRequest(c, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeBadRequest(c, "path is required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Write(id, req.Path, []byte(req.Content)); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *fileHandler) mkdir(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req model.MkdirRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeBadRequest(c, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeBadRequest(c, "path is required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Mkdir(id, req.Path); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *fileHandler) remove(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
writeBadRequest(c, "path is required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Remove(id, path); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *fileHandler) rename(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req model.RenameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeBadRequest(c, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.OldPath == "" || req.NewPath == "" {
|
||||
writeBadRequest(c, "oldPath and newPath are required")
|
||||
return
|
||||
}
|
||||
if err := h.svc.Rename(id, req.OldPath, req.NewPath); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/model"
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type processHandler struct {
|
||||
svc *service.ProcessService
|
||||
}
|
||||
|
||||
func (h *processHandler) start(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.svc.Start(id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *processHandler) stop(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.svc.Stop(id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *processHandler) restart(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.svc.Restart(id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *processHandler) status(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
status, err := h.svc.Status(id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.ProcessStatusResponse{
|
||||
WorkspaceID: status.WorkspaceID,
|
||||
Running: status.Running,
|
||||
PID: status.PID,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NewRouter builds a Gin engine with all API routes registered.
|
||||
func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
wsHandler := &workspaceHandler{svc: workspaces}
|
||||
fileHandler := &fileHandler{svc: files}
|
||||
procHandler := &processHandler{svc: processes}
|
||||
|
||||
r.GET("/healthz", healthHandler)
|
||||
|
||||
api := r.Group("/api")
|
||||
api.POST("/workspaces", wsHandler.create)
|
||||
api.GET("/workspaces/:id", wsHandler.get)
|
||||
api.DELETE("/workspaces/:id", wsHandler.delete)
|
||||
|
||||
api.GET("/workspaces/:id/files", fileHandler.list)
|
||||
api.GET("/workspaces/:id/files/read", fileHandler.read)
|
||||
api.PUT("/workspaces/:id/files/write", fileHandler.write)
|
||||
api.POST("/workspaces/:id/files/mkdir", fileHandler.mkdir)
|
||||
api.DELETE("/workspaces/:id/files", fileHandler.remove)
|
||||
api.POST("/workspaces/:id/files/rename", fileHandler.rename)
|
||||
|
||||
api.POST("/workspaces/:id/process/start", procHandler.start)
|
||||
api.POST("/workspaces/:id/process/stop", procHandler.stop)
|
||||
api.POST("/workspaces/:id/process/restart", procHandler.restart)
|
||||
api.GET("/workspaces/:id/process/status", procHandler.status)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func healthHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"codespace/internal/process"
|
||||
"codespace/internal/service"
|
||||
"codespace/internal/workspace"
|
||||
)
|
||||
|
||||
func setupTestRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
wsMgr := workspace.NewLocalManager(root)
|
||||
procMgr := process.NewManager("")
|
||||
wsSvc := service.NewWorkspaceService(wsMgr, procMgr)
|
||||
fileSvc := service.NewFileService(wsMgr)
|
||||
procSvc := service.NewProcessService(wsMgr, procMgr)
|
||||
return NewRouter(wsSvc, fileSvc, procSvc)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
req := httptest.NewRequest("GET", "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("status = %q, want ok", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkspace(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{"id": "user1"})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["id"] != "user1" {
|
||||
t.Errorf("id = %q, want user1", resp["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReadFile(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
// Create workspace first.
|
||||
payload, _ := json.Marshal(map[string]string{"id": "user1"})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create workspace: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Write file.
|
||||
writePayload, _ := json.Marshal(map[string]string{"path": "main.go", "content": "package main\n"})
|
||||
req = httptest.NewRequest("PUT", "/api/workspaces/user1/files/write", bytes.NewReader(writePayload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("write file: status=%d want=%d body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
|
||||
}
|
||||
|
||||
// Read file.
|
||||
req = httptest.NewRequest("GET", "/api/workspaces/user1/files/read?path=main.go", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("read file: status=%d want=%d body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["content"] != "package main\n" {
|
||||
t.Errorf("content = %q, want 'package main\\n'", resp["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessStatus(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
// Create workspace.
|
||||
payload, _ := json.Marshal(map[string]string{"id": "user1"})
|
||||
req := httptest.NewRequest("POST", "/api/workspaces", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create workspace: status=%d", rec.Code)
|
||||
}
|
||||
|
||||
// Get process status.
|
||||
req = httptest.NewRequest("GET", "/api/workspaces/user1/process/status", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.WorkspaceID != "user1" {
|
||||
t.Errorf("workspaceId = %q, want user1", resp.WorkspaceID)
|
||||
}
|
||||
if resp.Running {
|
||||
t.Error("expected Running=false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/model"
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type workspaceHandler struct {
|
||||
svc *service.WorkspaceService
|
||||
}
|
||||
|
||||
func (h *workspaceHandler) create(c *gin.Context) {
|
||||
var req model.CreateWorkspaceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeBadRequest(c, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.ID == "" {
|
||||
writeBadRequest(c, "id is required")
|
||||
return
|
||||
}
|
||||
ws, err := h.svc.Create(req.ID)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, model.WorkspaceResponse{ID: ws.ID})
|
||||
}
|
||||
|
||||
func (h *workspaceHandler) get(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
ws, err := h.svc.Get(id)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.WorkspaceResponse{ID: ws.ID})
|
||||
}
|
||||
|
||||
func (h *workspaceHandler) delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.svc.Delete(id); err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package fs
|
||||
|
||||
import "time"
|
||||
|
||||
// FileInfo describes a file or directory within a workspace.
|
||||
// Path is always workspace-relative; host absolute paths are never exposed.
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"modTime"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package fs
|
||||
|
||||
// FileSystem abstracts file operations within a single workspace root.
|
||||
// All path arguments are workspace-relative.
|
||||
type FileSystem interface {
|
||||
List(path string) ([]FileInfo, error)
|
||||
Read(path string) ([]byte, error)
|
||||
Write(path string, data []byte) error
|
||||
Mkdir(path string) error
|
||||
Remove(path string) error
|
||||
Rename(oldPath, newPath string) error
|
||||
Stat(path string) (*FileInfo, error)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"codespace/internal/util"
|
||||
)
|
||||
|
||||
// LocalFS implements FileSystem bound to a single workspace root directory.
|
||||
type LocalFS struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewLocal creates a LocalFS whose root is absolute and cleaned if possible.
|
||||
func NewLocal(root string) *LocalFS {
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
abs = filepath.Clean(root)
|
||||
}
|
||||
return &LocalFS{root: abs}
|
||||
}
|
||||
|
||||
// resolve validates path, cleans it, and returns the absolute path on disk
|
||||
// plus the workspace-relative form. It rejects paths that escape the root.
|
||||
func (l *LocalFS) resolve(path string) (abs string, rel string, err error) {
|
||||
rel, err = util.CleanRelative(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
abs = filepath.Join(l.root, rel)
|
||||
// Final containment check: ensure abs is within root.
|
||||
if abs != l.root && !strings.HasPrefix(abs, l.root+string(filepath.Separator)) {
|
||||
return "", "", util.New(util.CodeBadRequest, "path escapes workspace root")
|
||||
}
|
||||
return abs, rel, nil
|
||||
}
|
||||
|
||||
// List returns sorted directory entries for path.
|
||||
func (l *LocalFS) List(path string) ([]FileInfo, error) {
|
||||
abs, rel, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, util.New(util.CodeNotFound, "path not found")
|
||||
}
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to list directory", err)
|
||||
}
|
||||
result := make([]FileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to get file info", err)
|
||||
}
|
||||
result = append(result, FileInfo{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(rel, e.Name()),
|
||||
IsDir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Read returns the contents of path.
|
||||
func (l *LocalFS) Read(path string) ([]byte, error) {
|
||||
abs, _, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, util.New(util.CodeNotFound, "file not found")
|
||||
}
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to read file", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Write writes data to path, creating parent directories as needed.
|
||||
func (l *LocalFS) Write(path string, data []byte) error {
|
||||
abs, _, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parent := filepath.Dir(abs)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to create parent directories", err)
|
||||
}
|
||||
if err := os.WriteFile(abs, data, 0o644); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to write file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mkdir creates a directory at path.
|
||||
func (l *LocalFS) Mkdir(path string) error {
|
||||
abs, _, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to create directory", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes path (file or directory recursively).
|
||||
func (l *LocalFS) Remove(path string) error {
|
||||
abs, _, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(abs); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to remove path", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rename moves oldPath to newPath, creating destination parent dirs.
|
||||
func (l *LocalFS) Rename(oldPath, newPath string) error {
|
||||
oldAbs, _, err := l.resolve(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newAbs, _, err := l.resolve(newPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parent := filepath.Dir(newAbs)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to create parent directories", err)
|
||||
}
|
||||
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to rename", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stat returns metadata for path.
|
||||
func (l *LocalFS) Stat(path string) (*FileInfo, error) {
|
||||
abs, rel, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, util.New(util.CodeNotFound, "path not found")
|
||||
}
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to stat path", err)
|
||||
}
|
||||
return &FileInfo{
|
||||
Name: filepath.Base(rel),
|
||||
Path: rel,
|
||||
IsDir: info.IsDir(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalFSWriteReadListStat(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fsys := NewLocal(root)
|
||||
|
||||
content := []byte("package main\n")
|
||||
if err := fsys.Write("dir/main.go", content); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
got, err := fsys.Read("dir/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if string(got) != string(content) {
|
||||
t.Errorf("Read = %q, want %q", got, content)
|
||||
}
|
||||
|
||||
entries, err := fsys.List("dir")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("List returned %d entries, want 1", len(entries))
|
||||
}
|
||||
if entries[0].Name != "main.go" {
|
||||
t.Errorf("entry name = %q, want main.go", entries[0].Name)
|
||||
}
|
||||
if entries[0].Path != "dir/main.go" {
|
||||
t.Errorf("entry path = %q, want dir/main.go", entries[0].Path)
|
||||
}
|
||||
if entries[0].IsDir {
|
||||
t.Error("expected IsDir=false")
|
||||
}
|
||||
|
||||
info, err := fsys.Stat("dir/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if info.Size != int64(len(content)) {
|
||||
t.Errorf("Stat size = %d, want %d", info.Size, len(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSRejectsPathEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fsys := NewLocal(root)
|
||||
|
||||
// Create an outside file to verify writes don't reach it.
|
||||
outside := filepath.Join(root, "..", "outside_escape_test")
|
||||
defer os.Remove(outside)
|
||||
|
||||
ops := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
}{
|
||||
{"Read", func() error { _, err := fsys.Read("../outside_escape_test"); return err }},
|
||||
{"Write", func() error { return fsys.Write("../outside_escape_test", []byte("x")) }},
|
||||
{"Mkdir", func() error { return fsys.Mkdir("../outside_escape_dir") }},
|
||||
{"Remove", func() error { return fsys.Remove("../outside_escape_test") }},
|
||||
{"Rename", func() error { return fsys.Rename("file", "../outside_escape_test") }},
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
if err := op.fn(); err == nil {
|
||||
t.Errorf("%s with ../ path should fail", op.name)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(outside); err == nil {
|
||||
t.Error("outside file should not have been created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSRenameAndRemove(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fsys := NewLocal(root)
|
||||
|
||||
if err := fsys.Write("old.txt", []byte("hello")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
if err := fsys.Rename("old.txt", "new.txt"); err != nil {
|
||||
t.Fatalf("Rename: %v", err)
|
||||
}
|
||||
|
||||
if _, err := fsys.Stat("old.txt"); err == nil {
|
||||
t.Error("old.txt should not exist after rename")
|
||||
}
|
||||
|
||||
data, err := fsys.Read("new.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Read new.txt: %v", err)
|
||||
}
|
||||
if string(data) != "hello" {
|
||||
t.Errorf("Read new.txt = %q, want hello", data)
|
||||
}
|
||||
|
||||
if err := fsys.Remove("new.txt"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
if _, err := fsys.Stat("new.txt"); err == nil {
|
||||
t.Error("new.txt should not exist after remove")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSListSorted(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fsys := NewLocal(root)
|
||||
|
||||
for _, name := range []string{"c.txt", "a.txt", "b.txt"} {
|
||||
if err := fsys.Write(name, []byte("x")); err != nil {
|
||||
t.Fatalf("Write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := fsys.List(".")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
|
||||
names := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
names[i] = e.Name
|
||||
}
|
||||
if !sort.StringsAreSorted(names) {
|
||||
t.Errorf("List not sorted: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
// WriteFileRequest is the request body for writing a file.
|
||||
type WriteFileRequest struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// MkdirRequest is the request body for creating a directory.
|
||||
type MkdirRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// RenameRequest is the request body for renaming a file or directory.
|
||||
type RenameRequest struct {
|
||||
OldPath string `json:"oldPath"`
|
||||
NewPath string `json:"newPath"`
|
||||
}
|
||||
|
||||
// ReadFileResponse is the API response for reading a file.
|
||||
type ReadFileResponse struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package model
|
||||
|
||||
// ProcessStatusResponse is the API response for process status.
|
||||
type ProcessStatusResponse struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Running bool `json:"running"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
// CreateWorkspaceRequest is the request body for creating a workspace.
|
||||
type CreateWorkspaceRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// WorkspaceResponse is the API response for a workspace.
|
||||
type WorkspaceResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"codespace/internal/util"
|
||||
)
|
||||
|
||||
// Manager manages OpenCode processes per workspace.
|
||||
type Manager interface {
|
||||
Start(workspaceID string, workspaceRoot string) error
|
||||
Stop(workspaceID string) error
|
||||
Restart(workspaceID string, workspaceRoot string) error
|
||||
Status(workspaceID string) Status
|
||||
}
|
||||
|
||||
// LocalManager implements Manager using local OS processes.
|
||||
type LocalManager struct {
|
||||
command string
|
||||
args []string
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
}
|
||||
|
||||
// NewManager creates a LocalManager with the given command.
|
||||
// If command is empty, it defaults to "opencode".
|
||||
func NewManager(command string) *LocalManager {
|
||||
return &LocalManager{
|
||||
command: normalizeCommand(command),
|
||||
args: []string{},
|
||||
sessions: make(map[string]*Session),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches a process for the given workspace.
|
||||
// Returns CodeConflict if a session is already running.
|
||||
func (m *LocalManager) Start(workspaceID string, workspaceRoot string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if sess, ok := m.sessions[workspaceID]; ok && sess.Cmd.Process != nil {
|
||||
if sess.Cmd.ProcessState == nil {
|
||||
return util.New(util.CodeConflict, "process already running")
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(m.command, m.args...)
|
||||
cmd.Dir = workspaceRoot
|
||||
cmd.Env = append(os.Environ(), fmt.Sprintf("HOME=%s", workspaceRoot))
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to start process", err)
|
||||
}
|
||||
|
||||
m.sessions[workspaceID] = &Session{
|
||||
WorkspaceID: workspaceID,
|
||||
Root: workspaceRoot,
|
||||
Cmd: cmd,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop kills the process for the given workspace.
|
||||
// Returns CodeNotFound if no session exists.
|
||||
func (m *LocalManager) Stop(workspaceID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
sess, ok := m.sessions[workspaceID]
|
||||
if !ok || sess.Cmd.Process == nil {
|
||||
return util.New(util.CodeNotFound, "no running process for workspace")
|
||||
}
|
||||
|
||||
if err := sess.Cmd.Process.Kill(); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to stop process", err)
|
||||
}
|
||||
|
||||
delete(m.sessions, workspaceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart stops (if running) then starts the process.
|
||||
func (m *LocalManager) Restart(workspaceID string, workspaceRoot string) error {
|
||||
m.mu.Lock()
|
||||
if sess, ok := m.sessions[workspaceID]; ok && sess.Cmd.Process != nil {
|
||||
if sess.Cmd.ProcessState == nil {
|
||||
_ = sess.Cmd.Process.Kill()
|
||||
delete(m.sessions, workspaceID)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
return m.Start(workspaceID, workspaceRoot)
|
||||
}
|
||||
|
||||
// Status returns the current process status for the workspace.
|
||||
func (m *LocalManager) Status(workspaceID string) Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
sess, ok := m.sessions[workspaceID]
|
||||
if !ok || sess.Cmd.Process == nil {
|
||||
return Status{WorkspaceID: workspaceID, Running: false}
|
||||
}
|
||||
|
||||
if sess.Cmd.ProcessState != nil {
|
||||
return Status{WorkspaceID: workspaceID, Running: false}
|
||||
}
|
||||
|
||||
return Status{
|
||||
WorkspaceID: workspaceID,
|
||||
Running: true,
|
||||
PID: sess.Cmd.Process.Pid,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"codespace/internal/util"
|
||||
)
|
||||
|
||||
func TestStatusBeforeStartIsNotRunning(t *testing.T) {
|
||||
m := NewManager("opencode")
|
||||
s := m.Status("ws1")
|
||||
if s.Running {
|
||||
t.Error("expected Running=false before start")
|
||||
}
|
||||
if s.PID != 0 {
|
||||
t.Errorf("expected PID=0, got %d", s.PID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartRejectsDuplicateRunningSession(t *testing.T) {
|
||||
m := NewManager("")
|
||||
// Override command for testing using the test binary itself.
|
||||
m.command = os.Args[0]
|
||||
m.args = []string{"-test.run=TestHelperProcess", "--"}
|
||||
// Set env so the helper process enters its blocking select loop.
|
||||
t.Setenv("CODESPACE_HELPER_PROCESS", "1")
|
||||
|
||||
root := t.TempDir()
|
||||
|
||||
if err := m.Start("ws1", root); err != nil {
|
||||
t.Fatalf("first Start: %v", err)
|
||||
}
|
||||
defer m.Stop("ws1")
|
||||
|
||||
err := m.Start("ws1", root)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on duplicate start")
|
||||
}
|
||||
if code := util.CodeOf(err); code != util.CodeConflict {
|
||||
t.Errorf("expected conflict error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopMissingSessionIsNotFound(t *testing.T) {
|
||||
m := NewManager("opencode")
|
||||
err := m.Stop("nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("expected error stopping missing session")
|
||||
}
|
||||
if code := util.CodeOf(err); code != util.CodeNotFound {
|
||||
t.Errorf("expected not_found error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelperProcess(t *testing.T) {
|
||||
if os.Getenv("CODESPACE_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
select {}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package process
|
||||
|
||||
// DefaultOpenCodeCommand is the default command used to launch OpenCode.
|
||||
const DefaultOpenCodeCommand = "opencode"
|
||||
|
||||
// normalizeCommand returns command if non-empty, otherwise the default.
|
||||
func normalizeCommand(command string) string {
|
||||
if command == "" {
|
||||
return DefaultOpenCodeCommand
|
||||
}
|
||||
return command
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package process
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// Status represents the status of a process for a workspace.
|
||||
type Status struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Running bool `json:"running"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
}
|
||||
|
||||
// Session holds the runtime state of a started OpenCode process.
|
||||
type Session struct {
|
||||
WorkspaceID string
|
||||
Root string
|
||||
Cmd *exec.Cmd
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"codespace/internal/fs"
|
||||
"codespace/internal/workspace"
|
||||
)
|
||||
|
||||
// FileService handles file operations within workspaces.
|
||||
type FileService struct {
|
||||
workspaces workspace.Manager
|
||||
}
|
||||
|
||||
// NewFileService creates a FileService.
|
||||
func NewFileService(workspaces workspace.Manager) *FileService {
|
||||
return &FileService{workspaces: workspaces}
|
||||
}
|
||||
|
||||
// List lists files at the given workspace-relative path.
|
||||
func (s *FileService) List(workspaceID, path string) ([]fs.FileInfo, error) {
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fs.NewLocal(ws.Root).List(path)
|
||||
}
|
||||
|
||||
// Read reads a file at the given workspace-relative path.
|
||||
func (s *FileService) Read(workspaceID, path string) ([]byte, error) {
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fs.NewLocal(ws.Root).Read(path)
|
||||
}
|
||||
|
||||
// Write writes data to a file at the given workspace-relative path.
|
||||
func (s *FileService) Write(workspaceID, path string, data []byte) error {
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fs.NewLocal(ws.Root).Write(path, data)
|
||||
}
|
||||
|
||||
// Mkdir creates a directory at the given workspace-relative path.
|
||||
func (s *FileService) Mkdir(workspaceID, path string) error {
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fs.NewLocal(ws.Root).Mkdir(path)
|
||||
}
|
||||
|
||||
// Remove removes a file or directory at the given workspace-relative path.
|
||||
func (s *FileService) Remove(workspaceID, path string) error {
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fs.NewLocal(ws.Root).Remove(path)
|
||||
}
|
||||
|
||||
// Rename renames a file or directory from oldPath to newPath.
|
||||
func (s *FileService) Rename(workspaceID, oldPath, newPath string) error {
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fs.NewLocal(ws.Root).Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
// Stat returns file info for the given workspace-relative path.
|
||||
func (s *FileService) Stat(workspaceID, path string) (*fs.FileInfo, error) {
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fs.NewLocal(ws.Root).Stat(path)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"codespace/internal/process"
|
||||
"codespace/internal/workspace"
|
||||
)
|
||||
|
||||
// ProcessService manages OpenCode processes for workspaces.
|
||||
type ProcessService struct {
|
||||
workspaces workspace.Manager
|
||||
processes process.Manager
|
||||
}
|
||||
|
||||
// NewProcessService creates a ProcessService.
|
||||
func NewProcessService(workspaces workspace.Manager, processes process.Manager) *ProcessService {
|
||||
return &ProcessService{workspaces: workspaces, processes: processes}
|
||||
}
|
||||
|
||||
// Start starts an OpenCode process for the given workspace.
|
||||
func (s *ProcessService) Start(workspaceID string) error {
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.processes.Start(workspaceID, ws.Root)
|
||||
}
|
||||
|
||||
// Stop stops the OpenCode process for the given workspace.
|
||||
func (s *ProcessService) Stop(workspaceID string) error {
|
||||
return s.processes.Stop(workspaceID)
|
||||
}
|
||||
|
||||
// Restart restarts the OpenCode process for the given workspace.
|
||||
func (s *ProcessService) Restart(workspaceID string) error {
|
||||
ws, err := s.workspaces.Get(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.processes.Restart(workspaceID, ws.Root)
|
||||
}
|
||||
|
||||
// Status returns the process status for the given workspace.
|
||||
func (s *ProcessService) Status(workspaceID string) (process.Status, error) {
|
||||
if _, err := s.workspaces.Get(workspaceID); err != nil {
|
||||
return process.Status{}, err
|
||||
}
|
||||
return s.processes.Status(workspaceID), nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"codespace/internal/process"
|
||||
"codespace/internal/workspace"
|
||||
)
|
||||
|
||||
// WorkspaceService orchestrates workspace lifecycle operations.
|
||||
type WorkspaceService struct {
|
||||
workspaces workspace.Manager
|
||||
processes process.Manager
|
||||
}
|
||||
|
||||
// NewWorkspaceService creates a WorkspaceService.
|
||||
func NewWorkspaceService(workspaces workspace.Manager, processes process.Manager) *WorkspaceService {
|
||||
return &WorkspaceService{workspaces: workspaces, processes: processes}
|
||||
}
|
||||
|
||||
// Create creates a new workspace.
|
||||
func (s *WorkspaceService) Create(id string) (*workspace.Workspace, error) {
|
||||
return s.workspaces.Create(id)
|
||||
}
|
||||
|
||||
// Get retrieves a workspace by id.
|
||||
func (s *WorkspaceService) Get(id string) (*workspace.Workspace, error) {
|
||||
return s.workspaces.Get(id)
|
||||
}
|
||||
|
||||
// Delete stops any running process for the workspace, then removes it.
|
||||
func (s *WorkspaceService) Delete(id string) error {
|
||||
status := s.processes.Status(id)
|
||||
if status.Running {
|
||||
_ = s.processes.Stop(id)
|
||||
}
|
||||
return s.workspaces.Delete(id)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Code is a string error category used across the application.
|
||||
type Code string
|
||||
|
||||
const (
|
||||
CodeBadRequest Code = "bad_request"
|
||||
CodeNotFound Code = "not_found"
|
||||
CodeConflict Code = "conflict"
|
||||
CodeInternal Code = "internal"
|
||||
)
|
||||
|
||||
// AppError is a typed error carrying a Code and optional wrapped error.
|
||||
type AppError struct {
|
||||
Code Code
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (e *AppError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// New returns a new AppError with the given code and message.
|
||||
func New(code Code, message string) error {
|
||||
return &AppError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// Wrap returns a new AppError wrapping err with the given code and message.
|
||||
func Wrap(code Code, message string, err error) error {
|
||||
return &AppError{Code: code, Message: message, Err: err}
|
||||
}
|
||||
|
||||
// CodeOf returns the Code embedded in err. nil returns CodeInternal;
|
||||
// non-AppError errors also default to CodeInternal.
|
||||
func CodeOf(err error) Code {
|
||||
if err == nil {
|
||||
return CodeInternal
|
||||
}
|
||||
var ae *AppError
|
||||
if errors.As(err, &ae) {
|
||||
return ae.Code
|
||||
}
|
||||
return CodeInternal
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CleanRelative validates and cleans a workspace-relative path.
|
||||
// It rejects absolute paths and paths that escape the workspace root via "..".
|
||||
// Empty path and "." return ".".
|
||||
func CleanRelative(path string) (string, error) {
|
||||
if path == "" || path == "." {
|
||||
return ".", nil
|
||||
}
|
||||
|
||||
// Reject absolute paths.
|
||||
if filepath.IsAbs(path) {
|
||||
return "", New(CodeBadRequest, "absolute paths are not allowed")
|
||||
}
|
||||
|
||||
cleaned := filepath.Clean(path)
|
||||
|
||||
// Reject paths that escape the workspace root.
|
||||
if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
|
||||
return "", New(CodeBadRequest, "path escapes workspace root")
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// JoinClean joins base and elem after cleaning, returning a cleaned path.
|
||||
func JoinClean(base, elem string) string {
|
||||
return filepath.Clean(filepath.Join(base, elem))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package watcher
|
||||
|
||||
// Event represents a filesystem change event.
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// Watcher is a placeholder interface for future filesystem watching.
|
||||
// Not wired into the API in v1.
|
||||
type Watcher interface {
|
||||
Start(root string) error
|
||||
Stop() error
|
||||
Events() <-chan Event
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"codespace/internal/util"
|
||||
)
|
||||
|
||||
// Manager manages workspace lifecycle.
|
||||
type Manager interface {
|
||||
Create(id string) (*Workspace, error)
|
||||
Get(id string) (*Workspace, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
// LocalManager implements Manager using the local filesystem.
|
||||
type LocalManager struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewLocalManager creates a LocalManager rooted at root. The root is made
|
||||
// absolute and cleaned if possible.
|
||||
func NewLocalManager(root string) *LocalManager {
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
abs = filepath.Clean(root)
|
||||
}
|
||||
return &LocalManager{root: abs}
|
||||
}
|
||||
|
||||
// Create creates a workspace directory and returns the workspace.
|
||||
func (m *LocalManager) Create(id string) (*Workspace, error) {
|
||||
if !ValidID(id) {
|
||||
return nil, util.New(util.CodeBadRequest, "invalid workspace id")
|
||||
}
|
||||
root := RootFor(m.root, id)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to create workspace", err)
|
||||
}
|
||||
return &Workspace{ID: id, Root: root}, nil
|
||||
}
|
||||
|
||||
// Get returns the workspace if its directory exists.
|
||||
func (m *LocalManager) Get(id string) (*Workspace, error) {
|
||||
if !ValidID(id) {
|
||||
return nil, util.New(util.CodeBadRequest, "invalid workspace id")
|
||||
}
|
||||
root := RootFor(m.root, id)
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, util.New(util.CodeNotFound, "workspace not found")
|
||||
}
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to stat workspace", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, util.New(util.CodeNotFound, "workspace not found")
|
||||
}
|
||||
return &Workspace{ID: id, Root: root}, nil
|
||||
}
|
||||
|
||||
// Delete removes the workspace directory recursively.
|
||||
func (m *LocalManager) Delete(id string) error {
|
||||
if !ValidID(id) {
|
||||
return util.New(util.CodeBadRequest, "invalid workspace id")
|
||||
}
|
||||
root := RootFor(m.root, id)
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to delete workspace", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"codespace/internal/util"
|
||||
)
|
||||
|
||||
var idPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
// ValidID reports whether id is a safe workspace identifier: only letters,
|
||||
// digits, '-', '_', '.' and non-empty. The bare values "." and ".." are
|
||||
// rejected because they would resolve to the parent or current directory.
|
||||
func ValidID(id string) bool {
|
||||
if id == "" || id == "." || id == ".." {
|
||||
return false
|
||||
}
|
||||
return idPattern.MatchString(id)
|
||||
}
|
||||
|
||||
// RootFor returns the absolute workspace root path for the given base root and
|
||||
// workspace id. The result is filepath-cleaned.
|
||||
func RootFor(baseRoot, id string) string {
|
||||
return util.JoinClean(baseRoot, id)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidIDAcceptsSafeSegments(t *testing.T) {
|
||||
valid := []string{"user1", "project-A", "project_A", "project.1"}
|
||||
for _, id := range valid {
|
||||
if !ValidID(id) {
|
||||
t.Errorf("expected ValidID(%q) to be true", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidIDRejectsUnsafeSegments(t *testing.T) {
|
||||
invalid := []string{"", ".", "..", "/tmp/x", "../x", "a/b", "a b", "中文", "*"}
|
||||
for _, id := range invalid {
|
||||
if ValidID(id) {
|
||||
t.Errorf("expected ValidID(%q) to be false", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootForUsesConfiguredRoot(t *testing.T) {
|
||||
got := RootFor("/tmp/workspaces", "user1")
|
||||
want := "/tmp/workspaces/user1"
|
||||
if got != want {
|
||||
t.Errorf("RootFor = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package workspace
|
||||
|
||||
// Workspace represents a single workspace backed by a local directory.
|
||||
type Workspace struct {
|
||||
ID string `json:"id"`
|
||||
Root string `json:"-"`
|
||||
}
|
||||
Reference in New Issue
Block a user