From 411ed1f8ba5c726514400d2266df554be2ce388a Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:07:54 +0800 Subject: [PATCH] feat: scaffold codespace backend Co-Authored-By: Claude Fable 5 --- .gitignore | 22 + Makefile | 10 + README.md | 88 ++ cmd/server/main.go | 64 + configs/config.yaml | 10 + .../2026-07-02-codespace-backend-skeleton.md | 1087 +++++++++++++++++ .../2026-07-02-gin-server-composition.md | 606 +++++++++ .../specs/2026-07-02-codespace-design.md | 390 ++++++ ...026-07-02-gin-server-composition-design.md | 287 +++++ go.mod | 36 + go.sum | 89 ++ internal/api/errors.go | 42 + internal/api/file_handler.go | 114 ++ internal/api/process_handler.go | 55 + internal/api/router.go | 45 + internal/api/router_test.go | 142 +++ internal/api/workspace_handler.go | 51 + internal/fs/fileinfo.go | 13 + internal/fs/filesystem.go | 13 + internal/fs/localfs.go | 170 +++ internal/fs/localfs_test.go | 137 +++ internal/model/file.go | 24 + internal/model/process.go | 8 + internal/model/workspace.go | 11 + internal/process/manager.go | 119 ++ internal/process/manager_test.go | 61 + internal/process/opencode.go | 12 + internal/process/session.go | 17 + internal/service/file_service.go | 79 ++ internal/service/process_service.go | 48 + internal/service/workspace_service.go | 36 + internal/util/errors.go | 57 + internal/util/path.go | 34 + internal/watcher/watcher.go | 15 + internal/workspace/manager.go | 73 ++ internal/workspace/path.go | 25 + internal/workspace/path_test.go | 31 + internal/workspace/workspace.go | 7 + pkg/config/config.go | 163 +++ pkg/config/config_test.go | 96 ++ pkg/logger/logger.go | 11 + pkg/response/response.go | 29 + web/.gitkeep | 1 + 43 files changed, 4428 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 configs/config.yaml create mode 100644 docs/superpowers/plans/2026-07-02-codespace-backend-skeleton.md create mode 100644 docs/superpowers/plans/2026-07-02-gin-server-composition.md create mode 100644 docs/superpowers/specs/2026-07-02-codespace-design.md create mode 100644 docs/superpowers/specs/2026-07-02-gin-server-composition-design.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/errors.go create mode 100644 internal/api/file_handler.go create mode 100644 internal/api/process_handler.go create mode 100644 internal/api/router.go create mode 100644 internal/api/router_test.go create mode 100644 internal/api/workspace_handler.go create mode 100644 internal/fs/fileinfo.go create mode 100644 internal/fs/filesystem.go create mode 100644 internal/fs/localfs.go create mode 100644 internal/fs/localfs_test.go create mode 100644 internal/model/file.go create mode 100644 internal/model/process.go create mode 100644 internal/model/workspace.go create mode 100644 internal/process/manager.go create mode 100644 internal/process/manager_test.go create mode 100644 internal/process/opencode.go create mode 100644 internal/process/session.go create mode 100644 internal/service/file_service.go create mode 100644 internal/service/process_service.go create mode 100644 internal/service/workspace_service.go create mode 100644 internal/util/errors.go create mode 100644 internal/util/path.go create mode 100644 internal/watcher/watcher.go create mode 100644 internal/workspace/manager.go create mode 100644 internal/workspace/path.go create mode 100644 internal/workspace/path_test.go create mode 100644 internal/workspace/workspace.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/logger/logger.go create mode 100644 pkg/response/response.go create mode 100644 web/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..436c4ba --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2088fc0 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: test run fmt + +test: + go test ./... + +run: + go run ./cmd/server + +fmt: + gofmt -w cmd internal pkg diff --git a/README.md b/README.md new file mode 100644 index 0000000..972d766 --- /dev/null +++ b/README.md @@ -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' +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..56b4614 --- /dev/null +++ b/cmd/server/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) + } +} diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..186a11d --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,10 @@ +server: + addr: ":8080" + readTimeout: "15s" + writeTimeout: "15s" + idleTimeout: "60s" + maxHeaderBytes: 1048576 +workspace: + root: "./workspaces" +process: + opencodeCommand: "opencode" diff --git a/docs/superpowers/plans/2026-07-02-codespace-backend-skeleton.md b/docs/superpowers/plans/2026-07-02-codespace-backend-skeleton.md new file mode 100644 index 0000000..3cee919 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-codespace-backend-skeleton.md @@ -0,0 +1,1087 @@ +# codespace Backend Skeleton 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:** Build a runnable Go backend MVP in the current project root that manages workspaces, file operations through an abstract filesystem, and OpenCode processes. + +**Architecture:** HTTP handlers decode requests and delegate to service objects. Services coordinate `workspace.Manager`, `fs.FileSystem`, and `process.Manager`. Local filesystem and OpenCode process execution are implementation details hidden behind focused interfaces. + +**Tech Stack:** Go 1.22+, standard library HTTP router, YAML config via `gopkg.in/yaml.v3`, local filesystem, OS processes. + +## Global Constraints + +- Current directory `/Users/taochen/llm/codespace` is the project root; do not create a nested `codespace/` directory. +- First version is a runnable backend MVP, not a complete IDE. +- Do not add Docker. +- Do not implement authentication, Git integration, LSP, AI Agent orchestration, web frontend, or full watcher/WebSocket event streaming. +- Handlers must not call `os.*` file APIs or `exec.*`; file and process work goes through services. +- File API paths are workspace-relative; never expose host absolute paths in file responses. +- Workspace IDs allow only letters, digits, `-`, `_`, and `.`. +- Prevent `../` and absolute-path escape from workspace roots. +- Process command defaults to `opencode`, but must be configurable. +- Current directory is not a git repository, so this plan does not include commit steps unless the user initializes git separately. +- 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"`. + +--- + +## File Structure + +### Create + +- `go.mod` — module declaration and dependencies. +- `Makefile` — local development commands. +- `README.md` — project overview, API examples, run/test instructions. +- `configs/config.yaml` — default runtime config. +- `cmd/server/main.go` — app entrypoint, config load, dependency wiring, HTTP server start. +- `pkg/config/config.go` — config structs and load logic. +- `pkg/response/response.go` — consistent JSON success/error responses. +- `pkg/logger/logger.go` — tiny standard-library logger wrapper. +- `internal/util/path.go` — reusable path validation helpers. +- `internal/util/errors.go` — typed app errors and helpers. +- `internal/workspace/workspace.go` — `Workspace` model. +- `internal/workspace/path.go` — workspace ID validation and root path construction. +- `internal/workspace/manager.go` — local workspace manager implementation. +- `internal/workspace/path_test.go` — workspace ID/path tests. +- `internal/fs/fileinfo.go` — file metadata model. +- `internal/fs/filesystem.go` — `FileSystem` interface. +- `internal/fs/localfs.go` — local filesystem implementation bound to one workspace root. +- `internal/fs/localfs_test.go` — path escape and file operation tests. +- `internal/process/session.go` — session/status structs. +- `internal/process/manager.go` — process manager interface and implementation. +- `internal/process/opencode.go` — OpenCode command/session creation details. +- `internal/process/manager_test.go` — status/start/stop tests with safe test command. +- `internal/model/workspace.go` — workspace API request/response structs. +- `internal/model/file.go` — file API request/response structs. +- `internal/model/process.go` — process API response structs. +- `internal/service/workspace_service.go` — workspace use cases. +- `internal/service/file_service.go` — file use cases. +- `internal/service/process_service.go` — process use cases. +- `internal/api/router.go` — HTTP router and route registration. +- `internal/api/workspace_handler.go` — workspace endpoints. +- `internal/api/file_handler.go` — file endpoints. +- `internal/api/process_handler.go` — process endpoints. +- `internal/api/router_test.go` — health/workspace/file/process route smoke tests. +- `internal/watcher/watcher.go` — narrow watcher interface placeholder. +- `web/.gitkeep` — keeps frontend placeholder directory. +- `docs/superpowers/specs/2026-07-02-codespace-design.md` — already created design spec. +- `docs/superpowers/plans/2026-07-02-codespace-backend-skeleton.md` — this plan. + +### External dependency + +Only one non-stdlib dependency is allowed for the first version: + +```text +gopkg.in/yaml.v3 +``` + +Everything else must use the Go standard library. + +--- + +## Task 1: Initialize Go module, config, responses, and docs + +**Files:** +- Create: `go.mod` +- Create: `Makefile` +- Create: `README.md` +- Create: `configs/config.yaml` +- Create: `pkg/config/config.go` +- Create: `pkg/response/response.go` +- Create: `pkg/logger/logger.go` +- Create: `web/.gitkeep` + +**Interfaces:** +- Produces: `config.Config`, `config.Load(path string) (Config, error)`. +- Produces: `response.JSON(w http.ResponseWriter, status int, value any)`. +- Produces: `response.Error(w http.ResponseWriter, status int, code string, err error)`. +- Produces: `logger.New() *log.Logger`. + +- [ ] **Step 1: Create module and default config** + +Create `go.mod`: + +```go +module codespace + +go 1.22 + +require gopkg.in/yaml.v3 v3.0.1 +``` + +Create `configs/config.yaml`: + +```yaml +server: + addr: ":8080" +workspace: + root: "./workspaces" +process: + opencodeCommand: "opencode" +``` + +Create `web/.gitkeep` as an empty file. + +- [ ] **Step 2: Implement config loader** + +Create `pkg/config/config.go` with these exported names: + +```go +package config + +type Config struct { + Server ServerConfig `yaml:"server"` + Workspace WorkspaceConfig `yaml:"workspace"` + Process ProcessConfig `yaml:"process"` +} + +type ServerConfig struct { + Addr string `yaml:"addr"` +} + +type WorkspaceConfig struct { + Root string `yaml:"root"` +} + +type ProcessConfig struct { + OpenCodeCommand string `yaml:"opencodeCommand"` +} + +func Default() Config +func Load(path string) (Config, error) +``` + +Required behavior: + +- `Default()` returns `:8080`, `./workspaces`, and `opencode`. +- `Load(path)` starts from defaults. +- If `path` exists, YAML values override defaults. +- If `path` does not exist, return defaults and no error. +- Environment variables override both defaults and file values: + - `CODESPACE_ADDR` + - `CODESPACE_WORKSPACE_ROOT` + - `CODESPACE_OPENCODE_COMMAND` + +- [ ] **Step 3: Implement response helpers** + +Create `pkg/response/response.go` with this response shape: + +```go +type ErrorBody struct { + Error ErrorDetail `json:"error"` +} + +type ErrorDetail struct { + Code string `json:"code"` + Message string `json:"message"` +} +``` + +Functions: + +```go +func JSON(w http.ResponseWriter, status int, value any) +func Error(w http.ResponseWriter, status int, code string, err error) +``` + +Required behavior: + +- Always set `Content-Type: application/json`. +- `JSON` writes the status code and JSON-encodes `value`. +- `Error` writes `{"error":{"code":"...","message":"..."}}`. + +- [ ] **Step 4: Implement logger helper** + +Create `pkg/logger/logger.go`: + +```go +package logger + +func New() *log.Logger +``` + +Required behavior: + +- Use standard library `log.New(os.Stdout, "codespace ", log.LstdFlags|log.Lshortfile)`. + +- [ ] **Step 5: Create README and Makefile** + +Create `Makefile` with these targets: + +```makefile +.PHONY: test run fmt + +test: + go test ./... + +run: + go run ./cmd/server + +fmt: + gofmt -w cmd internal pkg +``` + +Create `README.md` with: + +- Project purpose. +- First-version scope. +- `make test` and `make run` instructions. +- Example curl for `POST /api/workspaces` and `GET /healthz`. +- Explicit note that Docker/auth/Git/LSP/frontend are not implemented in v1. + +- [ ] **Step 6: Validate task** + +Run: + +```sh +go mod tidy +go test ./... +``` + +Expected: + +- `go mod tidy` succeeds. +- `go test ./...` succeeds or reports no test files for created packages. + +--- + +## Task 2: Implement typed errors and workspace manager + +**Files:** +- Create: `internal/util/errors.go` +- Create: `internal/util/path.go` +- Create: `internal/workspace/workspace.go` +- Create: `internal/workspace/path.go` +- Create: `internal/workspace/manager.go` +- Create: `internal/workspace/path_test.go` + +**Interfaces:** +- Produces: `util.AppError`, `util.Code`, `util.New(code Code, message string) error`. +- Produces: `workspace.Workspace`. +- Produces: `workspace.Manager` interface. +- Produces: `workspace.LocalManager` implementation. +- Produces: `workspace.ValidID(id string) bool`. + +- [ ] **Step 1: Write workspace path tests first** + +Create `internal/workspace/path_test.go` with tests covering: + +```go +func TestValidIDAcceptsSafeSegments(t *testing.T) +func TestValidIDRejectsUnsafeSegments(t *testing.T) +func TestRootForUsesConfiguredRoot(t *testing.T) +``` + +Cases: + +- valid: `user1`, `project-A`, `project_A`, `project.1` +- invalid: empty string, `/tmp/x`, `../x`, `a/b`, `a b`, `中文`, `*` +- `RootFor("/tmp/workspaces", "user1")` returns `/tmp/workspaces/user1` after filepath cleaning. + +- [ ] **Step 2: Implement typed errors** + +Create `internal/util/errors.go` with codes: + +```go +type Code string + +const ( + CodeBadRequest Code = "bad_request" + CodeNotFound Code = "not_found" + CodeConflict Code = "conflict" + CodeInternal Code = "internal" +) + +type AppError struct { + Code Code + Message string + Err error +} + +func (e *AppError) Error() string +func (e *AppError) Unwrap() error +func New(code Code, message string) error +func Wrap(code Code, message string, err error) error +func CodeOf(err error) Code +``` + +Required behavior: + +- `CodeOf(nil)` returns `CodeInternal`. +- `CodeOf(err)` returns embedded `AppError.Code` if present. +- Non-`AppError` defaults to `CodeInternal`. + +- [ ] **Step 3: Implement path helper** + +Create `internal/util/path.go` with: + +```go +func CleanRelative(path string) (string, error) +``` + +Required behavior: + +- Empty path and `.` return `.`. +- Absolute paths are rejected with bad request. +- Paths that clean to `..` or start with `../` are rejected with bad request. +- Backslash should be normalized by filepath semantics on the current platform; do not implement Windows-specific policy in v1. + +- [ ] **Step 4: Implement workspace model and manager** + +Create `internal/workspace/workspace.go`: + +```go +type Workspace struct { + ID string `json:"id"` + Root string `json:"root"` +} +``` + +Create `internal/workspace/path.go`: + +```go +func ValidID(id string) bool +func RootFor(baseRoot, id string) string +``` + +Create `internal/workspace/manager.go`: + +```go +type Manager interface { + Create(id string) (*Workspace, error) + Get(id string) (*Workspace, error) + Delete(id string) error +} + +type LocalManager struct { + root string +} + +func NewLocalManager(root string) *LocalManager +``` + +Required behavior: + +- `NewLocalManager` stores an absolute, cleaned root if possible. +- `Create` validates ID, creates the directory with `0755`, and returns workspace. +- `Get` validates ID and returns not found if directory does not exist. +- `Delete` validates ID and removes the workspace directory recursively. +- Manager must never accept unsafe IDs. + +- [ ] **Step 5: Run focused tests** + +Run: + +```sh +go test ./internal/workspace ./internal/util +``` + +Expected: + +- All tests pass. + +--- + +## Task 3: Implement FileSystem interface and LocalFS + +**Files:** +- Create: `internal/fs/fileinfo.go` +- Create: `internal/fs/filesystem.go` +- Create: `internal/fs/localfs.go` +- Create: `internal/fs/localfs_test.go` + +**Interfaces:** +- Produces: `fs.FileInfo`. +- Produces: `fs.FileSystem`. +- Produces: `fs.NewLocal(root string) FileSystem`. + +- [ ] **Step 1: Write LocalFS tests first** + +Create `internal/fs/localfs_test.go` with tests: + +```go +func TestLocalFSWriteReadListStat(t *testing.T) +func TestLocalFSRejectsPathEscape(t *testing.T) +func TestLocalFSRenameAndRemove(t *testing.T) +``` + +Required assertions: + +- Write `dir/main.go` with content `package main\n`. +- Read returns exact bytes. +- List `dir` returns one item named `main.go` with path `dir/main.go` and `IsDir == false`. +- Stat `dir/main.go` returns size equal to content length. +- Read `../outside`, Write `../outside`, Mkdir `../outside`, Remove `../outside`, Rename `file` to `../outside` all fail. +- Rename `old.txt` to `new.txt` makes `old.txt` missing and `new.txt` readable. + +- [ ] **Step 2: Define file models and interface** + +Create `internal/fs/fileinfo.go`: + +```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"` +} +``` + +Create `internal/fs/filesystem.go`: + +```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) +} +``` + +- [ ] **Step 3: Implement LocalFS safely** + +Create `internal/fs/localfs.go` with: + +```go +type LocalFS struct { + root string +} + +func NewLocal(root string) *LocalFS +``` + +Required behavior: + +- Store root as an absolute cleaned path if possible. +- A private `resolve(path string) (absolute string, relative string, error)` must call `util.CleanRelative` and ensure the final absolute path stays under `root`. +- `Write` creates parent directories with `0755` and writes file with `0644`. +- `Mkdir` creates directories with `0755`. +- `List` returns sorted entries by name for deterministic tests. +- `Read`, `List`, and `Stat` map missing files to `CodeNotFound`. +- `Remove` removes files or directories recursively. +- `Rename` creates destination parent directories and rejects destination escape. + +- [ ] **Step 4: Run focused tests** + +Run: + +```sh +go test ./internal/fs ./internal/util +``` + +Expected: + +- All tests pass. + +--- + +## Task 4: Implement process manager for OpenCode sessions + +**Files:** +- Create: `internal/process/session.go` +- Create: `internal/process/manager.go` +- Create: `internal/process/opencode.go` +- Create: `internal/process/manager_test.go` + +**Interfaces:** +- Produces: `process.Status`. +- Produces: `process.Manager` interface. +- Produces: `process.NewManager(command string) *LocalManager`. + +- [ ] **Step 1: Write process manager tests first** + +Create `internal/process/manager_test.go` with tests: + +```go +func TestStatusBeforeStartIsNotRunning(t *testing.T) +func TestStartRejectsDuplicateRunningSession(t *testing.T) +func TestStopMissingSessionIsNotFound(t *testing.T) +``` + +Use a safe long-running command: + +```go +cmd := os.Args[0] +args := []string{"-test.run=TestHelperProcess", "--"} +``` + +And helper pattern: + +```go +func TestHelperProcess(t *testing.T) { + if os.Getenv("CODESPACE_HELPER_PROCESS") != "1" { + return + } + select {} +} +``` + +The implementation must allow tests to override command and args, either through a constructor option or an unexported test helper in the same package. + +- [ ] **Step 2: Define session/status structs** + +Create `internal/process/session.go`: + +```go +type Status struct { + WorkspaceID string `json:"workspaceId"` + Running bool `json:"running"` + PID int `json:"pid,omitempty"` +} + +type Session struct { + WorkspaceID string + Root string + Cmd *exec.Cmd +} +``` + +- [ ] **Step 3: Define manager interface and implementation** + +Create `internal/process/manager.go`: + +```go +type Manager interface { + Start(workspaceID string, workspaceRoot string) error + Stop(workspaceID string) error + Restart(workspaceID string, workspaceRoot string) error + Status(workspaceID string) Status +} + +type LocalManager struct { + command string + args []string + mu sync.Mutex + sessions map[string]*Session +} + +func NewManager(command string) *LocalManager +``` + +Required behavior: + +- Empty command defaults to `opencode`. +- `Start` rejects duplicate running session with `CodeConflict`. +- `Start` uses `exec.Command(command, args...)`. +- `Start` sets `cmd.Dir = workspaceRoot`. +- `Start` appends `HOME=` to environment. +- `Start` starts the process and stores session by workspace ID. +- `Stop` returns not found if no running session exists. +- `Stop` kills the process and removes the session. +- `Restart` calls `Stop` if running, then `Start`. +- `Status` returns running and PID only if a session exists and process is started. + +- [ ] **Step 4: Implement OpenCode command helper** + +Create `internal/process/opencode.go` with small helpers only: + +```go +const DefaultOpenCodeCommand = "opencode" + +func normalizeCommand(command string) string +``` + +- [ ] **Step 5: Run focused tests** + +Run: + +```sh +go test ./internal/process +``` + +Expected: + +- All tests pass. +- No real `opencode` binary is required for tests. + +--- + +## Task 5: Implement services and API models + +**Files:** +- Create: `internal/model/workspace.go` +- Create: `internal/model/file.go` +- Create: `internal/model/process.go` +- Create: `internal/service/workspace_service.go` +- Create: `internal/service/file_service.go` +- Create: `internal/service/process_service.go` + +**Interfaces:** +- Produces: model request/response structs used by handlers. +- Produces: `service.WorkspaceService`. +- Produces: `service.FileService`. +- Produces: `service.ProcessService`. + +- [ ] **Step 1: Create API models** + +Create `internal/model/workspace.go`: + +```go +type CreateWorkspaceRequest struct { + ID string `json:"id"` +} + +type WorkspaceResponse struct { + ID string `json:"id"` +} +``` + +Do not include host root path in API responses. + +Create `internal/model/file.go`: + +```go +type WriteFileRequest struct { + Path string `json:"path"` + Content string `json:"content"` +} + +type MkdirRequest struct { + Path string `json:"path"` +} + +type RenameRequest struct { + OldPath string `json:"oldPath"` + NewPath string `json:"newPath"` +} + +type ReadFileResponse struct { + Path string `json:"path"` + Content string `json:"content"` +} +``` + +Create `internal/model/process.go`: + +```go +type ProcessStatusResponse struct { + WorkspaceID string `json:"workspaceId"` + Running bool `json:"running"` + PID int `json:"pid,omitempty"` +} +``` + +- [ ] **Step 2: Implement WorkspaceService** + +Create `internal/service/workspace_service.go`: + +```go +type WorkspaceService struct { + workspaces workspace.Manager + processes process.Manager +} + +func NewWorkspaceService(workspaces workspace.Manager, processes process.Manager) *WorkspaceService +func (s *WorkspaceService) Create(id string) (*workspace.Workspace, error) +func (s *WorkspaceService) Get(id string) (*workspace.Workspace, error) +func (s *WorkspaceService) Delete(id string) error +``` + +Required behavior: + +- `Delete` checks process status and stops it if running before deleting workspace. + +- [ ] **Step 3: Implement FileService** + +Create `internal/service/file_service.go`: + +```go +type FileService struct { + workspaces workspace.Manager +} + +func NewFileService(workspaces workspace.Manager) *FileService +func (s *FileService) List(workspaceID, path string) ([]fs.FileInfo, error) +func (s *FileService) Read(workspaceID, path string) ([]byte, error) +func (s *FileService) Write(workspaceID, path string, data []byte) error +func (s *FileService) Mkdir(workspaceID, path string) error +func (s *FileService) Remove(workspaceID, path string) error +func (s *FileService) Rename(workspaceID, oldPath, newPath string) error +func (s *FileService) Stat(workspaceID, path string) (*fs.FileInfo, error) +``` + +Required behavior: + +- Each method calls `workspaces.Get(workspaceID)` first. +- Each method constructs `fs.NewLocal(ws.Root)` and delegates. + +- [ ] **Step 4: Implement ProcessService** + +Create `internal/service/process_service.go`: + +```go +type ProcessService struct { + workspaces workspace.Manager + processes process.Manager +} + +func NewProcessService(workspaces workspace.Manager, processes process.Manager) *ProcessService +func (s *ProcessService) Start(workspaceID string) error +func (s *ProcessService) Stop(workspaceID string) error +func (s *ProcessService) Restart(workspaceID string) error +func (s *ProcessService) Status(workspaceID string) (process.Status, error) +``` + +Required behavior: + +- `Start`, `Restart`, and `Status` verify workspace exists. +- `Stop` should return process manager's result directly; if no process exists, return not found. +- `Restart` uses workspace root from manager. + +- [ ] **Step 5: Run package tests** + +Run: + +```sh +go test ./internal/service ./internal/model ./internal/workspace ./internal/fs ./internal/process +``` + +Expected: + +- All packages compile and tests pass. + +--- + +## Task 6: Implement HTTP router and handlers + +**Files:** +- Create: `internal/api/router.go` +- Create: `internal/api/workspace_handler.go` +- Create: `internal/api/file_handler.go` +- Create: `internal/api/process_handler.go` +- Create: `internal/api/router_test.go` + +**Interfaces:** +- Produces: `api.NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) http.Handler`. + +- [ ] **Step 1: Write HTTP smoke tests first** + +Create `internal/api/router_test.go` with tests: + +```go +func TestHealthz(t *testing.T) +func TestCreateWorkspace(t *testing.T) +func TestWriteReadFile(t *testing.T) +func TestProcessStatus(t *testing.T) +``` + +Required assertions: + +- `GET /healthz` returns `200` and `{"status":"ok"}`. +- `POST /api/workspaces` with `{"id":"user1"}` returns `201` and `{"id":"user1"}`. +- `PUT /api/workspaces/user1/files/write` with `{"path":"main.go","content":"package main\n"}` returns `204`. +- `GET /api/workspaces/user1/files/read?path=main.go` returns content. +- `GET /api/workspaces/user1/process/status` returns `200`, workspace ID, and `running:false`. + +Test setup must use `t.TempDir()` for workspace root and a process manager with a harmless command. Do not require real OpenCode. + +- [ ] **Step 2: Implement router** + +Create `internal/api/router.go`: + +```go +func NewRouter(workspaces *service.WorkspaceService, files *service.FileService, processes *service.ProcessService) http.Handler +``` + +Required routes: + +```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 +``` + +Use only the standard library. Since Go 1.22 `http.ServeMux` supports method/path patterns, use patterns like: + +```go +mux.HandleFunc("GET /healthz", healthHandler) +mux.HandleFunc("POST /api/workspaces", workspaceHandler.create) +mux.HandleFunc("GET /api/workspaces/{id}", workspaceHandler.get) +``` + +Use `r.PathValue("id")` for path variables. + +- [ ] **Step 3: Implement workspace handler** + +Create `internal/api/workspace_handler.go`. + +Required behavior: + +- Decode JSON for create. +- Empty or invalid JSON returns `400`. +- Create success returns `201` with `model.WorkspaceResponse{ID: ws.ID}`. +- Get success returns `200` with ID only. +- Delete success returns `204`. +- Map `util.CodeBadRequest` to HTTP 400, `CodeNotFound` to 404, `CodeConflict` to 409, default to 500. + +- [ ] **Step 4: Implement file handler** + +Create `internal/api/file_handler.go`. + +Required behavior: + +- `List` reads query `path`; default `.`. +- `Read` reads query `path`; missing path returns `400`. +- `Write` decodes `model.WriteFileRequest`; missing path returns `400`; success returns `204`. +- `Mkdir` decodes `model.MkdirRequest`; missing path returns `400`; success returns `204`. +- `Remove` reads query `path`; missing path returns `400`; success returns `204`. +- `Rename` decodes `model.RenameRequest`; missing old or new path returns `400`; success returns `204`. +- File read returns `model.ReadFileResponse` with content as UTF-8 string. + +- [ ] **Step 5: Implement process handler** + +Create `internal/api/process_handler.go`. + +Required behavior: + +- Start/stop/restart return `204` on success. +- Status returns `model.ProcessStatusResponse`. +- Error mapping matches workspace handler. + +- [ ] **Step 6: Run HTTP tests** + +Run: + +```sh +go test ./internal/api +``` + +Expected: + +- All tests pass. + +--- + +## Task 7: Wire server entrypoint and watcher placeholder + +**Files:** +- Create: `cmd/server/main.go` +- Create: `internal/watcher/watcher.go` + +**Interfaces:** +- Produces runnable server with `go run ./cmd/server`. +- Produces placeholder `watcher.Watcher` interface for future expansion. + +- [ ] **Step 1: Implement watcher placeholder** + +Create `internal/watcher/watcher.go`: + +```go +type Event struct { + Type string `json:"type"` + Path string `json:"path"` +} + +type Watcher interface { + Start(root string) error + Stop() error + Events() <-chan Event +} +``` + +Do not wire this into API in v1. + +- [ ] **Step 2: Implement server main** + +Create `cmd/server/main.go`. + +Required behavior: + +- Load config from `configs/config.yaml` with `config.Load`. +- Create logger with `logger.New()`. +- Create workspace manager with configured root. +- Create process manager with configured OpenCode command. +- Create services. +- Create router. +- Start HTTP server at configured address. +- Log address and workspace root. + +Main dependency flow: + +```go +cfg, err := config.Load("configs/config.yaml") +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) +handler := api.NewRouter(workspaceSvc, fileSvc, processSvc) +server := &http.Server{Addr: cfg.Server.Addr, Handler: handler} +``` + +- [ ] **Step 3: Verify server starts** + +Run: + +```sh +go run ./cmd/server +``` + +Expected: + +- Server logs that it is listening on `:8080`. +- Stop it manually after confirming startup. + +In another shell, if needed: + +```sh +curl -s http://localhost:8080/healthz +``` + +Expected response: + +```json +{"status":"ok"} +``` + +--- + +## Task 8: Final validation and cleanup + +**Files:** +- Modify only files created by Tasks 1-7 if validation exposes issues. + +**Interfaces:** +- Produces fully validated backend skeleton. + +- [ ] **Step 1: Format all Go code** + +Run: + +```sh +gofmt -w cmd internal pkg +``` + +Expected: + +- Command exits successfully. + +- [ ] **Step 2: Tidy modules** + +Run: + +```sh +go mod tidy +``` + +Expected: + +- `go.sum` is created or updated if required by `gopkg.in/yaml.v3`. + +- [ ] **Step 3: Run all tests** + +Run: + +```sh +go test ./... +``` + +Expected: + +- All tests pass. + +- [ ] **Step 4: Check handler boundary rule** + +Run: + +```sh +grep -R "os\.\|exec\." internal/api || true +``` + +Expected: + +- No matches. + +If matches exist, move those calls out of `internal/api` into services or lower-level packages. + +- [ ] **Step 5: Run server smoke test** + +Run server: + +```sh +go run ./cmd/server +``` + +In another terminal: + +```sh +curl -s http://localhost:8080/healthz +curl -s -X POST http://localhost:8080/api/workspaces \ + -H 'Content-Type: application/json' \ + -d '{"id":"user1"}' +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"}' +curl -s 'http://localhost:8080/api/workspaces/user1/files/read?path=main.go' +``` + +Expected: + +- Health returns `{"status":"ok"}`. +- Workspace create returns `{"id":"user1"}`. +- Write returns no body with HTTP 204. +- Read returns `{"path":"main.go","content":"package main\n"}`. + +- [ ] **Step 6: Document final result** + +Update `README.md` only if the actual commands or routes differ from the planned ones. Do not add future-roadmap promises beyond the explicit v1 non-goals. + +--- + +## Codex Execution Prompt + +Use this exact structure when dispatching Codex for implementation: + +```text +## Context +- Project root: /Users/taochen/llm/codespace +- Tech Stack: Go 1.22+, standard library HTTP router, gopkg.in/yaml.v3 for config. +- Design spec: docs/superpowers/specs/2026-07-02-codespace-design.md +- Implementation plan: docs/superpowers/plans/2026-07-02-codespace-backend-skeleton.md +- Current directory is the project root. Do not create a nested codespace directory. + +## Task +Generate the runnable Go backend MVP skeleton described by the implementation plan. +Steps: +1. Create the files listed in Tasks 1-7. +2. Implement tests before or alongside implementation for workspace path validation, LocalFS, process manager, and HTTP routes. +3. Run formatting, module tidy, tests, and boundary checks from Task 8. + +## Constraints +- Do not add Docker, auth, Git, LSP, AI Agent orchestration, frontend, or full watcher/WebSocket streaming. +- Do not expose host absolute paths in API responses. +- Handlers must not call os.* file APIs or exec.*; use services. +- Prevent workspace path escape and unsafe workspace IDs. +- Use only standard library plus gopkg.in/yaml.v3. +- If an existing file has meaningful content, stop and report before overwriting it. + +## Acceptance +- gofmt -w cmd internal pkg succeeds. +- go mod tidy succeeds. +- go test ./... passes. +- grep -R "os\.\|exec\." internal/api || true returns no matches. +- go run ./cmd/server starts and /healthz returns {"status":"ok"}. +``` + +--- + +## Self-Review + +- Spec coverage: The plan covers project root handling, runnable backend, workspace manager, FileSystem abstraction, LocalFS, process manager, services, API handlers, config, docs, tests, and watcher placeholder. Non-goals are explicitly excluded. +- Placeholder scan: No `TBD`, `TODO`, or undefined future implementation steps remain. +- Type consistency: Function names and signatures are consistent across file structure, task interfaces, and wiring steps. +- Scope check: The plan is one cohesive MVP backend skeleton. Git, LSP, AI Agent, frontend, and watcher streaming remain outside v1 as required. diff --git a/docs/superpowers/plans/2026-07-02-gin-server-composition.md b/docs/superpowers/plans/2026-07-02-gin-server-composition.md new file mode 100644 index 0000000..eb234de --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-gin-server-composition.md @@ -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. diff --git a/docs/superpowers/specs/2026-07-02-codespace-design.md b/docs/superpowers/specs/2026-07-02-codespace-design.md new file mode 100644 index 0000000..6a98ba1 --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-codespace-design.md @@ -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 为 `/`。 +- 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。 diff --git a/docs/superpowers/specs/2026-07-02-gin-server-composition-design.md b/docs/superpowers/specs/2026-07-02-gin-server-composition-design.md new file mode 100644 index 0000000..02db6ce --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-gin-server-composition-design.md @@ -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 通过。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b5d1a9 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0397106 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 0000000..a9a1d29 --- /dev/null +++ b/internal/api/errors.go @@ -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, + }}) +} diff --git a/internal/api/file_handler.go b/internal/api/file_handler.go new file mode 100644 index 0000000..efaa1ea --- /dev/null +++ b/internal/api/file_handler.go @@ -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) +} diff --git a/internal/api/process_handler.go b/internal/api/process_handler.go new file mode 100644 index 0000000..82008a9 --- /dev/null +++ b/internal/api/process_handler.go @@ -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, + }) +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..ecf10f7 --- /dev/null +++ b/internal/api/router.go @@ -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"}) +} diff --git a/internal/api/router_test.go b/internal/api/router_test.go new file mode 100644 index 0000000..e3c851b --- /dev/null +++ b/internal/api/router_test.go @@ -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") + } +} diff --git a/internal/api/workspace_handler.go b/internal/api/workspace_handler.go new file mode 100644 index 0000000..124c891 --- /dev/null +++ b/internal/api/workspace_handler.go @@ -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) +} diff --git a/internal/fs/fileinfo.go b/internal/fs/fileinfo.go new file mode 100644 index 0000000..9ef4e84 --- /dev/null +++ b/internal/fs/fileinfo.go @@ -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"` +} diff --git a/internal/fs/filesystem.go b/internal/fs/filesystem.go new file mode 100644 index 0000000..5792558 --- /dev/null +++ b/internal/fs/filesystem.go @@ -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) +} diff --git a/internal/fs/localfs.go b/internal/fs/localfs.go new file mode 100644 index 0000000..131bb1a --- /dev/null +++ b/internal/fs/localfs.go @@ -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 +} diff --git a/internal/fs/localfs_test.go b/internal/fs/localfs_test.go new file mode 100644 index 0000000..df9911a --- /dev/null +++ b/internal/fs/localfs_test.go @@ -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) + } +} diff --git a/internal/model/file.go b/internal/model/file.go new file mode 100644 index 0000000..e8d29a5 --- /dev/null +++ b/internal/model/file.go @@ -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"` +} diff --git a/internal/model/process.go b/internal/model/process.go new file mode 100644 index 0000000..a073dfb --- /dev/null +++ b/internal/model/process.go @@ -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"` +} diff --git a/internal/model/workspace.go b/internal/model/workspace.go new file mode 100644 index 0000000..fbc9feb --- /dev/null +++ b/internal/model/workspace.go @@ -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"` +} diff --git a/internal/process/manager.go b/internal/process/manager.go new file mode 100644 index 0000000..022031f --- /dev/null +++ b/internal/process/manager.go @@ -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, + } +} diff --git a/internal/process/manager_test.go b/internal/process/manager_test.go new file mode 100644 index 0000000..b3e41ac --- /dev/null +++ b/internal/process/manager_test.go @@ -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 {} +} diff --git a/internal/process/opencode.go b/internal/process/opencode.go new file mode 100644 index 0000000..f2a3489 --- /dev/null +++ b/internal/process/opencode.go @@ -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 +} diff --git a/internal/process/session.go b/internal/process/session.go new file mode 100644 index 0000000..1122eee --- /dev/null +++ b/internal/process/session.go @@ -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 +} diff --git a/internal/service/file_service.go b/internal/service/file_service.go new file mode 100644 index 0000000..de8fd60 --- /dev/null +++ b/internal/service/file_service.go @@ -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) +} diff --git a/internal/service/process_service.go b/internal/service/process_service.go new file mode 100644 index 0000000..f449bf5 --- /dev/null +++ b/internal/service/process_service.go @@ -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 +} diff --git a/internal/service/workspace_service.go b/internal/service/workspace_service.go new file mode 100644 index 0000000..9913a24 --- /dev/null +++ b/internal/service/workspace_service.go @@ -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) +} diff --git a/internal/util/errors.go b/internal/util/errors.go new file mode 100644 index 0000000..b4c65c7 --- /dev/null +++ b/internal/util/errors.go @@ -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 +} diff --git a/internal/util/path.go b/internal/util/path.go new file mode 100644 index 0000000..dc8d2ad --- /dev/null +++ b/internal/util/path.go @@ -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)) +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go new file mode 100644 index 0000000..2dafc66 --- /dev/null +++ b/internal/watcher/watcher.go @@ -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 +} diff --git a/internal/workspace/manager.go b/internal/workspace/manager.go new file mode 100644 index 0000000..ad34a4a --- /dev/null +++ b/internal/workspace/manager.go @@ -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 +} diff --git a/internal/workspace/path.go b/internal/workspace/path.go new file mode 100644 index 0000000..c4f1738 --- /dev/null +++ b/internal/workspace/path.go @@ -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) +} diff --git a/internal/workspace/path_test.go b/internal/workspace/path_test.go new file mode 100644 index 0000000..6cf380f --- /dev/null +++ b/internal/workspace/path_test.go @@ -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) + } +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go new file mode 100644 index 0000000..07758b1 --- /dev/null +++ b/internal/workspace/workspace.go @@ -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:"-"` +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..87e29eb --- /dev/null +++ b/pkg/config/config.go @@ -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 +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..0268797 --- /dev/null +++ b/pkg/config/config_test.go @@ -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") + } +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..73f6c1e --- /dev/null +++ b/pkg/logger/logger.go @@ -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) +} diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..a1cc994 --- /dev/null +++ b/pkg/response/response.go @@ -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}}) +} diff --git a/web/.gitkeep b/web/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/web/.gitkeep @@ -0,0 +1 @@ +