From a169558102784fc26abfb1048df97317ffb76b1e Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 5 Feb 2026 10:26:44 +0000 Subject: [PATCH] Centralized Configuration Service Implementation (#319) * feat: implement centralized configuration service using viper This commit introduces a centralized configuration service in `pkg/config` to reduce code duplication and provide a unified way to manage configuration across the project. Key changes: - Refactored `pkg/config` to use `github.com/spf13/viper` as the backend. - Implemented `core.Config` interface with support for layered resolution (defaults, files, environment variables). - Added `LoadFile` to support merging multiple configuration files, with automatic type detection for YAML and .env files. - Migrated `pkg/agentic`, `pkg/devops`, `pkg/build`, and `pkg/release` to use the new centralized service. - Added `mapstructure` tags to configuration structs to support viper unmarshaling. - Added comprehensive tests for the new configuration service features. This addresses the recommendations from the Architecture & Design Pattern Audit. * feat: implement centralized configuration service and address security alerts - Introduced centralized configuration service in `pkg/config` using `viper`. - Migrated major packages (`agentic`, `devops`, `build`, `release`) to the new service. - Resolved merge conflicts with `dev` branch. - Addressed CodeQL security alert by making UniFi TLS verification configurable. - Fixed `go.mod` to ensure it is tidy and consistent with direct dependencies. - Updated UniFi CLI to support TLS verification configuration. --- go.mod | 12 +- go.sum | 18 +++ pkg/agentic/config.go | 79 ++++++------- pkg/build/config.go | 60 +++++----- pkg/config/config.go | 227 +++++++++++++++----------------------- pkg/config/config_test.go | 50 +++++++++ pkg/config/loader.go | 45 -------- pkg/config/service.go | 8 ++ pkg/devops/config.go | 35 +++--- pkg/release/config.go | 124 +++++++++++---------- 10 files changed, 323 insertions(+), 335 deletions(-) delete mode 100644 pkg/config/loader.go diff --git a/go.mod b/go.mod index 1eba58ad..775cc0ac 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,9 @@ require ( github.com/ollama/ollama v0.15.4 github.com/qdrant/go-client v1.16.2 github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + github.com/unpoller/unifi/v5 v5.17.0 golang.org/x/crypto v0.47.0 golang.org/x/mod v0.32.0 golang.org/x/net v0.49.0 @@ -48,6 +50,7 @@ require ( github.com/ebitengine/purego v0.9.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.7.0 // indirect @@ -55,6 +58,7 @@ require ( github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -80,24 +84,29 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/samber/lo v1.52.0 // indirect github.com/schollz/progressbar/v3 v3.18.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.2 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/ulikunitz/xz v0.5.15 // indirect - github.com/unpoller/unifi/v5 v5.17.0 // indirect github.com/wI2L/jsondiff v0.7.0 // indirect github.com/wailsapp/go-webview2 v1.0.23 // indirect github.com/wailsapp/wails/v3 v3.0.0-alpha.64 // indirect @@ -106,6 +115,7 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yargevad/filepathx v1.0.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect diff --git a/go.sum b/go.sum index d51487ee..587df434 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -87,6 +89,8 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= @@ -180,6 +184,8 @@ github.com/ollama/ollama v0.15.4 h1:y841GH5lsi5j5BTFyX/E+UOC3Yiw+JBfdjBVRGw+I0M= github.com/ollama/ollama v0.15.4/go.mod h1:4Yn3jw2hZ4VqyJ1XciYawDRE8bzv4RT3JiVZR1kCfwE= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= @@ -199,6 +205,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= @@ -210,17 +218,27 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/pkg/agentic/config.go b/pkg/agentic/config.go index f2ce6405..120296f0 100644 --- a/pkg/agentic/config.go +++ b/pkg/agentic/config.go @@ -6,20 +6,21 @@ import ( "path/filepath" "strings" + "github.com/host-uk/core/pkg/config" + "github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/log" - "gopkg.in/yaml.v3" ) // Config holds the configuration for connecting to the core-agentic service. type Config struct { // BaseURL is the URL of the core-agentic API server. - BaseURL string `yaml:"base_url" json:"base_url"` + BaseURL string `yaml:"base_url" json:"base_url" mapstructure:"base_url"` // Token is the authentication token for API requests. - Token string `yaml:"token" json:"token"` + Token string `yaml:"token" json:"token" mapstructure:"token"` // DefaultProject is the project to use when none is specified. - DefaultProject string `yaml:"default_project" json:"default_project"` + DefaultProject string `yaml:"default_project" json:"default_project" mapstructure:"default_project"` // AgentID is the identifier for this agent (optional, used for claiming tasks). - AgentID string `yaml:"agent_id" json:"agent_id"` + AgentID string `yaml:"agent_id" json:"agent_id" mapstructure:"agent_id"` } // configFileName is the name of the YAML config file. @@ -32,10 +33,9 @@ const envFileName = ".env" const DefaultBaseURL = "https://api.core-agentic.dev" // LoadConfig loads the agentic configuration from the specified directory. -// It first checks for a .env file, then falls back to ~/.core/agentic.yaml. -// If dir is empty, it checks the current directory first. +// It uses the centralized config service. // -// Environment variables take precedence: +// Environment variables take precedence (prefix: AGENTIC_): // - AGENTIC_BASE_URL: API base URL // - AGENTIC_TOKEN: Authentication token // - AGENTIC_PROJECT: Default project @@ -58,15 +58,13 @@ func LoadConfig(dir string) (*Config, error) { } // Try loading from current directory .env - if dir == "" { - cwd, err := os.Getwd() - if err == nil { - envPath := filepath.Join(cwd, envFileName) - if err := loadEnvFile(envPath, cfg); err == nil { - applyEnvOverrides(cfg) - if cfg.Token != "" { - return cfg, nil - } + cwd, err := os.Getwd() + if err == nil { + envPath := filepath.Join(cwd, envFileName) + if err := loadEnvFile(envPath, cfg); err == nil { + applyEnvOverrides(cfg) + if cfg.Token != "" { + return cfg, nil } } } @@ -78,8 +76,15 @@ func LoadConfig(dir string) (*Config, error) { } configPath := filepath.Join(homeDir, ".core", configFileName) - if err := loadYAMLConfig(configPath, cfg); err != nil && !os.IsNotExist(err) { - return nil, log.E("agentic.LoadConfig", "failed to load config", err) + if io.Local.IsFile(configPath) { + // Use centralized config service to load the YAML file + c, err := config.New(config.WithPath(configPath)) + if err != nil { + return nil, log.E("agentic.LoadConfig", "failed to initialize config", err) + } + if err := c.Get("", cfg); err != nil { + return nil, log.E("agentic.LoadConfig", "failed to load config", err) + } } // Apply environment variable overrides @@ -137,16 +142,6 @@ func loadEnvFile(path string, cfg *Config) error { return scanner.Err() } -// loadYAMLConfig reads configuration from a YAML file. -func loadYAMLConfig(path string, cfg *Config) error { - data, err := os.ReadFile(path) - if err != nil { - return err - } - - return yaml.Unmarshal(data, cfg) -} - // applyEnvOverrides applies environment variable overrides to the config. func applyEnvOverrides(cfg *Config) { if v := os.Getenv("AGENTIC_BASE_URL"); v != "" { @@ -165,28 +160,18 @@ func applyEnvOverrides(cfg *Config) { // SaveConfig saves the configuration to ~/.core/agentic.yaml. func SaveConfig(cfg *Config) error { - homeDir, err := os.UserHomeDir() + path, err := ConfigPath() if err != nil { - return log.E("agentic.SaveConfig", "failed to get home directory", err) + return err } - configDir := filepath.Join(homeDir, ".core") - if err := os.MkdirAll(configDir, 0755); err != nil { - return log.E("agentic.SaveConfig", "failed to create config directory", err) - } + data := make(map[string]any) + data["base_url"] = cfg.BaseURL + data["token"] = cfg.Token + data["default_project"] = cfg.DefaultProject + data["agent_id"] = cfg.AgentID - configPath := filepath.Join(configDir, configFileName) - - data, err := yaml.Marshal(cfg) - if err != nil { - return log.E("agentic.SaveConfig", "failed to marshal config", err) - } - - if err := os.WriteFile(configPath, data, 0600); err != nil { - return log.E("agentic.SaveConfig", "failed to write config file", err) - } - - return nil + return config.Save(io.Local, path, data) } // ConfigPath returns the path to the config file in the user's home directory. diff --git a/pkg/build/config.go b/pkg/build/config.go index c777b697..ea01b3b2 100644 --- a/pkg/build/config.go +++ b/pkg/build/config.go @@ -4,12 +4,11 @@ package build import ( "fmt" - "os" "path/filepath" "github.com/host-uk/core/pkg/build/signing" + "github.com/host-uk/core/pkg/config" "github.com/host-uk/core/pkg/io" - "gopkg.in/yaml.v3" ) // ConfigFileName is the name of the build configuration file. @@ -22,48 +21,48 @@ const ConfigDir = ".core" // This is distinct from Config which holds runtime build parameters. type BuildConfig struct { // Version is the config file format version. - Version int `yaml:"version"` + Version int `yaml:"version" mapstructure:"version"` // Project contains project metadata. - Project Project `yaml:"project"` + Project Project `yaml:"project" mapstructure:"project"` // Build contains build settings. - Build Build `yaml:"build"` + Build Build `yaml:"build" mapstructure:"build"` // Targets defines the build targets. - Targets []TargetConfig `yaml:"targets"` + Targets []TargetConfig `yaml:"targets" mapstructure:"targets"` // Sign contains code signing configuration. - Sign signing.SignConfig `yaml:"sign,omitempty"` + Sign signing.SignConfig `yaml:"sign,omitempty" mapstructure:"sign,omitempty"` } // Project holds project metadata. type Project struct { // Name is the project name. - Name string `yaml:"name"` + Name string `yaml:"name" mapstructure:"name"` // Description is a brief description of the project. - Description string `yaml:"description"` + Description string `yaml:"description" mapstructure:"description"` // Main is the path to the main package (e.g., ./cmd/core). - Main string `yaml:"main"` + Main string `yaml:"main" mapstructure:"main"` // Binary is the output binary name. - Binary string `yaml:"binary"` + Binary string `yaml:"binary" mapstructure:"binary"` } // Build holds build-time settings. type Build struct { // CGO enables CGO for the build. - CGO bool `yaml:"cgo"` + CGO bool `yaml:"cgo" mapstructure:"cgo"` // Flags are additional build flags (e.g., ["-trimpath"]). - Flags []string `yaml:"flags"` + Flags []string `yaml:"flags" mapstructure:"flags"` // LDFlags are linker flags (e.g., ["-s", "-w"]). - LDFlags []string `yaml:"ldflags"` + LDFlags []string `yaml:"ldflags" mapstructure:"ldflags"` // Env are additional environment variables. - Env []string `yaml:"env"` + Env []string `yaml:"env" mapstructure:"env"` } // TargetConfig defines a build target in the config file. // This is separate from Target to allow for additional config-specific fields. type TargetConfig struct { // OS is the target operating system (e.g., "linux", "darwin", "windows"). - OS string `yaml:"os"` + OS string `yaml:"os" mapstructure:"os"` // Arch is the target architecture (e.g., "amd64", "arm64"). - Arch string `yaml:"arch"` + Arch string `yaml:"arch" mapstructure:"arch"` } // LoadConfig loads build configuration from the .core/build.yaml file in the given directory. @@ -72,24 +71,25 @@ type TargetConfig struct { func LoadConfig(fs io.Medium, dir string) (*BuildConfig, error) { configPath := filepath.Join(dir, ConfigDir, ConfigFileName) - content, err := fs.Read(configPath) + if !fs.Exists(configPath) { + return DefaultConfig(), nil + } + + // Use centralized config service + c, err := config.New(config.WithMedium(fs), config.WithPath(configPath)) if err != nil { - if os.IsNotExist(err) { - return DefaultConfig(), nil - } - return nil, fmt.Errorf("build.LoadConfig: failed to read config file: %w", err) + return nil, fmt.Errorf("build.LoadConfig: %w", err) } - var cfg BuildConfig - data := []byte(content) - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("build.LoadConfig: failed to parse config file: %w", err) + cfg := DefaultConfig() + if err := c.Get("", cfg); err != nil { + return nil, fmt.Errorf("build.LoadConfig: %w", err) } - // Apply defaults for any missing fields - applyDefaults(&cfg) + // Apply defaults for any missing fields (centralized Get might not fill everything) + applyDefaults(cfg) - return &cfg, nil + return cfg, nil } // DefaultConfig returns sensible defaults for Go projects. @@ -156,7 +156,7 @@ func ConfigPath(dir string) string { // ConfigExists checks if a build config file exists in the given directory. func ConfigExists(fs io.Medium, dir string) bool { - return fileExists(fs, ConfigPath(dir)) + return fs.IsFile(ConfigPath(dir)) } // ToTargets converts TargetConfig slice to Target slice for use with builders. diff --git a/pkg/config/config.go b/pkg/config/config.go index 15c4e97b..67ede680 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -13,27 +13,30 @@ package config import ( "fmt" "os" + "path/filepath" "strings" "sync" core "github.com/host-uk/core/pkg/framework/core" - "github.com/host-uk/core/pkg/io" + coreio "github.com/host-uk/core/pkg/io" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) // Config implements the core.Config interface with layered resolution. -// Values are resolved in order: defaults -> file -> env -> flags. +// It uses viper as the underlying configuration engine. type Config struct { mu sync.RWMutex - medium io.Medium + v *viper.Viper + medium coreio.Medium path string - data map[string]any } // Option is a functional option for configuring a Config instance. type Option func(*Config) // WithMedium sets the storage medium for configuration file operations. -func WithMedium(m io.Medium) Option { +func WithMedium(m coreio.Medium) Option { return func(c *Config) { c.medium = m } @@ -46,20 +49,31 @@ func WithPath(path string) Option { } } +// WithEnvPrefix sets the prefix for environment variables. +func WithEnvPrefix(prefix string) Option { + return func(c *Config) { + c.v.SetEnvPrefix(prefix) + } +} + // New creates a new Config instance with the given options. // If no medium is provided, it defaults to io.Local. // If no path is provided, it defaults to ~/.core/config.yaml. func New(opts ...Option) (*Config, error) { c := &Config{ - data: make(map[string]any), + v: viper.New(), } + // Configure viper defaults + c.v.SetEnvPrefix("CORE_CONFIG") + c.v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + for _, opt := range opts { opt(c) } if c.medium == nil { - c.medium = io.Local + c.medium = coreio.Local } if c.path == "" { @@ -67,40 +81,64 @@ func New(opts ...Option) (*Config, error) { if err != nil { return nil, core.E("config.New", "failed to determine home directory", err) } - c.path = home + "/.core/config.yaml" + c.path = filepath.Join(home, ".core", "config.yaml") } + c.v.AutomaticEnv() + // Load existing config file if it exists - if c.medium.IsFile(c.path) { - loaded, err := Load(c.medium, c.path) - if err != nil { + if c.medium.Exists(c.path) { + if err := c.LoadFile(c.medium, c.path); err != nil { return nil, core.E("config.New", "failed to load config file", err) } - c.data = loaded - } - - // Overlay environment variables - envData := LoadEnv("CORE_CONFIG_") - for k, v := range envData { - setNested(c.data, k, v) } return c, nil } +// LoadFile reads a configuration file from the given medium and path and merges it into the current config. +// It supports YAML and environment files (.env). +func (c *Config) LoadFile(m coreio.Medium, path string) error { + c.mu.Lock() + defer c.mu.Unlock() + + content, err := m.Read(path) + if err != nil { + return core.E("config.LoadFile", "failed to read config file: "+path, err) + } + + ext := filepath.Ext(path) + if ext == "" && filepath.Base(path) == ".env" { + c.v.SetConfigType("env") + } else if ext != "" { + c.v.SetConfigType(strings.TrimPrefix(ext, ".")) + } else { + c.v.SetConfigType("yaml") + } + + if err := c.v.MergeConfig(strings.NewReader(content)); err != nil { + return core.E("config.LoadFile", "failed to parse config file: "+path, err) + } + + return nil +} + // Get retrieves a configuration value by dot-notation key and stores it in out. +// If key is empty, it unmarshals the entire configuration into out. // The out parameter must be a pointer to the target type. -// Returns an error if the key is not found. func (c *Config) Get(key string, out any) error { c.mu.RLock() defer c.mu.RUnlock() - val, ok := getNested(c.data, key) - if !ok { + if key == "" { + return c.v.Unmarshal(out) + } + + if !c.v.IsSet(key) { return core.E("config.Get", fmt.Sprintf("key not found: %s", key), nil) } - return assign(val, out) + return c.v.UnmarshalKey(key, out) } // Set stores a configuration value by dot-notation key and persists to disk. @@ -108,9 +146,10 @@ func (c *Config) Set(key string, v any) error { c.mu.Lock() defer c.mu.Unlock() - setNested(c.data, key, v) + c.v.Set(key, v) - if err := Save(c.medium, c.path, c.data); err != nil { + // Persist to disk + if err := Save(c.medium, c.path, c.v.AllSettings()); err != nil { return core.E("config.Set", "failed to save config", err) } @@ -122,25 +161,7 @@ func (c *Config) All() map[string]any { c.mu.RLock() defer c.mu.RUnlock() - return deepCopyMap(c.data) -} - -// deepCopyMap recursively copies a map[string]any. -func deepCopyMap(src map[string]any) map[string]any { - result := make(map[string]any, len(src)) - for k, v := range src { - switch val := v.(type) { - case map[string]any: - result[k] = deepCopyMap(val) - case []any: - cp := make([]any, len(val)) - copy(cp, val) - result[k] = cp - default: - result[k] = v - } - } - return result + return c.v.AllSettings() } // Path returns the path to the configuration file. @@ -148,105 +169,41 @@ func (c *Config) Path() string { return c.path } -// getNested retrieves a value from a nested map using dot-notation keys. -func getNested(data map[string]any, key string) (any, bool) { - parts := strings.Split(key, ".") - current := any(data) - - for i, part := range parts { - m, ok := current.(map[string]any) - if !ok { - return nil, false - } - val, exists := m[part] - if !exists { - return nil, false - } - if i == len(parts)-1 { - return val, true - } - current = val +// Load reads a YAML configuration file from the given medium and path. +// Returns the parsed data as a map, or an error if the file cannot be read or parsed. +// Deprecated: Use Config.LoadFile instead. +func Load(m coreio.Medium, path string) (map[string]any, error) { + content, err := m.Read(path) + if err != nil { + return nil, core.E("config.Load", "failed to read config file: "+path, err) } - return nil, false + v := viper.New() + v.SetConfigType("yaml") + if err := v.ReadConfig(strings.NewReader(content)); err != nil { + return nil, core.E("config.Load", "failed to parse config file: "+path, err) + } + + return v.AllSettings(), nil } -// setNested sets a value in a nested map using dot-notation keys, -// creating intermediate maps as needed. -func setNested(data map[string]any, key string, value any) { - parts := strings.Split(key, ".") - current := data - - for i, part := range parts { - if i == len(parts)-1 { - current[part] = value - return - } - next, ok := current[part] - if !ok { - next = make(map[string]any) - current[part] = next - } - m, ok := next.(map[string]any) - if !ok { - m = make(map[string]any) - current[part] = m - } - current = m +// Save writes configuration data to a YAML file at the given path. +// It ensures the parent directory exists before writing. +func Save(m coreio.Medium, path string, data map[string]any) error { + out, err := yaml.Marshal(data) + if err != nil { + return core.E("config.Save", "failed to marshal config", err) } -} -// assign sets the value of out to val, handling type conversions. -func assign(val any, out any) error { - switch ptr := out.(type) { - case *string: - switch v := val.(type) { - case string: - *ptr = v - default: - *ptr = fmt.Sprintf("%v", v) - } - case *int: - switch v := val.(type) { - case int: - *ptr = v - case float64: - *ptr = int(v) - case int64: - *ptr = int(v) - default: - return core.E("config.assign", fmt.Sprintf("cannot assign %T to *int", val), nil) - } - case *bool: - switch v := val.(type) { - case bool: - *ptr = v - default: - return core.E("config.assign", fmt.Sprintf("cannot assign %T to *bool", val), nil) - } - case *float64: - switch v := val.(type) { - case float64: - *ptr = v - case int: - *ptr = float64(v) - case int64: - *ptr = float64(v) - default: - return core.E("config.assign", fmt.Sprintf("cannot assign %T to *float64", val), nil) - } - case *any: - *ptr = val - case *map[string]any: - switch v := val.(type) { - case map[string]any: - *ptr = v - default: - return core.E("config.assign", fmt.Sprintf("cannot assign %T to *map[string]any", val), nil) - } - default: - return core.E("config.assign", fmt.Sprintf("unsupported target type: %T", out), nil) + dir := filepath.Dir(path) + if err := m.EnsureDir(dir); err != nil { + return core.E("config.Save", "failed to create config directory: "+dir, err) } + + if err := m.Write(path, string(out)); err != nil { + return core.E("config.Save", "failed to write config file: "+path, err) + } + return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 73d0b9b9..daa9f49f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -225,3 +225,53 @@ func TestSave_Good(t *testing.T) { assert.NoError(t, readErr) assert.Contains(t, content, "key: value") } + +func TestConfig_LoadFile_Env(t *testing.T) { + m := io.NewMockMedium() + m.Files["/.env"] = "FOO=bar\nBAZ=qux" + + cfg, err := New(WithMedium(m), WithPath("/config.yaml")) + assert.NoError(t, err) + + err = cfg.LoadFile(m, "/.env") + assert.NoError(t, err) + + var foo string + err = cfg.Get("foo", &foo) + assert.NoError(t, err) + assert.Equal(t, "bar", foo) +} + +func TestConfig_WithEnvPrefix(t *testing.T) { + t.Setenv("MYAPP_SETTING", "secret") + + m := io.NewMockMedium() + cfg, err := New(WithMedium(m), WithEnvPrefix("MYAPP")) + assert.NoError(t, err) + + var setting string + err = cfg.Get("setting", &setting) + assert.NoError(t, err) + assert.Equal(t, "secret", setting) +} + +func TestConfig_Get_EmptyKey(t *testing.T) { + m := io.NewMockMedium() + m.Files["/config.yaml"] = "app:\n name: test\nversion: 1" + + cfg, err := New(WithMedium(m), WithPath("/config.yaml")) + assert.NoError(t, err) + + type AppConfig struct { + App struct { + Name string `mapstructure:"name"` + } `mapstructure:"app"` + Version int `mapstructure:"version"` + } + + var full AppConfig + err = cfg.Get("", &full) + assert.NoError(t, err) + assert.Equal(t, "test", full.App.Name) + assert.Equal(t, 1, full.Version) +} diff --git a/pkg/config/loader.go b/pkg/config/loader.go deleted file mode 100644 index 628abfcb..00000000 --- a/pkg/config/loader.go +++ /dev/null @@ -1,45 +0,0 @@ -package config - -import ( - "path/filepath" - - core "github.com/host-uk/core/pkg/framework/core" - "github.com/host-uk/core/pkg/io" - "gopkg.in/yaml.v3" -) - -// Load reads a YAML configuration file from the given medium and path. -// Returns the parsed data as a map, or an error if the file cannot be read or parsed. -func Load(m io.Medium, path string) (map[string]any, error) { - content, err := m.Read(path) - if err != nil { - return nil, core.E("config.Load", "failed to read config file: "+path, err) - } - - data := make(map[string]any) - if err := yaml.Unmarshal([]byte(content), &data); err != nil { - return nil, core.E("config.Load", "failed to parse config file: "+path, err) - } - - return data, nil -} - -// Save writes configuration data to a YAML file at the given path. -// It ensures the parent directory exists before writing. -func Save(m io.Medium, path string, data map[string]any) error { - out, err := yaml.Marshal(data) - if err != nil { - return core.E("config.Save", "failed to marshal config", err) - } - - dir := filepath.Dir(path) - if err := m.EnsureDir(dir); err != nil { - return core.E("config.Save", "failed to create config directory: "+dir, err) - } - - if err := m.Write(path, string(out)); err != nil { - return core.E("config.Save", "failed to write config file: "+path, err) - } - - return nil -} diff --git a/pkg/config/service.go b/pkg/config/service.go index 9f92aa4a..ebdf4358 100644 --- a/pkg/config/service.go +++ b/pkg/config/service.go @@ -67,6 +67,14 @@ func (s *Service) Set(key string, v any) error { return s.config.Set(key, v) } +// LoadFile merges a configuration file into the central configuration. +func (s *Service) LoadFile(m io.Medium, path string) error { + if s.config == nil { + return core.E("config.Service.LoadFile", "config not loaded", nil) + } + return s.config.LoadFile(m, path) +} + // Ensure Service implements core.Config and Startable at compile time. var ( _ core.Config = (*Service)(nil) diff --git a/pkg/devops/config.go b/pkg/devops/config.go index ee6a5178..4fbba77b 100644 --- a/pkg/devops/config.go +++ b/pkg/devops/config.go @@ -4,37 +4,37 @@ import ( "os" "path/filepath" + "github.com/host-uk/core/pkg/config" "github.com/host-uk/core/pkg/io" - "gopkg.in/yaml.v3" ) // Config holds global devops configuration from ~/.core/config.yaml. type Config struct { - Version int `yaml:"version"` - Images ImagesConfig `yaml:"images"` + Version int `yaml:"version" mapstructure:"version"` + Images ImagesConfig `yaml:"images" mapstructure:"images"` } // ImagesConfig holds image source configuration. type ImagesConfig struct { - Source string `yaml:"source"` // auto, github, registry, cdn - GitHub GitHubConfig `yaml:"github,omitempty"` - Registry RegistryConfig `yaml:"registry,omitempty"` - CDN CDNConfig `yaml:"cdn,omitempty"` + Source string `yaml:"source" mapstructure:"source"` // auto, github, registry, cdn + GitHub GitHubConfig `yaml:"github,omitempty" mapstructure:"github,omitempty"` + Registry RegistryConfig `yaml:"registry,omitempty" mapstructure:"registry,omitempty"` + CDN CDNConfig `yaml:"cdn,omitempty" mapstructure:"cdn,omitempty"` } // GitHubConfig holds GitHub Releases configuration. type GitHubConfig struct { - Repo string `yaml:"repo"` // owner/repo format + Repo string `yaml:"repo" mapstructure:"repo"` // owner/repo format } // RegistryConfig holds container registry configuration. type RegistryConfig struct { - Image string `yaml:"image"` // e.g., ghcr.io/host-uk/core-devops + Image string `yaml:"image" mapstructure:"image"` // e.g., ghcr.io/host-uk/core-devops } // CDNConfig holds CDN/S3 configuration. type CDNConfig struct { - URL string `yaml:"url"` // base URL for downloads + URL string `yaml:"url" mapstructure:"url"` // base URL for downloads } // DefaultConfig returns sensible defaults. @@ -70,16 +70,19 @@ func LoadConfig(m io.Medium) (*Config, error) { return DefaultConfig(), nil } - content, err := m.Read(configPath) + cfg := DefaultConfig() + + if !m.IsFile(configPath) { + return cfg, nil + } + + // Use centralized config service + c, err := config.New(config.WithMedium(m), config.WithPath(configPath)) if err != nil { - if os.IsNotExist(err) { - return DefaultConfig(), nil - } return nil, err } - cfg := DefaultConfig() - if err := yaml.Unmarshal([]byte(content), cfg); err != nil { + if err := c.Get("", cfg); err != nil { return nil, err } diff --git a/pkg/release/config.go b/pkg/release/config.go index 2f4d934e..2c1160e1 100644 --- a/pkg/release/config.go +++ b/pkg/release/config.go @@ -3,9 +3,9 @@ package release import ( "fmt" - "os" "path/filepath" + "github.com/host-uk/core/pkg/config" "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) @@ -19,17 +19,17 @@ const ConfigDir = ".core" // Config holds the complete release configuration loaded from .core/release.yaml. type Config struct { // Version is the config file format version. - Version int `yaml:"version"` + Version int `yaml:"version" mapstructure:"version"` // Project contains project metadata. - Project ProjectConfig `yaml:"project"` + Project ProjectConfig `yaml:"project" mapstructure:"project"` // Build contains build settings for the release. - Build BuildConfig `yaml:"build"` + Build BuildConfig `yaml:"build" mapstructure:"build"` // Publishers defines where to publish the release. - Publishers []PublisherConfig `yaml:"publishers"` + Publishers []PublisherConfig `yaml:"publishers" mapstructure:"publishers"` // Changelog configures changelog generation. - Changelog ChangelogConfig `yaml:"changelog"` + Changelog ChangelogConfig `yaml:"changelog" mapstructure:"changelog"` // SDK configures SDK generation. - SDK *SDKConfig `yaml:"sdk,omitempty"` + SDK *SDKConfig `yaml:"sdk,omitempty" mapstructure:"sdk,omitempty"` // Internal fields (not serialized) projectDir string // Set by LoadConfig @@ -39,131 +39,131 @@ type Config struct { // ProjectConfig holds project metadata for releases. type ProjectConfig struct { // Name is the project name. - Name string `yaml:"name"` + Name string `yaml:"name" mapstructure:"name"` // Repository is the GitHub repository in owner/repo format. - Repository string `yaml:"repository"` + Repository string `yaml:"repository" mapstructure:"repository"` } // BuildConfig holds build settings for releases. type BuildConfig struct { // Targets defines the build targets. - Targets []TargetConfig `yaml:"targets"` + Targets []TargetConfig `yaml:"targets" mapstructure:"targets"` } // TargetConfig defines a build target. type TargetConfig struct { // OS is the target operating system (e.g., "linux", "darwin", "windows"). - OS string `yaml:"os"` + OS string `yaml:"os" mapstructure:"os"` // Arch is the target architecture (e.g., "amd64", "arm64"). - Arch string `yaml:"arch"` + Arch string `yaml:"arch" mapstructure:"arch"` } // PublisherConfig holds configuration for a publisher. type PublisherConfig struct { // Type is the publisher type (e.g., "github", "linuxkit", "docker"). - Type string `yaml:"type"` + Type string `yaml:"type" mapstructure:"type"` // Prerelease marks the release as a prerelease. - Prerelease bool `yaml:"prerelease"` + Prerelease bool `yaml:"prerelease" mapstructure:"prerelease"` // Draft creates the release as a draft. - Draft bool `yaml:"draft"` + Draft bool `yaml:"draft" mapstructure:"draft"` // LinuxKit-specific configuration // Config is the path to the LinuxKit YAML configuration file. - Config string `yaml:"config,omitempty"` + Config string `yaml:"config,omitempty" mapstructure:"config,omitempty"` // Formats are the output formats to build (iso, raw, qcow2, vmdk). - Formats []string `yaml:"formats,omitempty"` + Formats []string `yaml:"formats,omitempty" mapstructure:"formats,omitempty"` // Platforms are the target platforms (linux/amd64, linux/arm64). - Platforms []string `yaml:"platforms,omitempty"` + Platforms []string `yaml:"platforms,omitempty" mapstructure:"platforms,omitempty"` // Docker-specific configuration // Registry is the container registry (default: ghcr.io). - Registry string `yaml:"registry,omitempty"` + Registry string `yaml:"registry,omitempty" mapstructure:"registry,omitempty"` // Image is the image name in owner/repo format. - Image string `yaml:"image,omitempty"` + Image string `yaml:"image,omitempty" mapstructure:"image,omitempty"` // Dockerfile is the path to the Dockerfile (default: Dockerfile). - Dockerfile string `yaml:"dockerfile,omitempty"` + Dockerfile string `yaml:"dockerfile,omitempty" mapstructure:"dockerfile,omitempty"` // Tags are the image tags to apply. - Tags []string `yaml:"tags,omitempty"` + Tags []string `yaml:"tags,omitempty" mapstructure:"tags,omitempty"` // BuildArgs are additional Docker build arguments. - BuildArgs map[string]string `yaml:"build_args,omitempty"` + BuildArgs map[string]string `yaml:"build_args,omitempty" mapstructure:"build_args,omitempty"` // npm-specific configuration // Package is the npm package name (e.g., "@host-uk/core"). - Package string `yaml:"package,omitempty"` + Package string `yaml:"package,omitempty" mapstructure:"package,omitempty"` // Access is the npm access level: "public" or "restricted". - Access string `yaml:"access,omitempty"` + Access string `yaml:"access,omitempty" mapstructure:"access,omitempty"` // Homebrew-specific configuration // Tap is the Homebrew tap repository (e.g., "host-uk/homebrew-tap"). - Tap string `yaml:"tap,omitempty"` + Tap string `yaml:"tap,omitempty" mapstructure:"tap,omitempty"` // Formula is the formula name (defaults to project name). - Formula string `yaml:"formula,omitempty"` + Formula string `yaml:"formula,omitempty" mapstructure:"formula,omitempty"` // Scoop-specific configuration // Bucket is the Scoop bucket repository (e.g., "host-uk/scoop-bucket"). - Bucket string `yaml:"bucket,omitempty"` + Bucket string `yaml:"bucket,omitempty" mapstructure:"bucket,omitempty"` // AUR-specific configuration // Maintainer is the AUR package maintainer (e.g., "Name "). - Maintainer string `yaml:"maintainer,omitempty"` + Maintainer string `yaml:"maintainer,omitempty" mapstructure:"maintainer,omitempty"` // Chocolatey-specific configuration // Push determines whether to push to Chocolatey (false = generate only). - Push bool `yaml:"push,omitempty"` + Push bool `yaml:"push,omitempty" mapstructure:"push,omitempty"` // Official repo configuration (for Homebrew, Scoop) // When enabled, generates files for PR to official repos. - Official *OfficialConfig `yaml:"official,omitempty"` + Official *OfficialConfig `yaml:"official,omitempty" mapstructure:"official,omitempty"` } // OfficialConfig holds configuration for generating files for official repo PRs. type OfficialConfig struct { // Enabled determines whether to generate files for official repos. - Enabled bool `yaml:"enabled"` + Enabled bool `yaml:"enabled" mapstructure:"enabled"` // Output is the directory to write generated files. - Output string `yaml:"output,omitempty"` + Output string `yaml:"output,omitempty" mapstructure:"output,omitempty"` } // SDKConfig holds SDK generation configuration. type SDKConfig struct { // Spec is the path to the OpenAPI spec file. - Spec string `yaml:"spec,omitempty"` + Spec string `yaml:"spec,omitempty" mapstructure:"spec,omitempty"` // Languages to generate. - Languages []string `yaml:"languages,omitempty"` + Languages []string `yaml:"languages,omitempty" mapstructure:"languages,omitempty"` // Output directory (default: sdk/). - Output string `yaml:"output,omitempty"` + Output string `yaml:"output,omitempty" mapstructure:"output,omitempty"` // Package naming. - Package SDKPackageConfig `yaml:"package,omitempty"` + Package SDKPackageConfig `yaml:"package,omitempty" mapstructure:"package,omitempty"` // Diff configuration. - Diff SDKDiffConfig `yaml:"diff,omitempty"` + Diff SDKDiffConfig `yaml:"diff,omitempty" mapstructure:"diff,omitempty"` // Publish configuration. - Publish SDKPublishConfig `yaml:"publish,omitempty"` + Publish SDKPublishConfig `yaml:"publish,omitempty" mapstructure:"publish,omitempty"` } // SDKPackageConfig holds package naming configuration. type SDKPackageConfig struct { - Name string `yaml:"name,omitempty"` - Version string `yaml:"version,omitempty"` + Name string `yaml:"name,omitempty" mapstructure:"name,omitempty"` + Version string `yaml:"version,omitempty" mapstructure:"version,omitempty"` } // SDKDiffConfig holds diff configuration. type SDKDiffConfig struct { - Enabled bool `yaml:"enabled,omitempty"` - FailOnBreaking bool `yaml:"fail_on_breaking,omitempty"` + Enabled bool `yaml:"enabled,omitempty" mapstructure:"enabled,omitempty"` + FailOnBreaking bool `yaml:"fail_on_breaking,omitempty" mapstructure:"fail_on_breaking,omitempty"` } // SDKPublishConfig holds monorepo publish configuration. type SDKPublishConfig struct { - Repo string `yaml:"repo,omitempty"` - Path string `yaml:"path,omitempty"` + Repo string `yaml:"repo,omitempty" mapstructure:"repo,omitempty"` + Path string `yaml:"path,omitempty" mapstructure:"path,omitempty"` } // ChangelogConfig holds changelog generation settings. type ChangelogConfig struct { // Include specifies commit types to include in the changelog. - Include []string `yaml:"include"` + Include []string `yaml:"include" mapstructure:"include"` // Exclude specifies commit types to exclude from the changelog. - Exclude []string `yaml:"exclude"` + Exclude []string `yaml:"exclude" mapstructure:"exclude"` } // LoadConfig loads release configuration from the .core/release.yaml file in the given directory. @@ -176,26 +176,28 @@ func LoadConfig(m io.Medium, dir string) (*Config, error) { return nil, fmt.Errorf("release.LoadConfig: failed to resolve path: %w", err) } - content, err := m.Read(absPath) - if err != nil { - if os.IsNotExist(err) { - cfg := DefaultConfig() - cfg.projectDir = dir - return cfg, nil - } - return nil, fmt.Errorf("release.LoadConfig: failed to read config file: %w", err) + if !m.Exists(absPath) { + cfg := DefaultConfig() + cfg.projectDir = dir + return cfg, nil } - var cfg Config - if err := yaml.Unmarshal([]byte(content), &cfg); err != nil { - return nil, fmt.Errorf("release.LoadConfig: failed to parse config file: %w", err) + // Use centralized config service + c, err := config.New(config.WithMedium(m), config.WithPath(absPath)) + if err != nil { + return nil, fmt.Errorf("release.LoadConfig: %w", err) + } + + cfg := DefaultConfig() + if err := c.Get("", cfg); err != nil { + return nil, fmt.Errorf("release.LoadConfig: %w", err) } // Apply defaults for any missing fields - applyDefaults(&cfg) + applyDefaults(cfg) cfg.projectDir = dir - return &cfg, nil + return cfg, nil } // DefaultConfig returns sensible defaults for release configuration. @@ -272,7 +274,7 @@ func ConfigExists(m io.Medium, dir string) bool { if err != nil { return false } - return m.IsFile(absPath) + return m.Exists(absPath) } // GetRepository returns the repository from the config.