package config import ( "fmt" "os" "strconv" "strings" "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"` Shell ShellConfig `yaml:"shell"` File FileConfig `yaml:"file"` Gin GinConfig `yaml:"gin"` Log LogConfig `yaml:"log"` } // 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"` } // ShellConfig holds interactive shell settings. type ShellConfig struct { Command string `yaml:"command"` Args []string `yaml:"args"` } // FileConfig holds file operation limits. type FileConfig struct { MaxWriteBytes int64 `yaml:"maxWriteBytes"` } // GinConfig holds Gin framework runtime settings. type GinConfig struct { Mode string `yaml:"mode"` } // LogConfig holds structured logging settings. type LogConfig struct { Level string `yaml:"level"` Format string `yaml:"format"` } // 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"` Shell ShellConfig `yaml:"shell"` File FileConfig `yaml:"file"` Gin GinConfig `yaml:"gin"` Log LogConfig `yaml:"log"` } 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"}, Shell: ShellConfig{Command: "bash", Args: []string{"-i"}}, File: FileConfig{MaxWriteBytes: 1 << 20}, Gin: GinConfig{Mode: "release"}, Log: LogConfig{Level: "info", Format: "json"}, } } // 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 } if err := validateGinMode(cfg.Gin.Mode); 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 } cfg.Shell.Command = raw.Shell.Command cfg.Shell.Args = raw.Shell.Args if raw.File.MaxWriteBytes != 0 { cfg.File.MaxWriteBytes = raw.File.MaxWriteBytes } if raw.Gin.Mode != "" { cfg.Gin.Mode = raw.Gin.Mode } if raw.Log.Level != "" { cfg.Log.Level = raw.Log.Level } if raw.Log.Format != "" { cfg.Log.Format = raw.Log.Format } 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 } if v := os.Getenv("CODESPACE_SHELL_COMMAND"); v != "" { cfg.Shell.Command = v } if v := os.Getenv("CODESPACE_SHELL_ARGS"); v != "" { cfg.Shell.Args = strings.Split(v, ",") } if v := os.Getenv("CODESPACE_FILE_MAX_WRITE_BYTES"); v != "" { n, err := strconv.ParseInt(v, 10, 64) if err != nil { return fmt.Errorf("invalid integer for CODESPACE_FILE_MAX_WRITE_BYTES: %w", err) } cfg.File.MaxWriteBytes = n } if v := os.Getenv("CODESPACE_GIN_MODE"); v != "" { cfg.Gin.Mode = v } if v := os.Getenv("CODESPACE_LOG_LEVEL"); v != "" { cfg.Log.Level = v } if v := os.Getenv("CODESPACE_LOG_FORMAT"); v != "" { cfg.Log.Format = v } return nil } func validateGinMode(mode string) error { switch mode { case "debug", "release", "test": return nil default: return fmt.Errorf("invalid gin mode %q: must be one of debug, release, test", mode) } }