diff --git a/cmd/root.go b/cmd/root.go index 6cd5636c..3b800984 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -219,13 +219,21 @@ func newRootCommand() *cobra.Command { return err } + // Stop spinner before AttachSettings — it may prompt for target selection + if showSpinner { + spinner.Stop() + } + err := runtimeContext.AttachSettings(cmd, isLoadDeploymentRPC(cmd)) if err != nil { - if showSpinner { - spinner.Stop() - } return fmt.Errorf("%w", err) } + + // Restart spinner for remaining initialization + if showSpinner { + spinner = ui.NewSpinner() + spinner.Start("Loading settings...") + } } // Stop the initialization spinner - commands can start their own if needed diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 5d2cf194..0d13e845 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -6,12 +6,14 @@ import ( "path/filepath" "strings" + "github.com/charmbracelet/huh" "github.com/joho/godotenv" "github.com/rs/zerolog" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/smartcontractkit/cre-cli/internal/constants" + "github.com/smartcontractkit/cre-cli/internal/ui" ) // sensitive information (not in configuration file) @@ -69,6 +71,15 @@ func New(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Command, registryCha return nil, err } + if target == "" { + target, err = promptForTarget(logger) + if err != nil { + return nil, err + } + // Store the selected target so subsequent GetTarget() calls find it + v.Set(Flags.Target.Name, target) + } + logger.Debug().Msgf("Target: %s", target) err = LoadSettingsIntoViper(v, cmd) @@ -169,3 +180,44 @@ func NormalizeHexKey(k string) string { } return k } + +// promptForTarget discovers available targets from project.yaml and prompts the user to select one. +func promptForTarget(logger *zerolog.Logger) (string, error) { + targets, err := GetAvailableTargets() + if err != nil { + return "", fmt.Errorf("target not set and unable to discover targets: %w\nSpecify --%s or set %s env var", + err, Flags.Target.Name, CreTargetEnvVar) + } + + if len(targets) == 0 { + return "", fmt.Errorf("no targets found in project.yaml; specify --%s or set %s env var", + Flags.Target.Name, CreTargetEnvVar) + } + + if len(targets) == 1 { + logger.Debug().Msgf("Auto-selecting target: %s", targets[0]) + return targets[0], nil + } + + var selected string + options := make([]huh.Option[string], len(targets)) + for i, t := range targets { + options[i] = huh.NewOption(t, t) + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Select a target"). + Description("No --target flag or CRE_TARGET env var set."). + Options(options...). + Value(&selected), + ), + ).WithTheme(ui.ChainlinkTheme()) + + if err := form.Run(); err != nil { + return "", fmt.Errorf("target selection cancelled: %w", err) + } + + return selected, nil +} diff --git a/internal/settings/settings_get.go b/internal/settings/settings_get.go index bffaabab..c5e5dc41 100644 --- a/internal/settings/settings_get.go +++ b/internal/settings/settings_get.go @@ -8,6 +8,7 @@ import ( ethcommon "github.com/ethereum/go-ethereum/common" "github.com/spf13/viper" + "gopkg.in/yaml.v3" chainSelectors "github.com/smartcontractkit/chain-selectors" @@ -172,10 +173,49 @@ func GetTarget(v *viper.Viper) (string, error) { return target, nil } - return "", fmt.Errorf( - "target not set: specify --%s or set %s env var", - Flags.Target.Name, CreTargetEnvVar, - ) + return "", nil +} + +// GetAvailableTargets reads project.yaml and returns the top-level keys +// that represent target configurations, preserving the order from the file. +func GetAvailableTargets() ([]string, error) { + projectPath, err := getProjectSettingsPath() + if err != nil { + return nil, fmt.Errorf("failed to find project settings: %w", err) + } + + data, err := os.ReadFile(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to read project settings: %w", err) + } + + // Parse with yaml.v3 Node to preserve key order + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("failed to parse project settings: %w", err) + } + + if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { + return nil, nil + } + + root := doc.Content[0] + if root.Kind != yaml.MappingNode { + return nil, nil + } + + // Mapping nodes alternate key, value, key, value... + // Only include keys whose values are mappings (actual target configs). + var targets []string + for i := 0; i+1 < len(root.Content); i += 2 { + key := root.Content[i] + val := root.Content[i+1] + if key.Kind == yaml.ScalarNode && val.Kind == yaml.MappingNode { + targets = append(targets, key.Value) + } + } + + return targets, nil } func GetChainNameByChainSelector(chainSelector uint64) (string, error) { diff --git a/internal/settings/settings_get_test.go b/internal/settings/settings_get_test.go index 9d421ba6..246428fd 100644 --- a/internal/settings/settings_get_test.go +++ b/internal/settings/settings_get_test.go @@ -56,9 +56,10 @@ func TestGetTarget_EnvWhenNoFlag(t *testing.T) { assert.Equal(t, "envOnly", got) } -func TestGetTarget_ErrorWhenNeither(t *testing.T) { +func TestGetTarget_EmptyWhenNeither(t *testing.T) { v := viper.New() - _, err := settings.GetTarget(v) - assert.Error(t, err) + got, err := settings.GetTarget(v) + assert.NoError(t, err) + assert.Equal(t, "", got) } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index d6034c6c..a42df983 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -84,9 +84,17 @@ func TestLoadEnvAndSettingsEmptyTarget(t *testing.T) { cmd := &cobra.Command{Use: "login"} s, err := settings.New(logger, v, cmd, "") - assert.Error(t, err, "Expected error due to empty target") - assert.Contains(t, err.Error(), "target not set", "Expected missing target error") - assert.Nil(t, s, "Settings object should be nil on error") + // With no target set, settings.New() tries to prompt for a target. + // In a non-TTY test environment, this will either auto-select (single target) + // or fail with a prompt error (multiple targets). + if err != nil { + // Expected in non-TTY when multiple targets exist + assert.Nil(t, s, "Settings object should be nil on error") + } else { + // Auto-selected the only available target + assert.NotNil(t, s) + assert.NotEmpty(t, s.User.TargetName) + } } func TestLoadEnvAndSettings(t *testing.T) { diff --git a/internal/settings/template/.env.tpl b/internal/settings/template/.env.tpl index a5664582..dbed2610 100644 --- a/internal/settings/template/.env.tpl +++ b/internal/settings/template/.env.tpl @@ -5,5 +5,3 @@ ############################################################################### # Ethereum private key or 1Password reference (e.g. op://vault/item/field) CRE_ETH_PRIVATE_KEY={{EthPrivateKey}} -# Default target used when --target flag is not specified (e.g. staging-settings, production-settings, my-target) -CRE_TARGET=staging-settings