From 27c25a1c56b598553664b8cc22dff709febdbc0d Mon Sep 17 00:00:00 2001 From: kangeunchan Date: Fri, 27 Feb 2026 09:36:27 +0900 Subject: [PATCH] refactor(config): centralize command config resolution Signed-off-by: kangeunchan --- cmd/devnet-builder/commands/core/init.go | 47 +--- cmd/devnet-builder/commands/manage/deploy.go | 58 ++--- cmd/devnet-builder/commands/root.go | 45 +--- internal/config/resolver.go | 252 +++++++++++++++++++ internal/config/resolver_test.go | 207 +++++++++++++++ 5 files changed, 500 insertions(+), 109 deletions(-) create mode 100644 internal/config/resolver.go create mode 100644 internal/config/resolver_test.go diff --git a/cmd/devnet-builder/commands/core/init.go b/cmd/devnet-builder/commands/core/init.go index 31f8ad14..ad5e3c26 100644 --- a/cmd/devnet-builder/commands/core/init.go +++ b/cmd/devnet-builder/commands/core/init.go @@ -3,7 +3,6 @@ package core import ( "encoding/json" "fmt" - "os" "path/filepath" "github.com/altuslabsxyz/devnet-builder/internal/application" @@ -98,41 +97,17 @@ func runInit(cmd *cobra.Command, args []string) error { jsonMode := cfg.JSONMode() // Build effective config from: default < config.toml < env < flag - // Start with loaded config.toml values - fileCfg := cfg.FileConfig() - if fileCfg == nil { - fileCfg = &config.FileConfig{} - } - - // Apply flag values (flags override config.toml) - if cmd.Flags().Changed("network") { - fileCfg.Network = &initNetwork - } - if cmd.Flags().Changed("blockchain") { - fileCfg.BlockchainNetwork = &initBlockchainNetwork - } - if cmd.Flags().Changed("validators") { - fileCfg.Validators = &initValidators - } - if cmd.Flags().Changed("mode") { - em := types.ExecutionMode(initMode) - fileCfg.ExecutionMode = &em - } - if cmd.Flags().Changed("no-cache") { - fileCfg.NoCache = &initNoCache - } - if cmd.Flags().Changed("accounts") { - fileCfg.Accounts = &initAccounts - } - - // Apply environment variables (env overrides config.toml but not flags) - if networkEnv := os.Getenv("DEVNET_NETWORK"); networkEnv != "" && !cmd.Flags().Changed("network") { - fileCfg.Network = &networkEnv - } - if modeEnv := os.Getenv("DEVNET_MODE"); modeEnv != "" && !cmd.Flags().Changed("mode") { - mode := types.ExecutionMode(modeEnv) - fileCfg.ExecutionMode = &mode - } + resolver := config.NewResolver() + resolved := resolver.ResolveRuntimeFileConfig(cmd, cfg.FileConfig(), config.RuntimeResolveInput{ + Network: initNetwork, + BlockchainNetwork: initBlockchainNetwork, + Validators: initValidators, + Mode: types.ExecutionMode(initMode), + NetworkVersion: initVersion, + NoCache: initNoCache, + Accounts: initAccounts, + }) + fileCfg := resolved.FileConfig // Run partial interactive setup for missing values setup := config.NewInteractiveSetup(homeDir) diff --git a/cmd/devnet-builder/commands/manage/deploy.go b/cmd/devnet-builder/commands/manage/deploy.go index 84dfd747..3d6f668d 100644 --- a/cmd/devnet-builder/commands/manage/deploy.go +++ b/cmd/devnet-builder/commands/manage/deploy.go @@ -155,46 +155,24 @@ func runDeploy(cmd *cobra.Command, args []string) error { logger := output.DefaultLogger // Build effective config from: default < config.toml < env < flag - // Start with loaded config.toml values - fileCfg := cfg.FileConfig() - if fileCfg == nil { - fileCfg = &config.FileConfig{} - } - - // Apply flag values (flags override config.toml) - if cmd.Flags().Changed("network") { - fileCfg.Network = &deployNetwork - } - if cmd.Flags().Changed("blockchain") { - fileCfg.BlockchainNetwork = &deployBlockchainNetwork - } - if cmd.Flags().Changed("validators") { - fileCfg.Validators = &deployValidators - } - if cmd.Flags().Changed("mode") { - mode := types.ExecutionMode(deployMode) - fileCfg.ExecutionMode = &mode - } - if cmd.Flags().Changed("network-version") { - fileCfg.NetworkVersion = &deployStableVersion - } - if cmd.Flags().Changed("no-cache") { - fileCfg.NoCache = &deployNoCache - } - if cmd.Flags().Changed("accounts") { - fileCfg.Accounts = &deployAccounts - } - - // Apply environment variables (env overrides config.toml but not flags) - if networkEnv := os.Getenv("DEVNET_NETWORK"); networkEnv != "" && !cmd.Flags().Changed("network") { - fileCfg.Network = &networkEnv - } - if modeEnv := os.Getenv("DEVNET_MODE"); modeEnv != "" && !cmd.Flags().Changed("mode") { - mode := types.ExecutionMode(modeEnv) - fileCfg.ExecutionMode = &mode - } - if versionEnv := os.Getenv("DEVNET_NETWORK_VERSION"); versionEnv != "" && !cmd.Flags().Changed("network-version") { - fileCfg.NetworkVersion = &versionEnv + resolver := config.NewResolver() + resolved := resolver.ResolveRuntimeFileConfig(cmd, cfg.FileConfig(), config.RuntimeResolveInput{ + Network: deployNetwork, + BlockchainNetwork: deployBlockchainNetwork, + Validators: deployValidators, + Mode: types.ExecutionMode(deployMode), + NetworkVersion: deployStableVersion, + NoCache: deployNoCache, + Accounts: deployAccounts, + }) + fileCfg := resolved.FileConfig + if cfg.Verbose() { + logger.Debug("Resolved config sources: network=%s mode=%s network_version=%s validators=%s", + resolved.Sources["network"], + resolved.Sources["mode"], + resolved.Sources["network_version"], + resolved.Sources["validators"], + ) } // Run partial interactive setup for missing base config values diff --git a/cmd/devnet-builder/commands/root.go b/cmd/devnet-builder/commands/root.go index 8aad98cd..0eae53c0 100644 --- a/cmd/devnet-builder/commands/root.go +++ b/cmd/devnet-builder/commands/root.go @@ -4,7 +4,6 @@ package commands import ( "context" - "os" "github.com/altuslabsxyz/devnet-builder/cmd/devnet-builder/commands/cache" configcmd "github.com/altuslabsxyz/devnet-builder/cmd/devnet-builder/commands/config" @@ -109,9 +108,6 @@ func persistentPreRunE(cmd *cobra.Command, args []string) error { // Priority: default < config.toml < env < flag applyConfigDefaults(cmd, fileCfg) - // Environment variables override config.toml (but not explicit flags) - applyEnvironmentOverrides(cmd) - // Build context-based config from FileConfig and CLI overrides cfg := ctxconfig.New( ctxconfig.FromFileConfig(fileCfg), @@ -150,35 +146,18 @@ func persistentPreRunE(cmd *cobra.Command, args []string) error { // applyConfigDefaults applies config file values to global flags if not explicitly set. func applyConfigDefaults(cmd *cobra.Command, fileCfg *config.FileConfig) { - // Apply home from config.toml - if !cmd.Flags().Changed("home") && fileCfg.Home != nil { - homeDir = *fileCfg.Home - } - - // Apply verbose from config.toml - if !cmd.Flags().Changed("verbose") && fileCfg.Verbose != nil { - verbose = *fileCfg.Verbose - } - - // Apply json from config.toml - if !cmd.Flags().Changed("json") && fileCfg.JSON != nil { - jsonMode = *fileCfg.JSON - } - - // Apply no_color from config.toml - if !cmd.Flags().Changed("no-color") && fileCfg.NoColor != nil { - noColor = *fileCfg.NoColor - } -} - -// applyEnvironmentOverrides applies environment variable overrides. -func applyEnvironmentOverrides(cmd *cobra.Command) { - if envHome := os.Getenv("DEVNET_HOME"); envHome != "" && !cmd.Flags().Changed("home") { - homeDir = envHome - } - if os.Getenv("NO_COLOR") != "" && !cmd.Flags().Changed("no-color") { - noColor = true - } + resolver := config.NewResolver() + resolved := resolver.ResolveGlobal(cmd, fileCfg, config.GlobalResolveInput{ + Home: homeDir, + Verbose: verbose, + JSON: jsonMode, + NoColor: noColor, + }) + + homeDir = resolved.Home.Value + verbose = resolved.Verbose.Value + jsonMode = resolved.JSON.Value + noColor = resolved.NoColor.Value } // registerCommands registers all subcommands with appropriate group assignments. diff --git a/internal/config/resolver.go b/internal/config/resolver.go new file mode 100644 index 00000000..73a196c1 --- /dev/null +++ b/internal/config/resolver.go @@ -0,0 +1,252 @@ +package config + +import ( + "os" + + "github.com/altuslabsxyz/devnet-builder/types" + "github.com/spf13/cobra" +) + +// Resolver centralizes configuration precedence handling. +// Priority: default < config.toml < environment < CLI flag. +type Resolver struct{} + +// NewResolver creates a new Resolver. +func NewResolver() *Resolver { + return &Resolver{} +} + +// GlobalResolveInput contains current global flag values. +type GlobalResolveInput struct { + Home string + Verbose bool + JSON bool + NoColor bool +} + +// GlobalResolveOutput contains resolved global values and sources. +type GlobalResolveOutput struct { + Home StringValue + Verbose BoolValue + JSON BoolValue + NoColor BoolValue +} + +// RuntimeResolveInput contains runtime config values from command flags. +type RuntimeResolveInput struct { + Network string + BlockchainNetwork string + Validators int + Mode types.ExecutionMode + NetworkVersion string + NoCache bool + Accounts int +} + +// RuntimeResolveOutput contains resolved runtime file config and field sources. +type RuntimeResolveOutput struct { + FileConfig *FileConfig + Sources map[string]ConfigSource +} + +// ResolveGlobal resolves global values using unified precedence. +func (r *Resolver) ResolveGlobal(cmd *cobra.Command, fileCfg *FileConfig, in GlobalResolveInput) GlobalResolveOutput { + out := GlobalResolveOutput{ + Home: StringValue{Value: in.Home, Source: SourceDefault}, + Verbose: BoolValue{Value: in.Verbose, Source: SourceDefault}, + JSON: BoolValue{Value: in.JSON, Source: SourceDefault}, + NoColor: BoolValue{Value: in.NoColor, Source: SourceDefault}, + } + + if fileCfg != nil { + if fileCfg.Home != nil { + out.Home = StringValue{Value: *fileCfg.Home, Source: SourceConfigFile} + } + if fileCfg.Verbose != nil { + out.Verbose = BoolValue{Value: *fileCfg.Verbose, Source: SourceConfigFile} + } + if fileCfg.JSON != nil { + out.JSON = BoolValue{Value: *fileCfg.JSON, Source: SourceConfigFile} + } + if fileCfg.NoColor != nil { + out.NoColor = BoolValue{Value: *fileCfg.NoColor, Source: SourceConfigFile} + } + } + + if envHome := os.Getenv("DEVNET_HOME"); envHome != "" && !flagChanged(cmd, "home") { + out.Home = StringValue{Value: envHome, Source: SourceEnvironment} + } + if os.Getenv("NO_COLOR") != "" && !flagChanged(cmd, "no-color") { + out.NoColor = BoolValue{Value: true, Source: SourceEnvironment} + } + + if flagChanged(cmd, "home") { + out.Home = StringValue{Value: in.Home, Source: SourceFlag} + } + if flagChanged(cmd, "verbose") { + out.Verbose = BoolValue{Value: in.Verbose, Source: SourceFlag} + } + if flagChanged(cmd, "json") { + out.JSON = BoolValue{Value: in.JSON, Source: SourceFlag} + } + if flagChanged(cmd, "no-color") { + out.NoColor = BoolValue{Value: in.NoColor, Source: SourceFlag} + } + + return out +} + +// ResolveRuntimeFileConfig resolves runtime settings into a FileConfig while +// preserving RunPartial behavior (unset/default fields remain nil). +func (r *Resolver) ResolveRuntimeFileConfig(cmd *cobra.Command, base *FileConfig, in RuntimeResolveInput) RuntimeResolveOutput { + cfg := cloneFileConfig(base) + sources := map[string]ConfigSource{ + "network": sourceFromStringPtr(cfg.Network), + "blockchain_network": sourceFromStringPtr(cfg.BlockchainNetwork), + "validators": sourceFromIntPtr(cfg.Validators), + "mode": sourceFromModePtr(cfg.ExecutionMode), + "network_version": sourceFromStringPtr(cfg.NetworkVersion), + "no_cache": sourceFromBoolPtr(cfg.NoCache), + "accounts": sourceFromIntPtr(cfg.Accounts), + } + + if flagChanged(cmd, "network") { + cfg.Network = stringPtr(in.Network) + sources["network"] = SourceFlag + } else if env := os.Getenv("DEVNET_NETWORK"); env != "" { + cfg.Network = stringPtr(env) + sources["network"] = SourceEnvironment + } + + if flagChanged(cmd, "blockchain") { + cfg.BlockchainNetwork = stringPtr(in.BlockchainNetwork) + sources["blockchain_network"] = SourceFlag + } + + if flagChanged(cmd, "validators") { + cfg.Validators = intPtr(in.Validators) + sources["validators"] = SourceFlag + } + + if flagChanged(cmd, "mode") { + cfg.ExecutionMode = modePtr(in.Mode) + sources["mode"] = SourceFlag + } else if env := os.Getenv("DEVNET_MODE"); env != "" { + cfg.ExecutionMode = modePtr(types.ExecutionMode(env)) + sources["mode"] = SourceEnvironment + } + + if flagChanged(cmd, "network-version") { + cfg.NetworkVersion = stringPtr(in.NetworkVersion) + sources["network_version"] = SourceFlag + } else if env := os.Getenv("DEVNET_NETWORK_VERSION"); env != "" { + cfg.NetworkVersion = stringPtr(env) + sources["network_version"] = SourceEnvironment + } + + if flagChanged(cmd, "no-cache") { + cfg.NoCache = boolPtr(in.NoCache) + sources["no_cache"] = SourceFlag + } + + if flagChanged(cmd, "accounts") { + cfg.Accounts = intPtr(in.Accounts) + sources["accounts"] = SourceFlag + } + + return RuntimeResolveOutput{ + FileConfig: cfg, + Sources: sources, + } +} + +func cloneFileConfig(base *FileConfig) *FileConfig { + if base == nil { + return &FileConfig{} + } + + out := &FileConfig{} + if base.Home != nil { + out.Home = stringPtr(*base.Home) + } + if base.NoColor != nil { + out.NoColor = boolPtr(*base.NoColor) + } + if base.Verbose != nil { + out.Verbose = boolPtr(*base.Verbose) + } + if base.JSON != nil { + out.JSON = boolPtr(*base.JSON) + } + if base.Network != nil { + out.Network = stringPtr(*base.Network) + } + if base.BlockchainNetwork != nil { + out.BlockchainNetwork = stringPtr(*base.BlockchainNetwork) + } + if base.Validators != nil { + out.Validators = intPtr(*base.Validators) + } + if base.ExecutionMode != nil { + out.ExecutionMode = modePtr(*base.ExecutionMode) + } + if base.NetworkVersion != nil { + out.NetworkVersion = stringPtr(*base.NetworkVersion) + } + if base.NoCache != nil { + out.NoCache = boolPtr(*base.NoCache) + } + if base.Accounts != nil { + out.Accounts = intPtr(*base.Accounts) + } + if base.GitHubToken != nil { + out.GitHubToken = stringPtr(*base.GitHubToken) + } + if base.CacheTTL != nil { + out.CacheTTL = stringPtr(*base.CacheTTL) + } + return out +} + +func flagChanged(cmd *cobra.Command, flagName string) bool { + if cmd == nil || cmd.Flags() == nil || cmd.Flags().Lookup(flagName) == nil { + return false + } + return cmd.Flags().Changed(flagName) +} + +func sourceFromStringPtr(v *string) ConfigSource { + if v == nil { + return SourceDefault + } + return SourceConfigFile +} + +func sourceFromIntPtr(v *int) ConfigSource { + if v == nil { + return SourceDefault + } + return SourceConfigFile +} + +func sourceFromBoolPtr(v *bool) ConfigSource { + if v == nil { + return SourceDefault + } + return SourceConfigFile +} + +func sourceFromModePtr(v *types.ExecutionMode) ConfigSource { + if v == nil { + return SourceDefault + } + return SourceConfigFile +} + +func stringPtr(v string) *string { return &v } + +func intPtr(v int) *int { return &v } + +func boolPtr(v bool) *bool { return &v } + +func modePtr(v types.ExecutionMode) *types.ExecutionMode { return &v } diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go new file mode 100644 index 00000000..2190487e --- /dev/null +++ b/internal/config/resolver_test.go @@ -0,0 +1,207 @@ +package config + +import ( + "testing" + + "github.com/altuslabsxyz/devnet-builder/types" + "github.com/spf13/cobra" +) + +func TestResolveRuntimeFileConfigPrecedence(t *testing.T) { + t.Setenv("DEVNET_NETWORK", "env-network") + t.Setenv("DEVNET_MODE", "local") + t.Setenv("DEVNET_NETWORK_VERSION", "v9.9.9") + + cmd := newRuntimeResolverTestCmd() + if err := cmd.Flags().Set("network", "flag-network"); err != nil { + t.Fatalf("set network flag: %v", err) + } + if err := cmd.Flags().Set("validators", "8"); err != nil { + t.Fatalf("set validators flag: %v", err) + } + + mode := types.ExecutionModeDocker + base := &FileConfig{ + Network: strPtr("file-network"), + BlockchainNetwork: strPtr("stable"), + Validators: intPtrTest(4), + ExecutionMode: &mode, + NetworkVersion: strPtr("v1.0.0"), + } + + resolver := NewResolver() + resolved := resolver.ResolveRuntimeFileConfig(cmd, base, RuntimeResolveInput{ + Network: "flag-network", + BlockchainNetwork: "stable", + Validators: 8, + Mode: types.ExecutionModeDocker, + NetworkVersion: "latest", + }) + + if got := derefString(resolved.FileConfig.Network); got != "flag-network" { + t.Fatalf("network mismatch: got %q", got) + } + if got := derefInt(resolved.FileConfig.Validators); got != 8 { + t.Fatalf("validators mismatch: got %d", got) + } + if got := string(derefMode(resolved.FileConfig.ExecutionMode)); got != "local" { + t.Fatalf("mode mismatch: got %q", got) + } + if got := derefString(resolved.FileConfig.NetworkVersion); got != "v9.9.9" { + t.Fatalf("network version mismatch: got %q", got) + } + + if resolved.Sources["network"] != SourceFlag { + t.Fatalf("network source mismatch: got %s", resolved.Sources["network"]) + } + if resolved.Sources["validators"] != SourceFlag { + t.Fatalf("validators source mismatch: got %s", resolved.Sources["validators"]) + } + if resolved.Sources["mode"] != SourceEnvironment { + t.Fatalf("mode source mismatch: got %s", resolved.Sources["mode"]) + } + if resolved.Sources["network_version"] != SourceEnvironment { + t.Fatalf("network version source mismatch: got %s", resolved.Sources["network_version"]) + } +} + +func TestResolveRuntimeFileConfigPreservesNilDefaults(t *testing.T) { + cmd := newRuntimeResolverTestCmd() + + resolver := NewResolver() + resolved := resolver.ResolveRuntimeFileConfig(cmd, nil, RuntimeResolveInput{ + Network: "mainnet", + BlockchainNetwork: "stable", + Validators: 4, + Mode: types.ExecutionModeDocker, + NetworkVersion: "latest", + NoCache: false, + Accounts: 0, + }) + + if resolved.FileConfig.Network != nil { + t.Fatalf("expected network to remain nil when only defaults are present") + } + if resolved.FileConfig.ExecutionMode != nil { + t.Fatalf("expected mode to remain nil when only defaults are present") + } + if resolved.FileConfig.NetworkVersion != nil { + t.Fatalf("expected network version to remain nil when only defaults are present") + } + if resolved.Sources["network"] != SourceDefault { + t.Fatalf("expected default source for network, got %s", resolved.Sources["network"]) + } +} + +func TestResolveGlobalPrecedence(t *testing.T) { + t.Setenv("DEVNET_HOME", "/env-home") + t.Setenv("NO_COLOR", "1") + + cmd := newGlobalResolverTestCmd() + if err := cmd.Flags().Set("home", "/flag-home"); err != nil { + t.Fatalf("set home flag: %v", err) + } + if err := cmd.Flags().Set("verbose", "true"); err != nil { + t.Fatalf("set verbose flag: %v", err) + } + + base := &FileConfig{ + Home: strPtr("/file-home"), + Verbose: boolPtrTest(false), + JSON: boolPtrTest(true), + NoColor: boolPtrTest(false), + } + + resolver := NewResolver() + out := resolver.ResolveGlobal(cmd, base, GlobalResolveInput{ + Home: "/flag-home", + Verbose: true, + JSON: false, + NoColor: false, + }) + + if out.Home.Value != "/flag-home" || out.Home.Source != SourceFlag { + t.Fatalf("home resolution mismatch: value=%q source=%s", out.Home.Value, out.Home.Source) + } + if !out.Verbose.Value || out.Verbose.Source != SourceFlag { + t.Fatalf("verbose resolution mismatch: value=%t source=%s", out.Verbose.Value, out.Verbose.Source) + } + if !out.JSON.Value || out.JSON.Source != SourceConfigFile { + t.Fatalf("json resolution mismatch: value=%t source=%s", out.JSON.Value, out.JSON.Source) + } + if !out.NoColor.Value || out.NoColor.Source != SourceEnvironment { + t.Fatalf("no-color resolution mismatch: value=%t source=%s", out.NoColor.Value, out.NoColor.Source) + } +} + +func TestResolveGlobalFlagBeatsEnvironment(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + cmd := newGlobalResolverTestCmd() + if err := cmd.Flags().Set("no-color", "false"); err != nil { + t.Fatalf("set no-color flag: %v", err) + } + + resolver := NewResolver() + out := resolver.ResolveGlobal(cmd, &FileConfig{}, GlobalResolveInput{ + Home: "/tmp/devnet", + Verbose: false, + JSON: false, + NoColor: false, + }) + + if out.NoColor.Source != SourceFlag { + t.Fatalf("expected flag source, got %s", out.NoColor.Source) + } + if out.NoColor.Value { + t.Fatalf("expected no-color=false from explicit flag") + } +} + +func newRuntimeResolverTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("network", "mainnet", "") + cmd.Flags().String("blockchain", "stable", "") + cmd.Flags().Int("validators", 4, "") + cmd.Flags().String("mode", "docker", "") + cmd.Flags().String("network-version", "latest", "") + cmd.Flags().Bool("no-cache", false, "") + cmd.Flags().Int("accounts", 0, "") + return cmd +} + +func newGlobalResolverTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("home", "/tmp/default-home", "") + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().Bool("json", false, "") + cmd.Flags().Bool("no-color", false, "") + return cmd +} + +func strPtr(v string) *string { return &v } + +func intPtrTest(v int) *int { return &v } + +func boolPtrTest(v bool) *bool { return &v } + +func derefString(v *string) string { + if v == nil { + return "" + } + return *v +} + +func derefInt(v *int) int { + if v == nil { + return 0 + } + return *v +} + +func derefMode(v *types.ExecutionMode) types.ExecutionMode { + if v == nil { + return "" + } + return *v +}