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.
This commit is contained in:
Snider 2026-02-05 10:26:44 +00:00 committed by GitHub
parent 38db43bbfb
commit a169558102
10 changed files with 323 additions and 335 deletions

12
go.mod
View file

@ -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

18
go.sum
View file

@ -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=

View file

@ -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,7 +58,6 @@ 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)
@ -69,7 +68,6 @@ func LoadConfig(dir string) (*Config, error) {
}
}
}
}
// Try loading from ~/.core/agentic.yaml
homeDir, err := os.UserHomeDir()
@ -78,9 +76,16 @@ func LoadConfig(dir string) (*Config, error) {
}
configPath := filepath.Join(homeDir, ".core", configFileName)
if err := loadYAMLConfig(configPath, cfg); err != nil && !os.IsNotExist(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
applyEnvOverrides(cfg)
@ -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.

View file

@ -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 err != nil {
if os.IsNotExist(err) {
if !fs.Exists(configPath) {
return DefaultConfig(), nil
}
return nil, fmt.Errorf("build.LoadConfig: failed to read config file: %w", err)
// Use centralized config service
c, err := config.New(config.WithMedium(fs), config.WithPath(configPath))
if err != nil {
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.

View file

@ -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
// 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)
}
for i, part := range parts {
if i == len(parts)-1 {
current[part] = value
return
dir := filepath.Dir(path)
if err := m.EnsureDir(dir); err != nil {
return core.E("config.Save", "failed to create config directory: "+dir, err)
}
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
}
}
// 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)
if err := m.Write(path, string(out)); err != nil {
return core.E("config.Save", "failed to write config file: "+path, err)
}
return nil
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)

View file

@ -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)
if err != nil {
if os.IsNotExist(err) {
return DefaultConfig(), nil
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 {
return nil, err
}
cfg := DefaultConfig()
if err := yaml.Unmarshal([]byte(content), cfg); err != nil {
if err := c.Get("", cfg); err != nil {
return nil, err
}

View file

@ -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 <email>").
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) {
if !m.Exists(absPath) {
cfg := DefaultConfig()
cfg.projectDir = dir
return cfg, nil
}
return nil, fmt.Errorf("release.LoadConfig: failed to read 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)
}
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)
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.