feat: scaffold codespace backend

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-02 15:07:54 +08:00
co-authored by Claude Fable 5
commit 411ed1f8ba
43 changed files with 4428 additions and 0 deletions
+22
View File
@@ -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.*
+10
View File
@@ -0,0 +1,10 @@
.PHONY: test run fmt
test:
go test ./...
run:
go run ./cmd/server
fmt:
gofmt -w cmd internal pkg
+88
View File
@@ -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'
```
+64
View File
@@ -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)
}
}
+10
View File
@@ -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 通过。
+36
View File
@@ -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
)
+89
View File
@@ -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=
+42
View File
@@ -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,
}})
}
+114
View File
@@ -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)
}
+55
View File
@@ -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,
})
}
+45
View File
@@ -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"})
}
+142
View File
@@ -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")
}
}
+51
View File
@@ -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)
}
+13
View File
@@ -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"`
}
+13
View File
@@ -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)
}
+170
View File
@@ -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
}
+137
View File
@@ -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)
}
}
+24
View File
@@ -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"`
}
+8
View File
@@ -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"`
}
+11
View File
@@ -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"`
}
+119
View File
@@ -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,
}
}
+61
View File
@@ -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 {}
}
+12
View File
@@ -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
}
+17
View File
@@ -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
}
+79
View File
@@ -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)
}
+48
View File
@@ -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
}
+36
View File
@@ -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)
}
+57
View File
@@ -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
}
+34
View File
@@ -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))
}
+15
View File
@@ -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
}
+73
View File
@@ -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
}
+25
View File
@@ -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)
}
+31
View File
@@ -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)
}
}
+7
View File
@@ -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:"-"`
}
+163
View File
@@ -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
}
+96
View File
@@ -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")
}
}
+11
View File
@@ -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)
}
+29
View File
@@ -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}})
}
+1
View File
@@ -0,0 +1 @@