Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions internal/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
48 changes: 44 additions & 4 deletions internal/settings/settings_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 4 additions & 3 deletions internal/settings/settings_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
14 changes: 11 additions & 3 deletions internal/settings/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 0 additions & 2 deletions internal/settings/template/.env.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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