feat: Phase 1 — config, auth, OAuth2 PKCE, CLI scaffold, token store
This commit is contained in:
204
internal/config/config.go
Normal file
204
internal/config/config.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
OAuth OAuthConfig `mapstructure:"oauth"`
|
||||
Google GoogleConfig `mapstructure:"google"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
API APIConfig `mapstructure:"api"`
|
||||
MCP MCPConfig `mapstructure:"mcp"`
|
||||
Security SecurityConfig `mapstructure:"security"`
|
||||
Output OutputConfig `mapstructure:"output"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Env string `mapstructure:"env"`
|
||||
LogLevel string `mapstructure:"log_level"`
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
ClientCredentialsFile string `mapstructure:"client_credentials_file"`
|
||||
TokenStoreFile string `mapstructure:"token_store_file"`
|
||||
DefaultPort int `mapstructure:"default_port"`
|
||||
OpenBrowser bool `mapstructure:"open_browser"`
|
||||
ManualFallback bool `mapstructure:"manual_fallback"`
|
||||
CallbackPath string `mapstructure:"callback_path"`
|
||||
}
|
||||
|
||||
type GoogleConfig struct {
|
||||
Scopes []string `mapstructure:"scopes"`
|
||||
DefaultCalendarID string `mapstructure:"default_calendar_id"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Bind string `mapstructure:"bind"`
|
||||
Port int `mapstructure:"port"`
|
||||
ReadTimeout time.Duration `mapstructure:"read_timeout"`
|
||||
WriteTimeout time.Duration `mapstructure:"write_timeout"`
|
||||
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
|
||||
}
|
||||
|
||||
type APIConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
BasePath string `mapstructure:"base_path"`
|
||||
Auth AuthCfg `mapstructure:"auth"`
|
||||
}
|
||||
|
||||
type AuthCfg struct {
|
||||
OAuthBearerEnabled bool `mapstructure:"oauth_bearer_enabled"`
|
||||
APIKeyEnabled bool `mapstructure:"api_key_enabled"`
|
||||
}
|
||||
|
||||
type MCPConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Stdio bool `mapstructure:"stdio"`
|
||||
HTTP bool `mapstructure:"http"`
|
||||
HTTPPath string `mapstructure:"http_path"`
|
||||
ProtocolVersion string `mapstructure:"protocol_version"`
|
||||
SessionRequired bool `mapstructure:"session_required"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
APIKeysFile string `mapstructure:"api_keys_file"`
|
||||
RedactSecretsInLogs bool `mapstructure:"redact_secrets_in_logs"`
|
||||
RequireTLSForRemote bool `mapstructure:"require_tls_for_remote_http"`
|
||||
}
|
||||
|
||||
type OutputConfig struct {
|
||||
Format string `mapstructure:"format"`
|
||||
Timezone string `mapstructure:"timezone"`
|
||||
}
|
||||
|
||||
func Load(cfgFile, profile string) (*Config, error) {
|
||||
v := viper.New()
|
||||
setDefaults(v)
|
||||
|
||||
v.SetEnvPrefix("GOCALGOO")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
if cfgFile != "" {
|
||||
v.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get home dir: %w", err)
|
||||
}
|
||||
cfgName := "config"
|
||||
if profile != "" {
|
||||
cfgName = "config." + profile
|
||||
}
|
||||
v.SetConfigName(cfgName)
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath(filepath.Join(home, ".config", "gocalgoo"))
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("./configs")
|
||||
}
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
cfg.OAuth.ClientCredentialsFile = expandHome(cfg.OAuth.ClientCredentialsFile)
|
||||
cfg.OAuth.TokenStoreFile = expandHome(cfg.OAuth.TokenStoreFile)
|
||||
cfg.Security.APIKeysFile = expandHome(cfg.Security.APIKeysFile)
|
||||
cfg.App.DataDir = expandHome(cfg.App.DataDir)
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func Validate(cfg *Config) error {
|
||||
if cfg.OAuth.ClientCredentialsFile == "" {
|
||||
return fmt.Errorf("oauth.client_credentials_file is required")
|
||||
}
|
||||
if cfg.OAuth.TokenStoreFile == "" {
|
||||
return fmt.Errorf("oauth.token_store_file is required")
|
||||
}
|
||||
if len(cfg.Google.Scopes) == 0 {
|
||||
return fmt.Errorf("google.scopes must not be empty")
|
||||
}
|
||||
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
|
||||
return fmt.Errorf("server.port must be 1-65535")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setDefaults(v *viper.Viper) {
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
v.SetDefault("app.name", "GoCalGoo")
|
||||
v.SetDefault("app.env", "development")
|
||||
v.SetDefault("app.log_level", "info")
|
||||
v.SetDefault("app.data_dir", filepath.Join(home, ".local", "share", "gocalgoo"))
|
||||
|
||||
v.SetDefault("oauth.client_credentials_file", filepath.Join(home, ".config", "gocalgoo", "credentials.json"))
|
||||
v.SetDefault("oauth.token_store_file", filepath.Join(home, ".config", "gocalgoo", "tokens.json"))
|
||||
v.SetDefault("oauth.default_port", 53682)
|
||||
v.SetDefault("oauth.open_browser", true)
|
||||
v.SetDefault("oauth.manual_fallback", true)
|
||||
v.SetDefault("oauth.callback_path", "/oauth/callback")
|
||||
|
||||
v.SetDefault("google.scopes", []string{
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/contacts",
|
||||
})
|
||||
v.SetDefault("google.default_calendar_id", "primary")
|
||||
|
||||
v.SetDefault("server.bind", "127.0.0.1")
|
||||
v.SetDefault("server.port", 8080)
|
||||
v.SetDefault("server.read_timeout", "15s")
|
||||
v.SetDefault("server.write_timeout", "30s")
|
||||
v.SetDefault("server.shutdown_timeout", "10s")
|
||||
|
||||
v.SetDefault("api.enabled", true)
|
||||
v.SetDefault("api.base_path", "/api/v1")
|
||||
v.SetDefault("api.auth.oauth_bearer_enabled", true)
|
||||
v.SetDefault("api.auth.api_key_enabled", true)
|
||||
|
||||
v.SetDefault("mcp.enabled", true)
|
||||
v.SetDefault("mcp.stdio", true)
|
||||
v.SetDefault("mcp.http", true)
|
||||
v.SetDefault("mcp.http_path", "/mcp")
|
||||
v.SetDefault("mcp.protocol_version", "2025-06-18")
|
||||
v.SetDefault("mcp.session_required", true)
|
||||
|
||||
v.SetDefault("security.api_keys_file", filepath.Join(home, ".config", "gocalgoo", "api-keys.yaml"))
|
||||
v.SetDefault("security.redact_secrets_in_logs", true)
|
||||
v.SetDefault("security.require_tls_for_remote_http", false)
|
||||
|
||||
v.SetDefault("output.format", "table")
|
||||
v.SetDefault("output.timezone", "UTC")
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
if path == "" {
|
||||
return path
|
||||
}
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(home, path[2:])
|
||||
}
|
||||
return path
|
||||
}
|
||||
73
internal/config/config_test.go
Normal file
73
internal/config/config_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
cfg, err := Load("", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "GoCalGoo", cfg.App.Name)
|
||||
assert.Equal(t, 53682, cfg.OAuth.DefaultPort)
|
||||
assert.Equal(t, "primary", cfg.Google.DefaultCalendarID)
|
||||
assert.Equal(t, 8080, cfg.Server.Port)
|
||||
assert.NotEmpty(t, cfg.Google.Scopes)
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid config",
|
||||
mutate: func(c *Config) {},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing credentials file",
|
||||
mutate: func(c *Config) {
|
||||
c.OAuth.ClientCredentialsFile = ""
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing token store",
|
||||
mutate: func(c *Config) {
|
||||
c.OAuth.TokenStoreFile = ""
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty scopes",
|
||||
mutate: func(c *Config) {
|
||||
c.Google.Scopes = nil
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid port",
|
||||
mutate: func(c *Config) {
|
||||
c.Server.Port = 0
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg, err := Load("", "")
|
||||
require.NoError(t, err)
|
||||
tc.mutate(cfg)
|
||||
err = Validate(cfg)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user