feat: scaffold codespace backend
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
# IDE / editor
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Go build artifacts
|
||||
/bin/
|
||||
/dist/
|
||||
*.test
|
||||
*.out
|
||||
coverage.out
|
||||
|
||||
# Runtime data
|
||||
/workspaces/
|
||||
/tmp/
|
||||
*.log
|
||||
|
||||
# Local environment
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,10 @@
|
||||
.PHONY: test run fmt
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
fmt:
|
||||
gofmt -w cmd internal pkg
|
||||
@@ -0,0 +1,88 @@
|
||||
# codespace
|
||||
|
||||
A runnable Go backend MVP skeleton that manages local workspaces, file operations through an abstract filesystem, and OpenCode processes.
|
||||
|
||||
## V1 scope
|
||||
|
||||
- Workspace creation, lookup, and deletion backed by local directories.
|
||||
- File read/write/list/mkdir/remove/rename/stat through a `FileSystem` interface with path-escape protection.
|
||||
- OpenCode process start/stop/restart/status per workspace.
|
||||
- HTTP API using [Gin](https://github.com/gin-gonic/gin) for routing with JSON responses.
|
||||
- Explicit `http.Server` composition with configurable timeouts and max header bytes.
|
||||
|
||||
### Not implemented in v1
|
||||
|
||||
Docker, authentication, Git integration, LSP, AI Agent orchestration, web frontend, and full watcher/WebSocket event streaming are out of scope for the first version.
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
make run
|
||||
```
|
||||
|
||||
The server listens on `:8080` by default. Override via `configs/config.yaml` or environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `CODESPACE_ADDR` | Listen address |
|
||||
| `CODESPACE_WORKSPACE_ROOT` | Workspace root directory |
|
||||
| `CODESPACE_OPENCODE_COMMAND` | OpenCode binary path |
|
||||
| `CODESPACE_READ_TIMEOUT` | HTTP read timeout (e.g. `15s`) |
|
||||
| `CODESPACE_WRITE_TIMEOUT` | HTTP write timeout (e.g. `15s`) |
|
||||
| `CODESPACE_IDLE_TIMEOUT` | HTTP idle timeout (e.g. `60s`) |
|
||||
| `CODESPACE_MAX_HEADER_BYTES` | Max header bytes (e.g. `1048576`) |
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
make test
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
`configs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
addr: ":8080"
|
||||
readTimeout: "15s"
|
||||
writeTimeout: "15s"
|
||||
idleTimeout: "60s"
|
||||
maxHeaderBytes: 1048576
|
||||
workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
```
|
||||
|
||||
The server is started through an explicit `http.Server{Handler: router}` — it does not use `gin.Engine.Run()`.
|
||||
|
||||
## API examples
|
||||
|
||||
Health check:
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
Create a workspace:
|
||||
|
||||
```sh
|
||||
curl -s -X POST http://localhost:8080/api/workspaces \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"id":"user1"}'
|
||||
```
|
||||
|
||||
Write a file:
|
||||
|
||||
```sh
|
||||
curl -s -X PUT http://localhost:8080/api/workspaces/user1/files/write \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"path":"main.go","content":"package main\n"}'
|
||||
```
|
||||
|
||||
Read a file:
|
||||
|
||||
```sh
|
||||
curl -s 'http://localhost:8080/api/workspaces/user1/files/read?path=main.go'
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"codespace/internal/api"
|
||||
"codespace/internal/process"
|
||||
"codespace/internal/service"
|
||||
"codespace/internal/workspace"
|
||||
"codespace/pkg/config"
|
||||
"codespace/pkg/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load("configs/config.yaml")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
lg := logger.New()
|
||||
|
||||
workspaces := workspace.NewLocalManager(cfg.Workspace.Root)
|
||||
processes := process.NewManager(cfg.Process.OpenCodeCommand)
|
||||
|
||||
workspaceSvc := service.NewWorkspaceService(workspaces, processes)
|
||||
fileSvc := service.NewFileService(workspaces)
|
||||
processSvc := service.NewProcessService(workspaces, processes)
|
||||
|
||||
router := api.NewRouter(workspaceSvc, fileSvc, processSvc)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.Server.Addr,
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: cfg.Server.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.Server.MaxHeaderBytes,
|
||||
}
|
||||
|
||||
lg.Printf("listening on %s, workspace root: %s", cfg.Server.Addr, cfg.Workspace.Root)
|
||||
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
lg.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
lg.Printf("shutting down...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
lg.Fatalf("forced shutdown: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
server:
|
||||
addr: ":8080"
|
||||
readTimeout: "15s"
|
||||
writeTimeout: "15s"
|
||||
idleTimeout: "60s"
|
||||
maxHeaderBytes: 1048576
|
||||
workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,606 @@
|
||||
# Gin Server Composition Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the standard-library ServeMux API layer with Gin while keeping explicit `http.Server` composition and configurable network settings.
|
||||
|
||||
**Architecture:** `internal/api.NewRouter` will build and return a `*gin.Engine`. `cmd/server/main.go` will pass that engine into `http.Server{Handler: router}` and continue using `ListenAndServe`, never `router.Run`. `pkg/config` will parse server timeout/header settings into strong types used by `http.Server`.
|
||||
|
||||
**Tech Stack:** Go 1.22+, Gin (`github.com/gin-gonic/gin`), standard library `net/http`, YAML config via `gopkg.in/yaml.v3`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Use Gin for API routing.
|
||||
- Do not use `router.Run()` or `engine.Run()`.
|
||||
- Set `*gin.Engine` as `http.Server.Handler` explicitly.
|
||||
- Keep all existing API paths, HTTP methods, and JSON response shapes compatible.
|
||||
- Add configurable `readTimeout`, `writeTimeout`, `idleTimeout`, and `maxHeaderBytes` server settings.
|
||||
- Do not add auth, CORS, rate limiting, request logger middleware, WebSocket, Git, LSP, watcher streaming, or AI Agent orchestration.
|
||||
- Use only one new dependency for this migration: `github.com/gin-gonic/gin`.
|
||||
- Code generation/refactoring must be delegated to Codex MCP using exactly `model: "kimi/kimi-k2.7-code"`, `sandbox: "danger-full-access"`, and `approval-policy: "on-failure"`.
|
||||
- Do not commit unless the user explicitly asks.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Modify
|
||||
|
||||
- `go.mod` — add Gin dependency.
|
||||
- `go.sum` — update transitive dependency checksums from `go mod tidy`.
|
||||
- `configs/config.yaml` — add server network settings.
|
||||
- `pkg/config/config.go` — parse duration/header config and environment overrides.
|
||||
- `internal/api/router.go` — replace `http.ServeMux` with `gin.Engine` route registration.
|
||||
- `internal/api/errors_helper.go` — convert error writer to Gin context.
|
||||
- `internal/api/workspace_handler.go` — migrate handlers to `*gin.Context`.
|
||||
- `internal/api/file_handler.go` — migrate handlers to `*gin.Context`.
|
||||
- `internal/api/process_handler.go` — migrate handlers to `*gin.Context`.
|
||||
- `internal/api/router_test.go` — keep smoke tests and add Gin/server compatibility assertions if useful.
|
||||
- `cmd/server/main.go` — use configured server network parameters and keep explicit `http.Server`.
|
||||
- `README.md` — update dependency/framework and config examples if needed.
|
||||
|
||||
### Create
|
||||
|
||||
- `pkg/config/config_test.go` — tests for default config, YAML duration parsing, environment overrides, and invalid config failures.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add configurable server network settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `configs/config.yaml`
|
||||
- Modify: `pkg/config/config.go`
|
||||
- Create: `pkg/config/config_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `config.Load(path string) (Config, error)`.
|
||||
- Produces: `config.ServerConfig` with fields `Addr string`, `ReadTimeout time.Duration`, `WriteTimeout time.Duration`, `IdleTimeout time.Duration`, `MaxHeaderBytes int`.
|
||||
|
||||
- [ ] **Step 1: Write config tests first**
|
||||
|
||||
Create `pkg/config/config_test.go` with tests for these behaviors:
|
||||
|
||||
```go
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultIncludesServerNetworkSettings(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Server.Addr != ":8080" {
|
||||
t.Fatalf("Addr = %q, want :8080", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 15*time.Second {
|
||||
t.Fatalf("ReadTimeout = %v, want 15s", cfg.Server.ReadTimeout)
|
||||
}
|
||||
if cfg.Server.WriteTimeout != 15*time.Second {
|
||||
t.Fatalf("WriteTimeout = %v, want 15s", cfg.Server.WriteTimeout)
|
||||
}
|
||||
if cfg.Server.IdleTimeout != 60*time.Second {
|
||||
t.Fatalf("IdleTimeout = %v, want 60s", cfg.Server.IdleTimeout)
|
||||
}
|
||||
if cfg.Server.MaxHeaderBytes != 1048576 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want 1048576", cfg.Server.MaxHeaderBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesServerNetworkSettingsFromYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
data := []byte(`server:
|
||||
addr: ":9090"
|
||||
readTimeout: "2s"
|
||||
writeTimeout: "3s"
|
||||
idleTimeout: "4s"
|
||||
maxHeaderBytes: 2048
|
||||
workspace:
|
||||
root: "./tmp-workspaces"
|
||||
process:
|
||||
opencodeCommand: "fake-opencode"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.Server.Addr != ":9090" {
|
||||
t.Fatalf("Addr = %q, want :9090", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 2*time.Second || cfg.Server.WriteTimeout != 3*time.Second || cfg.Server.IdleTimeout != 4*time.Second {
|
||||
t.Fatalf("timeouts = %v/%v/%v, want 2s/3s/4s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout)
|
||||
}
|
||||
if cfg.Server.MaxHeaderBytes != 2048 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want 2048", cfg.Server.MaxHeaderBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesServerEnvironmentOverrides(t *testing.T) {
|
||||
t.Setenv("CODESPACE_ADDR", ":7070")
|
||||
t.Setenv("CODESPACE_READ_TIMEOUT", "5s")
|
||||
t.Setenv("CODESPACE_WRITE_TIMEOUT", "6s")
|
||||
t.Setenv("CODESPACE_IDLE_TIMEOUT", "7s")
|
||||
t.Setenv("CODESPACE_MAX_HEADER_BYTES", "4096")
|
||||
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.Server.Addr != ":7070" {
|
||||
t.Fatalf("Addr = %q, want :7070", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 5*time.Second || cfg.Server.WriteTimeout != 6*time.Second || cfg.Server.IdleTimeout != 7*time.Second {
|
||||
t.Fatalf("timeouts = %v/%v/%v, want 5s/6s/7s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout)
|
||||
}
|
||||
if cfg.Server.MaxHeaderBytes != 4096 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want 4096", cfg.Server.MaxHeaderBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidDuration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
data := []byte(`server:
|
||||
readTimeout: "garbage"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("expected invalid duration error")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go test ./pkg/config
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Fails because `ServerConfig` lacks timeout/header fields.
|
||||
|
||||
- [ ] **Step 3: Implement config parsing**
|
||||
|
||||
Modify `pkg/config/config.go`:
|
||||
|
||||
- Import `fmt`, `strconv`, and `time`.
|
||||
- Change public `ServerConfig` to:
|
||||
|
||||
```go
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
}
|
||||
```
|
||||
|
||||
- Introduce raw YAML structs:
|
||||
|
||||
```go
|
||||
type rawConfig struct {
|
||||
Server rawServerConfig `yaml:"server"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Process ProcessConfig `yaml:"process"`
|
||||
}
|
||||
|
||||
type rawServerConfig struct {
|
||||
Addr string `yaml:"addr"`
|
||||
ReadTimeout string `yaml:"readTimeout"`
|
||||
WriteTimeout string `yaml:"writeTimeout"`
|
||||
IdleTimeout string `yaml:"idleTimeout"`
|
||||
MaxHeaderBytes int `yaml:"maxHeaderBytes"`
|
||||
}
|
||||
```
|
||||
|
||||
- `Default()` returns `15*time.Second`, `15*time.Second`, `60*time.Second`, and `1048576`.
|
||||
- `Load(path)` starts from `Default()`, unmarshals YAML into `rawConfig`, applies only non-zero/non-empty raw fields, parses durations using `time.ParseDuration`, then applies env.
|
||||
- `applyEnv` parses:
|
||||
- `CODESPACE_READ_TIMEOUT`
|
||||
- `CODESPACE_WRITE_TIMEOUT`
|
||||
- `CODESPACE_IDLE_TIMEOUT`
|
||||
- `CODESPACE_MAX_HEADER_BYTES`
|
||||
- Invalid duration or integer env values return an error from `Load`, not silent fallback.
|
||||
|
||||
A clean implementation shape:
|
||||
|
||||
```go
|
||||
func applyRaw(cfg *Config, raw rawConfig) error
|
||||
func applyDuration(target *time.Duration, value string, name string) error
|
||||
func applyEnv(cfg *Config) error
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update YAML config**
|
||||
|
||||
Modify `configs/config.yaml` to:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
addr: ":8080"
|
||||
readTimeout: "15s"
|
||||
writeTimeout: "15s"
|
||||
idleTimeout: "60s"
|
||||
maxHeaderBytes: 1048576
|
||||
workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify config package**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
gofmt -w pkg/config
|
||||
go test ./pkg/config
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Tests pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Migrate API router and handlers to Gin
|
||||
|
||||
**Files:**
|
||||
- Modify: `go.mod`
|
||||
- Modify: `go.sum`
|
||||
- Modify: `internal/api/router.go`
|
||||
- Modify: `internal/api/errors_helper.go`
|
||||
- Modify: `internal/api/workspace_handler.go`
|
||||
- Modify: `internal/api/file_handler.go`
|
||||
- Modify: `internal/api/process_handler.go`
|
||||
- Modify: `internal/api/router_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: service constructors and methods unchanged.
|
||||
- Produces: `api.NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) *gin.Engine`.
|
||||
|
||||
- [ ] **Step 1: Add Gin dependency**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
go get github.com/gin-gonic/gin
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `go.mod` contains `github.com/gin-gonic/gin`.
|
||||
- `go.sum` is updated.
|
||||
|
||||
- [ ] **Step 2: Keep HTTP tests as compatibility tests**
|
||||
|
||||
`internal/api/router_test.go` can keep using `http.Handler` because `*gin.Engine` implements `ServeHTTP`. Update setup only if compile requires it:
|
||||
|
||||
```go
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
Existing tests must continue to assert the same HTTP status codes and JSON response bodies.
|
||||
|
||||
- [ ] **Step 3: Implement Gin router**
|
||||
|
||||
Modify `internal/api/router.go` to:
|
||||
|
||||
```go
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codespace/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
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"})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update error helper for Gin**
|
||||
|
||||
Modify `internal/api/errors_helper.go` to use:
|
||||
|
||||
```go
|
||||
func writeError(c *gin.Context, err error)
|
||||
```
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Use `util.CodeOf(err)`.
|
||||
- Map bad request/not found/conflict/internal to 400/404/409/500.
|
||||
- Respond with `response.ErrorBody{Error: response.ErrorDetail{Code: string(code), Message: message}}`.
|
||||
- If `err == nil`, message should be `http.StatusText(status)`.
|
||||
|
||||
- [ ] **Step 5: Update workspace handler**
|
||||
|
||||
Modify `internal/api/workspace_handler.go`:
|
||||
|
||||
- Remove `encoding/json` import.
|
||||
- Handler signatures use `*gin.Context`.
|
||||
- `create` uses `c.ShouldBindJSON(&req)`.
|
||||
- `get/delete` use `c.Param("id")`.
|
||||
- Success responses use `c.JSON(...)` or `c.Status(http.StatusNoContent)`.
|
||||
|
||||
- [ ] **Step 6: Update file handler**
|
||||
|
||||
Modify `internal/api/file_handler.go`:
|
||||
|
||||
- Remove `encoding/json` import.
|
||||
- Handler signatures use `*gin.Context`.
|
||||
- Workspace ID uses `c.Param("id")`.
|
||||
- Query path uses `c.Query("path")`.
|
||||
- JSON body uses `c.ShouldBindJSON(&req)`.
|
||||
- No-content responses use `c.Status(http.StatusNoContent)`.
|
||||
|
||||
- [ ] **Step 7: Update process handler**
|
||||
|
||||
Modify `internal/api/process_handler.go`:
|
||||
|
||||
- Handler signatures use `*gin.Context`.
|
||||
- Workspace ID uses `c.Param("id")`.
|
||||
- Success responses use `c.Status(http.StatusNoContent)` or `c.JSON(...)`.
|
||||
|
||||
- [ ] **Step 8: Verify API package**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
gofmt -w internal/api
|
||||
go test ./internal/api
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- API tests pass with the same status/body assertions.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire Gin engine into explicit http.Server
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/server/main.go`
|
||||
- Modify: `README.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `api.NewRouter(...) *gin.Engine`.
|
||||
- Consumes: `cfg.Server.ReadTimeout`, `cfg.Server.WriteTimeout`, `cfg.Server.IdleTimeout`, `cfg.Server.MaxHeaderBytes`.
|
||||
- Produces: explicit `http.Server{Handler: router}` startup without Gin `Run()`.
|
||||
|
||||
- [ ] **Step 1: Update server wiring**
|
||||
|
||||
Modify `cmd/server/main.go` server construction to:
|
||||
|
||||
```go
|
||||
router := api.NewRouter(workspaceSvc, fileSvc, processSvc)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.Server.Addr,
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: cfg.Server.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.Server.MaxHeaderBytes,
|
||||
}
|
||||
```
|
||||
|
||||
Remove direct `time` usage if it becomes unused except shutdown timeout. Keep `10*time.Second` for graceful shutdown context unless you add a separate config field; do not add that field in this task.
|
||||
|
||||
- [ ] **Step 2: Update README**
|
||||
|
||||
Update `README.md` so it says:
|
||||
|
||||
- The API router uses Gin.
|
||||
- The server is still started through explicit `http.Server`, not `gin.Engine.Run()`.
|
||||
- Config supports `server.readTimeout`, `server.writeTimeout`, `server.idleTimeout`, and `server.maxHeaderBytes`.
|
||||
|
||||
Do not add promises for auth/CORS/Git/LSP/frontend.
|
||||
|
||||
- [ ] **Step 3: Verify server package**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
gofmt -w cmd/server
|
||||
go test ./cmd/server ./pkg/config ./internal/api
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- All listed packages pass.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Final validation and safety checks
|
||||
|
||||
**Files:**
|
||||
- Modify only files from Tasks 1-3 if validation exposes issues.
|
||||
|
||||
**Interfaces:**
|
||||
- Produces validated Gin migration.
|
||||
|
||||
- [ ] **Step 1: Format, tidy, and test everything**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
gofmt -w cmd internal pkg
|
||||
go mod tidy
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- All tests pass.
|
||||
|
||||
- [ ] **Step 2: Check Gin Run is not used**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -R "\.Run(" cmd internal || true
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- No output.
|
||||
|
||||
- [ ] **Step 3: Check explicit http.Server composition exists**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
grep -R "http.Server" -n cmd/server/main.go
|
||||
grep -R "Handler:" -n cmd/server/main.go
|
||||
grep -R "MaxHeaderBytes" -n cmd/server/main.go pkg/config configs/config.yaml
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `cmd/server/main.go` contains `http.Server`, `Handler: router`, and `MaxHeaderBytes: cfg.Server.MaxHeaderBytes`.
|
||||
- `pkg/config/config.go` and `configs/config.yaml` contain `MaxHeaderBytes`/`maxHeaderBytes`.
|
||||
|
||||
- [ ] **Step 4: Run server smoke test**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
(go run ./cmd/server > /tmp/codespace-server.log 2>&1 & pid=$!; \
|
||||
for i in $(seq 1 30); do \
|
||||
if curl -fsS http://localhost:8080/healthz; then \
|
||||
kill $pid; wait $pid 2>/dev/null || true; exit 0; \
|
||||
fi; \
|
||||
sleep 0.2; \
|
||||
done; \
|
||||
kill $pid 2>/dev/null || true; \
|
||||
wait $pid 2>/dev/null || true; \
|
||||
cat /tmp/codespace-server.log; exit 1)
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```json
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Check git diff for scope creep**
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
git diff --stat
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- Changes are limited to Gin migration, config, docs/spec/plan, `.gitignore`, and previously generated skeleton files.
|
||||
- No auth/CORS/Git/LSP/frontend implementation appears.
|
||||
|
||||
---
|
||||
|
||||
## Codex Execution Prompt
|
||||
|
||||
Use this exact structure when dispatching Codex:
|
||||
|
||||
```text
|
||||
## Context
|
||||
- Project root: /Users/taochen/llm/codespace
|
||||
- Current branch: feat/codespace-backend-skeleton
|
||||
- Existing backend is a Go 1.22 codespace service.
|
||||
- Current router uses standard library http.ServeMux in internal/api/router.go.
|
||||
- Server already uses explicit http.Server in cmd/server/main.go.
|
||||
- Design spec: docs/superpowers/specs/2026-07-02-gin-server-composition-design.md
|
||||
- Implementation plan: docs/superpowers/plans/2026-07-02-gin-server-composition.md
|
||||
|
||||
## Task
|
||||
Migrate routing to Gin and configure explicit http.Server network settings.
|
||||
Steps:
|
||||
1. Add Gin dependency.
|
||||
2. Add configurable server read/write/idle timeout and maxHeaderBytes settings.
|
||||
3. Replace internal/api ServeMux router and handlers with Gin equivalents.
|
||||
4. Keep cmd/server/main.go using explicit http.Server{Handler: router}; do not use router.Run().
|
||||
5. Update tests and README.
|
||||
6. Run final validation.
|
||||
|
||||
## Constraints
|
||||
- Do not change API paths, methods, or response JSON shapes.
|
||||
- Do not change service interfaces.
|
||||
- Do not add auth, CORS, logging middleware, rate limiting, WebSocket, Git, LSP, frontend, watcher streaming, or AI Agent features.
|
||||
- Use only one new dependency: github.com/gin-gonic/gin.
|
||||
- Do not commit.
|
||||
|
||||
## Acceptance
|
||||
- gofmt -w cmd internal pkg succeeds.
|
||||
- go mod tidy succeeds.
|
||||
- go test ./... passes.
|
||||
- grep -R "\.Run(" cmd internal || true returns no output.
|
||||
- cmd/server/main.go contains explicit http.Server with Handler: router and MaxHeaderBytes from config.
|
||||
- go run ./cmd/server starts and /healthz returns {"status":"ok"}.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: Plan covers Gin routing, handler migration, explicit server composition, configurable network settings, docs, tests, and validation against `Run()` misuse.
|
||||
- Placeholder scan: No TBD/TODO/ambiguous implementation steps remain.
|
||||
- Type consistency: `api.NewRouter` consistently returns `*gin.Engine`; `cmd/server/main.go` consumes it as `http.Server.Handler`; `ServerConfig` fields are consistent across plan tasks.
|
||||
- Scope check: Plan is limited to Gin migration and server config. It does not introduce auth, CORS, WebSocket, Git, LSP, frontend, watcher streaming, or Agent features.
|
||||
@@ -0,0 +1,390 @@
|
||||
# codespace 可运行后端骨架设计
|
||||
|
||||
日期:2026-07-02
|
||||
|
||||
## 目标
|
||||
|
||||
在当前目录 `/Users/taochen/llm/codespace` 生成一个 Go 后端项目骨架。第一版目标是可运行、可测试、边界清晰的 Workspace Service,而不是完整 Web IDE。
|
||||
|
||||
核心能力:
|
||||
|
||||
- 每个 workspace 对应本地文件系统中的一个目录。
|
||||
- 文件读写统一通过 `FileSystem` 接口,第一版实现 `LocalFS`。
|
||||
- OpenCode 以本机进程方式启动,工作目录 `cwd` 指向 workspace root。
|
||||
- HTTP handler 不直接操作文件系统或进程,必须经过 service 层。
|
||||
- 预留 watcher、Git、LSP、AI Agent 的后续扩展点。
|
||||
- 不依赖 Docker。
|
||||
|
||||
## 非目标
|
||||
|
||||
第一版不实现:
|
||||
|
||||
- 用户认证和权限系统。
|
||||
- Docker / 容器隔离。
|
||||
- Git 集成。
|
||||
- LSP。
|
||||
- AI Agent 编排。
|
||||
- Web 前端。
|
||||
- 完整 watcher / WebSocket 文件事件推送。
|
||||
|
||||
这些能力以后接入,但不能污染第一版的核心边界。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go
|
||||
├── configs/
|
||||
│ └── config.yaml
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── router.go
|
||||
│ │ ├── workspace_handler.go
|
||||
│ │ ├── file_handler.go
|
||||
│ │ └── process_handler.go
|
||||
│ ├── workspace/
|
||||
│ │ ├── manager.go
|
||||
│ │ ├── workspace.go
|
||||
│ │ └── path.go
|
||||
│ ├── fs/
|
||||
│ │ ├── filesystem.go
|
||||
│ │ ├── localfs.go
|
||||
│ │ └── fileinfo.go
|
||||
│ ├── process/
|
||||
│ │ ├── manager.go
|
||||
│ │ ├── session.go
|
||||
│ │ └── opencode.go
|
||||
│ ├── watcher/
|
||||
│ │ └── watcher.go
|
||||
│ ├── service/
|
||||
│ │ ├── workspace_service.go
|
||||
│ │ ├── file_service.go
|
||||
│ │ └── process_service.go
|
||||
│ ├── model/
|
||||
│ │ ├── workspace.go
|
||||
│ │ ├── file.go
|
||||
│ │ └── process.go
|
||||
│ └── util/
|
||||
│ ├── path.go
|
||||
│ └── errors.go
|
||||
├── pkg/
|
||||
│ ├── config/
|
||||
│ ├── logger/
|
||||
│ └── response/
|
||||
├── web/
|
||||
├── docs/
|
||||
├── Makefile
|
||||
├── go.mod
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
HTTP request
|
||||
↓
|
||||
internal/api handler
|
||||
↓
|
||||
internal/service
|
||||
↓
|
||||
workspace.Manager / fs.FileSystem / process.Manager
|
||||
↓
|
||||
local workspace directory + opencode process
|
||||
```
|
||||
|
||||
边界规则:
|
||||
|
||||
1. `api` 只负责 HTTP 编解码、路由参数、状态码。
|
||||
2. `service` 负责业务编排和校验。
|
||||
3. `workspace` 负责 workspace 元数据和 root 目录管理。
|
||||
4. `fs` 负责 workspace 内文件操作。
|
||||
5. `process` 负责进程生命周期。
|
||||
6. `watcher` 第一版只保留接口/占位,不接入运行链路。
|
||||
|
||||
## 核心数据结构
|
||||
|
||||
### Workspace
|
||||
|
||||
```go
|
||||
type Workspace struct {
|
||||
ID string `json:"id"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
```
|
||||
|
||||
含义:
|
||||
|
||||
- `ID` 是外部 API 使用的 workspace 标识。
|
||||
- `Root` 是服务端本地绝对路径。
|
||||
- 第一版不引入数据库,workspace 是否存在由目录和 manager 判断。
|
||||
|
||||
### FileInfo
|
||||
|
||||
```go
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"modTime"`
|
||||
}
|
||||
```
|
||||
|
||||
`Path` 始终是 workspace 内相对路径,不向 API 暴露宿主机绝对路径。
|
||||
|
||||
### ProcessStatus
|
||||
|
||||
```go
|
||||
type ProcessStatus struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
Running bool `json:"running"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
## Workspace Manager
|
||||
|
||||
职责:
|
||||
|
||||
- 创建 workspace 目录。
|
||||
- 查询 workspace。
|
||||
- 删除 workspace 目录。
|
||||
- 生成 workspace root 路径。
|
||||
|
||||
接口:
|
||||
|
||||
```go
|
||||
type Manager interface {
|
||||
Create(id string) (*Workspace, error)
|
||||
Get(id string) (*Workspace, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
```
|
||||
|
||||
路径策略:
|
||||
|
||||
- 配置项 `workspaceRoot` 默认使用 `./workspaces`。
|
||||
- workspace root 为 `<workspaceRoot>/<workspaceID>`。
|
||||
- workspace ID 第一版限制为安全路径片段,只允许字母、数字、`-`、`_`、`.`。
|
||||
|
||||
## FileSystem
|
||||
|
||||
所有文件操作必须走接口:
|
||||
|
||||
```go
|
||||
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)
|
||||
}
|
||||
```
|
||||
|
||||
第一版实现 `LocalFS`:
|
||||
|
||||
- `LocalFS` 绑定一个 workspace root。
|
||||
- 所有传入路径都是 workspace 内相对路径。
|
||||
- 解析路径时必须阻止 `../` 逃逸。
|
||||
- API 不返回宿主机绝对路径。
|
||||
|
||||
## Process Manager
|
||||
|
||||
职责:按 workspace 管理 OpenCode 进程。
|
||||
|
||||
接口:
|
||||
|
||||
```go
|
||||
type Manager interface {
|
||||
Start(workspaceID string, workspaceRoot string) error
|
||||
Stop(workspaceID string) error
|
||||
Restart(workspaceID string, workspaceRoot string) error
|
||||
Status(workspaceID string) Status
|
||||
}
|
||||
```
|
||||
|
||||
第一版行为:
|
||||
|
||||
- 命令默认是 `opencode`,通过配置项 `opencodeCommand` 可改。
|
||||
- `cmd.Dir = workspace.Root`。
|
||||
- `HOME` 默认设置为 workspace root,避免不同 workspace 共享 CLI 状态。
|
||||
- 同一个 workspace 已有运行进程时,再次 start 返回已运行错误,不重复启动。
|
||||
- stop 使用进程 kill;优雅退出可以后续补。
|
||||
|
||||
## Service 层
|
||||
|
||||
### WorkspaceService
|
||||
|
||||
- `CreateWorkspace(id string)`
|
||||
- `GetWorkspace(id string)`
|
||||
- `DeleteWorkspace(id string)`
|
||||
|
||||
删除 workspace 前,先停止对应进程,避免目录被运行进程占用。
|
||||
|
||||
### FileService
|
||||
|
||||
- `List(workspaceID, path string)`
|
||||
- `Read(workspaceID, path string)`
|
||||
- `Write(workspaceID, path string, data []byte)`
|
||||
- `Mkdir(workspaceID, path string)`
|
||||
- `Remove(workspaceID, path string)`
|
||||
- `Rename(workspaceID, oldPath, newPath string)`
|
||||
- `Stat(workspaceID, path string)`
|
||||
|
||||
每次通过 workspace manager 取 workspace,再构造绑定该 root 的 `LocalFS`。
|
||||
|
||||
### ProcessService
|
||||
|
||||
- `Start(workspaceID string)`
|
||||
- `Stop(workspaceID string)`
|
||||
- `Restart(workspaceID string)`
|
||||
- `Status(workspaceID string)`
|
||||
|
||||
ProcessService 不自己拼路径,只通过 workspace manager 取 root。
|
||||
|
||||
## HTTP API
|
||||
|
||||
基础路径:`/api`
|
||||
|
||||
### 健康检查
|
||||
|
||||
```text
|
||||
GET /healthz
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{"status":"ok"}
|
||||
```
|
||||
|
||||
### Workspace
|
||||
|
||||
```text
|
||||
POST /api/workspaces
|
||||
GET /api/workspaces/{id}
|
||||
DELETE /api/workspaces/{id}
|
||||
```
|
||||
|
||||
创建请求:
|
||||
|
||||
```json
|
||||
{"id":"user1"}
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
```text
|
||||
GET /api/workspaces/{id}/files?path=.
|
||||
GET /api/workspaces/{id}/files/read?path=main.go
|
||||
PUT /api/workspaces/{id}/files/write
|
||||
POST /api/workspaces/{id}/files/mkdir
|
||||
DELETE /api/workspaces/{id}/files?path=main.go
|
||||
POST /api/workspaces/{id}/files/rename
|
||||
```
|
||||
|
||||
写文件请求:
|
||||
|
||||
```json
|
||||
{"path":"main.go","content":"package main\n"}
|
||||
```
|
||||
|
||||
重命名请求:
|
||||
|
||||
```json
|
||||
{"oldPath":"old.go","newPath":"new.go"}
|
||||
```
|
||||
|
||||
### Process
|
||||
|
||||
```text
|
||||
POST /api/workspaces/{id}/process/start
|
||||
POST /api/workspaces/{id}/process/stop
|
||||
POST /api/workspaces/{id}/process/restart
|
||||
GET /api/workspaces/{id}/process/status
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
`configs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
addr: ":8080"
|
||||
workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
```
|
||||
|
||||
配置加载规则:
|
||||
|
||||
1. 先加载默认值。
|
||||
2. 如果配置文件存在,覆盖默认值。
|
||||
3. 环境变量可覆盖:
|
||||
- `CODESPACE_ADDR`
|
||||
- `CODESPACE_WORKSPACE_ROOT`
|
||||
- `CODESPACE_OPENCODE_COMMAND`
|
||||
|
||||
## 错误处理
|
||||
|
||||
统一 JSON 响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "bad_request",
|
||||
"message": "invalid workspace id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
常见映射:
|
||||
|
||||
- 参数错误:400
|
||||
- workspace 不存在:404
|
||||
- 文件不存在:404
|
||||
- 进程已运行:409
|
||||
- 内部错误:500
|
||||
|
||||
第一版只做简单错误类型,不引入复杂错误框架。
|
||||
|
||||
## 测试策略
|
||||
|
||||
最小测试:
|
||||
|
||||
- workspace ID 校验和路径生成。
|
||||
- LocalFS 路径逃逸防护。
|
||||
- LocalFS 基础读写/列表。
|
||||
- Process manager 状态逻辑使用可配置命令测试,不依赖真实 OpenCode。
|
||||
- HTTP handler 可做轻量测试,至少覆盖 health 和 workspace 创建。
|
||||
|
||||
验收命令:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
## 兼容性与风险
|
||||
|
||||
- 当前目录是项目根目录,不再额外创建 `codespace/` 子目录。
|
||||
- 初始化会创建标准 Go 项目文件;如果目标文件已存在,实施阶段必须先检查,避免覆盖用户已有内容。
|
||||
- 不改变公共 API,因为这是初始版本。
|
||||
- 最大安全风险是路径逃逸,必须在 `LocalFS` 和 workspace ID 校验中处理。
|
||||
- 最大复杂度风险是过早引入 watcher/Git/LSP/Agent;第一版明确不实现。
|
||||
|
||||
## 骨架完成标准
|
||||
|
||||
完成后应满足:
|
||||
|
||||
- 当前目录包含完整 Go module。
|
||||
- `go test ./...` 通过。
|
||||
- `go run ./cmd/server` 能启动 HTTP 服务。
|
||||
- 可以创建 workspace、读写 workspace 内文件、查询/启动/停止进程。
|
||||
- Handler 不直接调用 `os.*` 或 `exec.*`。
|
||||
- 文件路径不能逃逸 workspace root。
|
||||
@@ -0,0 +1,287 @@
|
||||
# Gin Router and Explicit HTTP Server Composition Design
|
||||
|
||||
日期:2026-07-02
|
||||
|
||||
## 目标
|
||||
|
||||
将 codespace 后端的原生 Go 1.22 `http.ServeMux` 路由替换为 Gin,同时继续显式构造 `http.Server`。Gin 只负责路由和 handler 上下文,网络层仍由 `http.Server` 控制。
|
||||
|
||||
本次改动的核心要求:
|
||||
|
||||
- 使用 Gin 注册 API 路由。
|
||||
- 不使用 `router.Run()` 或 `engine.Run()`。
|
||||
- 将 `*gin.Engine` 显式设置为 `http.Server.Handler`。
|
||||
- 将服务器网络参数配置化,包括 timeout 和最大请求头大小。
|
||||
- 保持现有 API 路径、HTTP method、响应 JSON 结构不变。
|
||||
|
||||
## 当前状态
|
||||
|
||||
当前代码:
|
||||
|
||||
- `cmd/server/main.go` 已显式构造 `http.Server`。
|
||||
- `internal/api/router.go` 使用 `http.NewServeMux()`。
|
||||
- handler 使用 `http.ResponseWriter`、`*http.Request`、`r.PathValue("id")` 和 `json.NewDecoder(r.Body)`。
|
||||
- `pkg/config` 只配置 `server.addr`,timeout 仍硬编码在 `main.go`。
|
||||
|
||||
## 非目标
|
||||
|
||||
本次不做:
|
||||
|
||||
- 不引入认证、CORS、rate limit、request logger middleware。
|
||||
- 不改变 workspace/file/process service 接口。
|
||||
- 不改变 API 路径、HTTP method、响应结构。
|
||||
- 不实现 WebSocket、watcher 推送、Git、LSP、AI Agent。
|
||||
- 不把网络层控制权交给 Gin 的 `Run()`。
|
||||
|
||||
## 目标架构
|
||||
|
||||
```text
|
||||
cmd/server/main.go
|
||||
↓
|
||||
config.Load()
|
||||
↓
|
||||
api.NewRouter(...)
|
||||
↓
|
||||
*gin.Engine
|
||||
↓
|
||||
http.Server{
|
||||
Addr,
|
||||
Handler: ginEngine,
|
||||
ReadTimeout,
|
||||
WriteTimeout,
|
||||
IdleTimeout,
|
||||
MaxHeaderBytes,
|
||||
}
|
||||
↓
|
||||
ListenAndServe()
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
1. `internal/api.NewRouter` 返回 `*gin.Engine`。
|
||||
2. `cmd/server/main.go` 显式创建 `http.Server`。
|
||||
3. `http.Server.Handler` 设置为 Gin engine。
|
||||
4. 代码中不得出现 `.Run(` 用于启动 Gin server。
|
||||
5. `ReadTimeout`、`WriteTimeout`、`IdleTimeout`、`MaxHeaderBytes` 来自配置。
|
||||
|
||||
## 配置设计
|
||||
|
||||
`configs/config.yaml` 扩展为:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
addr: ":8080"
|
||||
readTimeout: "15s"
|
||||
writeTimeout: "15s"
|
||||
idleTimeout: "60s"
|
||||
maxHeaderBytes: 1048576
|
||||
workspace:
|
||||
root: "./workspaces"
|
||||
process:
|
||||
opencodeCommand: "opencode"
|
||||
```
|
||||
|
||||
`pkg/config.ServerConfig` 对外使用强类型:
|
||||
|
||||
```go
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
}
|
||||
```
|
||||
|
||||
YAML 加载不要依赖 `time.Duration` 的隐式解析。使用 raw config 字符串字段承接 YAML,然后通过 `time.ParseDuration` 显式解析。这样 `"15s"`、`"1m"`、`"500ms"` 都清晰可控。
|
||||
|
||||
环境变量增加:
|
||||
|
||||
- `CODESPACE_READ_TIMEOUT`
|
||||
- `CODESPACE_WRITE_TIMEOUT`
|
||||
- `CODESPACE_IDLE_TIMEOUT`
|
||||
- `CODESPACE_MAX_HEADER_BYTES`
|
||||
|
||||
已有环境变量保持不变:
|
||||
|
||||
- `CODESPACE_ADDR`
|
||||
- `CODESPACE_WORKSPACE_ROOT`
|
||||
- `CODESPACE_OPENCODE_COMMAND`
|
||||
|
||||
默认值:
|
||||
|
||||
- `addr`: `:8080`
|
||||
- `readTimeout`: `15s`
|
||||
- `writeTimeout`: `15s`
|
||||
- `idleTimeout`: `60s`
|
||||
- `maxHeaderBytes`: `1048576`
|
||||
|
||||
## 路由设计
|
||||
|
||||
原路由保持不变:
|
||||
|
||||
```text
|
||||
GET /healthz
|
||||
POST /api/workspaces
|
||||
GET /api/workspaces/:id
|
||||
DELETE /api/workspaces/:id
|
||||
GET /api/workspaces/:id/files
|
||||
GET /api/workspaces/:id/files/read
|
||||
PUT /api/workspaces/:id/files/write
|
||||
POST /api/workspaces/:id/files/mkdir
|
||||
DELETE /api/workspaces/:id/files
|
||||
POST /api/workspaces/:id/files/rename
|
||||
POST /api/workspaces/:id/process/start
|
||||
POST /api/workspaces/:id/process/stop
|
||||
POST /api/workspaces/:id/process/restart
|
||||
GET /api/workspaces/:id/process/status
|
||||
```
|
||||
|
||||
Gin 注册方式:
|
||||
|
||||
```go
|
||||
r := gin.New()
|
||||
r.GET("/healthz", healthHandler)
|
||||
api := r.Group("/api")
|
||||
api.POST("/workspaces", workspaceHandler.create)
|
||||
api.GET("/workspaces/:id", workspaceHandler.get)
|
||||
```
|
||||
|
||||
第一版使用 `gin.New()` 而不是 `gin.Default()`,避免隐式加入 logger middleware。只添加 `gin.Recovery()`,防止 panic 打爆进程。请求日志以后有真实需求再加。
|
||||
|
||||
## Handler 迁移
|
||||
|
||||
签名从:
|
||||
|
||||
```go
|
||||
func (h *workspaceHandler) get(w http.ResponseWriter, r *http.Request)
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```go
|
||||
func (h *workspaceHandler) get(c *gin.Context)
|
||||
```
|
||||
|
||||
路径参数:
|
||||
|
||||
```go
|
||||
id := c.Param("id")
|
||||
```
|
||||
|
||||
查询参数:
|
||||
|
||||
```go
|
||||
path := c.Query("path")
|
||||
```
|
||||
|
||||
JSON body:
|
||||
|
||||
```go
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeError(c, util.New(util.CodeBadRequest, "invalid json"))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```go
|
||||
c.JSON(http.StatusCreated, model.WorkspaceResponse{ID: ws.ID})
|
||||
c.Status(http.StatusNoContent)
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
保持现有错误 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "bad_request",
|
||||
"message": "invalid json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`internal/api/errors_helper.go` 改为接收 `*gin.Context`:
|
||||
|
||||
```go
|
||||
func writeError(c *gin.Context, err error)
|
||||
```
|
||||
|
||||
继续使用 `util.CodeOf(err)` 映射:
|
||||
|
||||
- `bad_request` -> 400
|
||||
- `not_found` -> 404
|
||||
- `conflict` -> 409
|
||||
- default -> 500
|
||||
|
||||
可以复用 `pkg/response.ErrorBody` 和 `pkg/response.ErrorDetail`,避免重复定义响应结构。
|
||||
|
||||
## Server 组合
|
||||
|
||||
`cmd/server/main.go` 最终保持显式组合:
|
||||
|
||||
```go
|
||||
router := api.NewRouter(workspaceSvc, fileSvc, processSvc)
|
||||
server := &http.Server{
|
||||
Addr: cfg.Server.Addr,
|
||||
Handler: router,
|
||||
ReadTimeout: cfg.Server.ReadTimeout,
|
||||
WriteTimeout: cfg.Server.WriteTimeout,
|
||||
IdleTimeout: cfg.Server.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.Server.MaxHeaderBytes,
|
||||
}
|
||||
```
|
||||
|
||||
启动仍然使用:
|
||||
|
||||
```go
|
||||
server.ListenAndServe()
|
||||
```
|
||||
|
||||
## 测试策略
|
||||
|
||||
现有 `httptest` 测试继续有效,因为 `*gin.Engine` 实现 `http.Handler`。
|
||||
|
||||
需要验证:
|
||||
|
||||
- `GET /healthz` 行为不变。
|
||||
- workspace 创建、文件写读、process status 行为不变。
|
||||
- Gin path param `:id` 正常传入 service。
|
||||
- 配置默认值正确。
|
||||
- YAML timeout 字符串能解析。
|
||||
- 环境变量能覆盖 timeout 和 `maxHeaderBytes`。
|
||||
- `go test ./...` 通过。
|
||||
- `grep -R "\.Run(" cmd internal || true` 无输出。
|
||||
- `go run ./cmd/server` 后 `/healthz` 返回 `{"status":"ok"}`。
|
||||
|
||||
## 依赖
|
||||
|
||||
新增唯一依赖:
|
||||
|
||||
```text
|
||||
github.com/gin-gonic/gin
|
||||
```
|
||||
|
||||
不新增其他框架或中间件依赖。
|
||||
|
||||
## 风险与处理
|
||||
|
||||
- 风险:Gin 默认模式可能输出 debug 信息。处理:启动时可设置 `gin.SetMode(gin.ReleaseMode)`,或保留 Gin 默认但不影响功能。第一版建议在 `NewRouter` 中设置 `gin.ReleaseMode`,减少噪音。
|
||||
- 风险:`time.Duration` YAML 解析行为不符合预期。处理:使用 raw string + `time.ParseDuration`。
|
||||
- 风险:错误响应格式变化。处理:复用现有响应结构并保留 HTTP 测试。
|
||||
- 风险:误用 `router.Run()`。处理:增加 grep 验证。
|
||||
|
||||
## 完成标准
|
||||
|
||||
完成后必须满足:
|
||||
|
||||
- `internal/api` 使用 Gin 注册路由。
|
||||
- `cmd/server/main.go` 使用 `http.Server{Handler: router}`。
|
||||
- 不出现 Gin `Run()` 启动服务器。
|
||||
- server 网络参数来自配置。
|
||||
- API 行为和响应结构保持兼容。
|
||||
- `go test ./...` 通过。
|
||||
- server smoke test 通过。
|
||||
@@ -0,0 +1,36 @@
|
||||
module codespace
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.20.0 // indirect
|
||||
golang.org/x/text v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds all runtime configuration for the codespace server.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Process ProcessConfig `yaml:"process"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP server settings.
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
MaxHeaderBytes int
|
||||
}
|
||||
|
||||
// WorkspaceConfig holds workspace directory settings.
|
||||
type WorkspaceConfig struct {
|
||||
Root string `yaml:"root"`
|
||||
}
|
||||
|
||||
// ProcessConfig holds OpenCode process settings.
|
||||
type ProcessConfig struct {
|
||||
OpenCodeCommand string `yaml:"opencodeCommand"`
|
||||
}
|
||||
|
||||
// rawConfig is used for YAML deserialization so that duration strings can be
|
||||
// parsed explicitly rather than relying on implicit time.Duration unmarshalling.
|
||||
type rawConfig struct {
|
||||
Server rawServerConfig `yaml:"server"`
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Process ProcessConfig `yaml:"process"`
|
||||
}
|
||||
|
||||
type rawServerConfig struct {
|
||||
Addr string `yaml:"addr"`
|
||||
ReadTimeout string `yaml:"readTimeout"`
|
||||
WriteTimeout string `yaml:"writeTimeout"`
|
||||
IdleTimeout string `yaml:"idleTimeout"`
|
||||
MaxHeaderBytes int `yaml:"maxHeaderBytes"`
|
||||
}
|
||||
|
||||
// Default returns the built-in default configuration.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{
|
||||
Addr: ":8080",
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20, // 1 MiB
|
||||
},
|
||||
Workspace: WorkspaceConfig{Root: "./workspaces"},
|
||||
Process: ProcessConfig{OpenCodeCommand: "opencode"},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads configuration from the given YAML file path (if it exists),
|
||||
// then applies environment variable overrides. Missing files are not an error.
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
var raw rawConfig
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := applyRaw(&cfg, raw); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := applyEnv(&cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyRaw(cfg *Config, raw rawConfig) error {
|
||||
if raw.Server.Addr != "" {
|
||||
cfg.Server.Addr = raw.Server.Addr
|
||||
}
|
||||
if err := applyDuration(&cfg.Server.ReadTimeout, raw.Server.ReadTimeout, "readTimeout"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyDuration(&cfg.Server.WriteTimeout, raw.Server.WriteTimeout, "writeTimeout"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyDuration(&cfg.Server.IdleTimeout, raw.Server.IdleTimeout, "idleTimeout"); err != nil {
|
||||
return err
|
||||
}
|
||||
if raw.Server.MaxHeaderBytes != 0 {
|
||||
cfg.Server.MaxHeaderBytes = raw.Server.MaxHeaderBytes
|
||||
}
|
||||
if raw.Workspace.Root != "" {
|
||||
cfg.Workspace.Root = raw.Workspace.Root
|
||||
}
|
||||
if raw.Process.OpenCodeCommand != "" {
|
||||
cfg.Process.OpenCodeCommand = raw.Process.OpenCodeCommand
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyDuration(target *time.Duration, value, name string) error {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration for %s: %w", name, err)
|
||||
}
|
||||
*target = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) error {
|
||||
if v := os.Getenv("CODESPACE_ADDR"); v != "" {
|
||||
cfg.Server.Addr = v
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_READ_TIMEOUT"); v != "" {
|
||||
if err := applyDuration(&cfg.Server.ReadTimeout, v, "CODESPACE_READ_TIMEOUT"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_WRITE_TIMEOUT"); v != "" {
|
||||
if err := applyDuration(&cfg.Server.WriteTimeout, v, "CODESPACE_WRITE_TIMEOUT"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_IDLE_TIMEOUT"); v != "" {
|
||||
if err := applyDuration(&cfg.Server.IdleTimeout, v, "CODESPACE_IDLE_TIMEOUT"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_MAX_HEADER_BYTES"); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid integer for CODESPACE_MAX_HEADER_BYTES: %w", err)
|
||||
}
|
||||
cfg.Server.MaxHeaderBytes = n
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_WORKSPACE_ROOT"); v != "" {
|
||||
cfg.Workspace.Root = v
|
||||
}
|
||||
if v := os.Getenv("CODESPACE_OPENCODE_COMMAND"); v != "" {
|
||||
cfg.Process.OpenCodeCommand = v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultIncludesServerNetworkSettings(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Server.Addr != ":8080" {
|
||||
t.Fatalf("Addr = %q, want :8080", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 15*time.Second {
|
||||
t.Fatalf("ReadTimeout = %v, want 15s", cfg.Server.ReadTimeout)
|
||||
}
|
||||
if cfg.Server.WriteTimeout != 15*time.Second {
|
||||
t.Fatalf("WriteTimeout = %v, want 15s", cfg.Server.WriteTimeout)
|
||||
}
|
||||
if cfg.Server.IdleTimeout != 60*time.Second {
|
||||
t.Fatalf("IdleTimeout = %v, want 60s", cfg.Server.IdleTimeout)
|
||||
}
|
||||
if cfg.Server.MaxHeaderBytes != 1048576 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want 1048576", cfg.Server.MaxHeaderBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesServerNetworkSettingsFromYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
data := []byte(`server:
|
||||
addr: ":9090"
|
||||
readTimeout: "2s"
|
||||
writeTimeout: "3s"
|
||||
idleTimeout: "4s"
|
||||
maxHeaderBytes: 2048
|
||||
workspace:
|
||||
root: "./tmp-workspaces"
|
||||
process:
|
||||
opencodeCommand: "fake-opencode"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.Server.Addr != ":9090" {
|
||||
t.Fatalf("Addr = %q, want :9090", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 2*time.Second || cfg.Server.WriteTimeout != 3*time.Second || cfg.Server.IdleTimeout != 4*time.Second {
|
||||
t.Fatalf("timeouts = %v/%v/%v, want 2s/3s/4s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout)
|
||||
}
|
||||
if cfg.Server.MaxHeaderBytes != 2048 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want 2048", cfg.Server.MaxHeaderBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesServerEnvironmentOverrides(t *testing.T) {
|
||||
t.Setenv("CODESPACE_ADDR", ":7070")
|
||||
t.Setenv("CODESPACE_READ_TIMEOUT", "5s")
|
||||
t.Setenv("CODESPACE_WRITE_TIMEOUT", "6s")
|
||||
t.Setenv("CODESPACE_IDLE_TIMEOUT", "7s")
|
||||
t.Setenv("CODESPACE_MAX_HEADER_BYTES", "4096")
|
||||
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load returned error: %v", err)
|
||||
}
|
||||
if cfg.Server.Addr != ":7070" {
|
||||
t.Fatalf("Addr = %q, want :7070", cfg.Server.Addr)
|
||||
}
|
||||
if cfg.Server.ReadTimeout != 5*time.Second || cfg.Server.WriteTimeout != 6*time.Second || cfg.Server.IdleTimeout != 7*time.Second {
|
||||
t.Fatalf("timeouts = %v/%v/%v, want 5s/6s/7s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout)
|
||||
}
|
||||
if cfg.Server.MaxHeaderBytes != 4096 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want 4096", cfg.Server.MaxHeaderBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidDuration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
data := []byte(`server:
|
||||
readTimeout: "garbage"
|
||||
`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("expected invalid duration error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// New returns a standard library logger writing to stdout with a codespace prefix.
|
||||
func New() *log.Logger {
|
||||
return log.New(os.Stdout, "codespace ", log.LstdFlags|log.Lshortfile)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ErrorBody struct {
|
||||
Error ErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
type ErrorDetail struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func JSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func Error(w http.ResponseWriter, status int, code string, err error) {
|
||||
msg := code
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
JSON(w, status, ErrorBody{Error: ErrorDetail{Code: code, Message: msg}})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user