feat: scaffold codespace backend

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-02 15:07:54 +08:00
co-authored by Claude Fable 5
commit 411ed1f8ba
43 changed files with 4428 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
"gopkg.in/yaml.v3"
)
// Config holds all runtime configuration for the codespace server.
type Config struct {
Server ServerConfig `yaml:"server"`
Workspace WorkspaceConfig `yaml:"workspace"`
Process ProcessConfig `yaml:"process"`
}
// ServerConfig holds HTTP server settings.
type ServerConfig struct {
Addr string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxHeaderBytes int
}
// WorkspaceConfig holds workspace directory settings.
type WorkspaceConfig struct {
Root string `yaml:"root"`
}
// ProcessConfig holds OpenCode process settings.
type ProcessConfig struct {
OpenCodeCommand string `yaml:"opencodeCommand"`
}
// rawConfig is used for YAML deserialization so that duration strings can be
// parsed explicitly rather than relying on implicit time.Duration unmarshalling.
type rawConfig struct {
Server rawServerConfig `yaml:"server"`
Workspace WorkspaceConfig `yaml:"workspace"`
Process ProcessConfig `yaml:"process"`
}
type rawServerConfig struct {
Addr string `yaml:"addr"`
ReadTimeout string `yaml:"readTimeout"`
WriteTimeout string `yaml:"writeTimeout"`
IdleTimeout string `yaml:"idleTimeout"`
MaxHeaderBytes int `yaml:"maxHeaderBytes"`
}
// Default returns the built-in default configuration.
func Default() Config {
return Config{
Server: ServerConfig{
Addr: ":8080",
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20, // 1 MiB
},
Workspace: WorkspaceConfig{Root: "./workspaces"},
Process: ProcessConfig{OpenCodeCommand: "opencode"},
}
}
// Load reads configuration from the given YAML file path (if it exists),
// then applies environment variable overrides. Missing files are not an error.
func Load(path string) (Config, error) {
cfg := Default()
data, err := os.ReadFile(path)
if err == nil {
var raw rawConfig
if err := yaml.Unmarshal(data, &raw); err != nil {
return Config{}, err
}
if err := applyRaw(&cfg, raw); err != nil {
return Config{}, err
}
} else if !os.IsNotExist(err) {
return Config{}, err
}
if err := applyEnv(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}
func applyRaw(cfg *Config, raw rawConfig) error {
if raw.Server.Addr != "" {
cfg.Server.Addr = raw.Server.Addr
}
if err := applyDuration(&cfg.Server.ReadTimeout, raw.Server.ReadTimeout, "readTimeout"); err != nil {
return err
}
if err := applyDuration(&cfg.Server.WriteTimeout, raw.Server.WriteTimeout, "writeTimeout"); err != nil {
return err
}
if err := applyDuration(&cfg.Server.IdleTimeout, raw.Server.IdleTimeout, "idleTimeout"); err != nil {
return err
}
if raw.Server.MaxHeaderBytes != 0 {
cfg.Server.MaxHeaderBytes = raw.Server.MaxHeaderBytes
}
if raw.Workspace.Root != "" {
cfg.Workspace.Root = raw.Workspace.Root
}
if raw.Process.OpenCodeCommand != "" {
cfg.Process.OpenCodeCommand = raw.Process.OpenCodeCommand
}
return nil
}
func applyDuration(target *time.Duration, value, name string) error {
if value == "" {
return nil
}
d, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("invalid duration for %s: %w", name, err)
}
*target = d
return nil
}
func applyEnv(cfg *Config) error {
if v := os.Getenv("CODESPACE_ADDR"); v != "" {
cfg.Server.Addr = v
}
if v := os.Getenv("CODESPACE_READ_TIMEOUT"); v != "" {
if err := applyDuration(&cfg.Server.ReadTimeout, v, "CODESPACE_READ_TIMEOUT"); err != nil {
return err
}
}
if v := os.Getenv("CODESPACE_WRITE_TIMEOUT"); v != "" {
if err := applyDuration(&cfg.Server.WriteTimeout, v, "CODESPACE_WRITE_TIMEOUT"); err != nil {
return err
}
}
if v := os.Getenv("CODESPACE_IDLE_TIMEOUT"); v != "" {
if err := applyDuration(&cfg.Server.IdleTimeout, v, "CODESPACE_IDLE_TIMEOUT"); err != nil {
return err
}
}
if v := os.Getenv("CODESPACE_MAX_HEADER_BYTES"); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return fmt.Errorf("invalid integer for CODESPACE_MAX_HEADER_BYTES: %w", err)
}
cfg.Server.MaxHeaderBytes = n
}
if v := os.Getenv("CODESPACE_WORKSPACE_ROOT"); v != "" {
cfg.Workspace.Root = v
}
if v := os.Getenv("CODESPACE_OPENCODE_COMMAND"); v != "" {
cfg.Process.OpenCodeCommand = v
}
return nil
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDefaultIncludesServerNetworkSettings(t *testing.T) {
cfg := Default()
if cfg.Server.Addr != ":8080" {
t.Fatalf("Addr = %q, want :8080", cfg.Server.Addr)
}
if cfg.Server.ReadTimeout != 15*time.Second {
t.Fatalf("ReadTimeout = %v, want 15s", cfg.Server.ReadTimeout)
}
if cfg.Server.WriteTimeout != 15*time.Second {
t.Fatalf("WriteTimeout = %v, want 15s", cfg.Server.WriteTimeout)
}
if cfg.Server.IdleTimeout != 60*time.Second {
t.Fatalf("IdleTimeout = %v, want 60s", cfg.Server.IdleTimeout)
}
if cfg.Server.MaxHeaderBytes != 1048576 {
t.Fatalf("MaxHeaderBytes = %d, want 1048576", cfg.Server.MaxHeaderBytes)
}
}
func TestLoadParsesServerNetworkSettingsFromYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
data := []byte(`server:
addr: ":9090"
readTimeout: "2s"
writeTimeout: "3s"
idleTimeout: "4s"
maxHeaderBytes: 2048
workspace:
root: "./tmp-workspaces"
process:
opencodeCommand: "fake-opencode"
`)
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Server.Addr != ":9090" {
t.Fatalf("Addr = %q, want :9090", cfg.Server.Addr)
}
if cfg.Server.ReadTimeout != 2*time.Second || cfg.Server.WriteTimeout != 3*time.Second || cfg.Server.IdleTimeout != 4*time.Second {
t.Fatalf("timeouts = %v/%v/%v, want 2s/3s/4s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout)
}
if cfg.Server.MaxHeaderBytes != 2048 {
t.Fatalf("MaxHeaderBytes = %d, want 2048", cfg.Server.MaxHeaderBytes)
}
}
func TestLoadAppliesServerEnvironmentOverrides(t *testing.T) {
t.Setenv("CODESPACE_ADDR", ":7070")
t.Setenv("CODESPACE_READ_TIMEOUT", "5s")
t.Setenv("CODESPACE_WRITE_TIMEOUT", "6s")
t.Setenv("CODESPACE_IDLE_TIMEOUT", "7s")
t.Setenv("CODESPACE_MAX_HEADER_BYTES", "4096")
cfg, err := Load(filepath.Join(t.TempDir(), "missing.yaml"))
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Server.Addr != ":7070" {
t.Fatalf("Addr = %q, want :7070", cfg.Server.Addr)
}
if cfg.Server.ReadTimeout != 5*time.Second || cfg.Server.WriteTimeout != 6*time.Second || cfg.Server.IdleTimeout != 7*time.Second {
t.Fatalf("timeouts = %v/%v/%v, want 5s/6s/7s", cfg.Server.ReadTimeout, cfg.Server.WriteTimeout, cfg.Server.IdleTimeout)
}
if cfg.Server.MaxHeaderBytes != 4096 {
t.Fatalf("MaxHeaderBytes = %d, want 4096", cfg.Server.MaxHeaderBytes)
}
}
func TestLoadRejectsInvalidDuration(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
data := []byte(`server:
readTimeout: "garbage"
`)
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("expected invalid duration error")
}
}