diff --git a/cmd/creinit/creinit_test.go b/cmd/creinit/creinit_test.go index d7c83bca..b0c4eef0 100644 --- a/cmd/creinit/creinit_test.go +++ b/cmd/creinit/creinit_test.go @@ -358,6 +358,15 @@ func TestInitExecuteFlows(t *testing.T) { expectWorkflowName: "starter-wf", expectTemplateFiles: GetTemplateFileListGo(), }, + { + name: "Starter template with all flags", + projectNameFlag: "starterProj", + templateNameFlag: "starter-go", + workflowNameFlag: "starter-wf", + expectProjectDirRel: "starterProj", + expectWorkflowName: "starter-wf", + expectTemplateFiles: GetTemplateFileListGo(), + }, } for _, tc := range cases { 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..f6a6fcef 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,19 @@ func New(logger *zerolog.Logger, v *viper.Viper, cmd *cobra.Command, registryCha return nil, err } + if target == "" { + if v.GetBool(Flags.NonInteractive.Name) { + target, err = autoSelectTarget(logger) + } else { + 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 +184,66 @@ func NormalizeHexKey(k string) string { } return k } + +// autoSelectTarget discovers available targets and auto-selects when possible (non-interactive mode). +func autoSelectTarget(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 + } + + return "", fmt.Errorf("multiple targets found in project.yaml and --non-interactive is set; specify --%s or set %s env var", + Flags.Target.Name, CreTargetEnvVar) +} + +// 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 diff --git a/test/convert_simulate_helper.go b/test/convert_simulate_helper.go index cfda81f2..441d0426 100644 --- a/test/convert_simulate_helper.go +++ b/test/convert_simulate_helper.go @@ -14,6 +14,7 @@ func convertSimulateCaptureOutput(t *testing.T, projectRoot, workflowName string cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, "--project-root", projectRoot, "--non-interactive", "--trigger-index=0", + "--target=staging-settings", ) cmd.Dir = projectRoot cmd.Stdout = &stdout @@ -30,6 +31,7 @@ func convertSimulateRequireOutputContains(t *testing.T, projectRoot, workflowNam cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, "--project-root", projectRoot, "--non-interactive", "--trigger-index=0", + "--target=staging-settings", ) cmd.Dir = projectRoot cmd.Stdout = &stdout diff --git a/test/init_and_binding_generation_and_simulate_go_test.go b/test/init_and_binding_generation_and_simulate_go_test.go index 310289df..53cf7a60 100644 --- a/test/init_and_binding_generation_and_simulate_go_test.go +++ b/test/init_and_binding_generation_and_simulate_go_test.go @@ -103,6 +103,7 @@ func TestE2EInit_DevPoRTemplate(t *testing.T) { "--project-root", projectRoot, "--non-interactive", "--trigger-index=0", + "--target=staging-settings", } simulateCmd := exec.Command(CLIPath, simulateArgs...) simulateCmd.Dir = projectRoot diff --git a/test/init_and_simulate_ts_test.go b/test/init_and_simulate_ts_test.go index 102b8ac4..d0e4a0e4 100644 --- a/test/init_and_simulate_ts_test.go +++ b/test/init_and_simulate_ts_test.go @@ -85,6 +85,7 @@ func TestE2EInit_DevPoRTemplateTS(t *testing.T) { "--project-root", projectRoot, "--non-interactive", "--trigger-index=0", + "--target=staging-settings", } simulateCmd := exec.Command(CLIPath, simulateArgs...) simulateCmd.Dir = projectRoot diff --git a/test/init_convert_simulate_go_test.go b/test/init_convert_simulate_go_test.go index 73c9fcbb..7c396517 100644 --- a/test/init_convert_simulate_go_test.go +++ b/test/init_convert_simulate_go_test.go @@ -110,6 +110,7 @@ func convertGoBuildWithFlagAndAssert(t *testing.T, projectRoot, workflowDir, wor cmd := exec.Command(CLIPath, "workflow", "simulate", workflowName, "--project-root", projectRoot, "--non-interactive", "--trigger-index=0", + "--target=staging-settings", ) cmd.Dir = projectRoot cmd.Stdout = &stdout diff --git a/test/init_convert_simulate_ts_test.go b/test/init_convert_simulate_ts_test.go index 362493e4..94ea8387 100644 --- a/test/init_convert_simulate_ts_test.go +++ b/test/init_convert_simulate_ts_test.go @@ -134,6 +134,7 @@ func convertTSBuildWithFlagAndAssert(t *testing.T, projectRoot, workflowDir, wor cmd := exec.Command(CLIPath, "workflow", "simulate", workflowDirAbs, "--project-root", projectRoot, "--non-interactive", "--trigger-index=0", + "--target=staging-settings", ) cmd.Dir = projectRoot cmd.Stdout = &stdout diff --git a/test/multi_command_flows/workflow_simulator_path.go b/test/multi_command_flows/workflow_simulator_path.go index 9f8bd17f..3d64cd43 100644 --- a/test/multi_command_flows/workflow_simulator_path.go +++ b/test/multi_command_flows/workflow_simulator_path.go @@ -97,6 +97,7 @@ func RunSimulationHappyPath(t *testing.T, tc TestConfig, projectDir string) { tc.GetProjectRootFlag(), "--non-interactive", "--trigger-index=0", + "--target=staging-settings", } cmd := exec.Command(CLIPath, args...)