Skip to content
Merged
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
88 changes: 80 additions & 8 deletions cmd/creinit/creinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/rs/zerolog"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"

"github.com/smartcontractkit/cre-cli/internal/constants"
"github.com/smartcontractkit/cre-cli/internal/runtime"
Expand All @@ -22,10 +23,12 @@ import (
)

type Inputs struct {
ProjectName string `validate:"omitempty,project_name" cli:"project-name"`
TemplateName string `validate:"omitempty" cli:"template"`
WorkflowName string `validate:"omitempty,workflow_name" cli:"workflow-name"`
RpcURLs map[string]string // chain-name -> url, from --rpc-url flags
ProjectName string `validate:"omitempty,project_name" cli:"project-name"`
TemplateName string `validate:"omitempty" cli:"template"`
WorkflowName string `validate:"omitempty,workflow_name" cli:"workflow-name"`
RpcURLs map[string]string // chain-name -> url, from --rpc-url flags
NonInteractive bool
ProjectRoot string // from -R / --project-root flag
}

func New(runtimeContext *runtime.Context) *cobra.Command {
Expand All @@ -47,6 +50,11 @@ Templates are fetched dynamically from GitHub repositories.`,
if err != nil {
return err
}

// Only use -R if the user explicitly passed it on the command line
if cmd.Flags().Changed(settings.Flags.ProjectRoot.Name) {
inputs.ProjectRoot = runtimeContext.Viper.GetString(settings.Flags.ProjectRoot.Name)
}
if err = h.ValidateInputs(inputs); err != nil {
return err
}
Expand All @@ -67,6 +75,7 @@ Templates are fetched dynamically from GitHub repositories.`,
initCmd.Flags().StringP("template", "t", "", "Name of the template to use (e.g., kv-store-go)")
initCmd.Flags().Bool("refresh", false, "Bypass template cache and fetch fresh data")
initCmd.Flags().StringArray("rpc-url", nil, "RPC URL for a network (format: chain-name=url, repeatable)")
initCmd.Flags().Bool("non-interactive", false, "Fail instead of prompting; requires all inputs via flags")

// Deprecated: --template-id is kept for backwards compatibility, maps to hello-world-go
initCmd.Flags().Uint32("template-id", 0, "")
Expand Down Expand Up @@ -133,10 +142,11 @@ func (h *handler) ResolveInputs(v *viper.Viper) (Inputs, error) {
}

return Inputs{
ProjectName: v.GetString("project-name"),
TemplateName: templateName,
WorkflowName: v.GetString("workflow-name"),
RpcURLs: rpcURLs,
ProjectName: v.GetString("project-name"),
TemplateName: templateName,
WorkflowName: v.GetString("workflow-name"),
RpcURLs: rpcURLs,
NonInteractive: v.GetBool("non-interactive"),
}, nil
}

Expand Down Expand Up @@ -170,6 +180,21 @@ func (h *handler) Execute(inputs Inputs) error {
}
startDir := cwd

// Respect -R / --project-root flag if provided.
// For init, treat -R as the base directory for project creation.
// The directory does not need to exist yet — it will be created during scaffolding.
if inputs.ProjectRoot != "" {
absRoot, err := filepath.Abs(inputs.ProjectRoot)
if err != nil {
return fmt.Errorf("invalid --project-root path: %w", err)
}
// If -R points to a file, that's a user error — it must be a directory
if info, err := os.Stat(absRoot); err == nil && !info.IsDir() {
return fmt.Errorf("--project-root %q is a file, not a directory", inputs.ProjectRoot)
}
startDir = absRoot
}

// Detect if we're in an existing project
existingProjectRoot, _, existingErr := h.findExistingProject(startDir)
isNewProject := existingErr != nil
Expand Down Expand Up @@ -218,9 +243,56 @@ func (h *handler) Execute(inputs Inputs) error {
}
}

// Non-interactive mode: validate all required inputs are present
if inputs.NonInteractive {
var missingFlags []string
if isNewProject && inputs.ProjectName == "" {
missingFlags = append(missingFlags, "--project-name")
}
if inputs.TemplateName == "" {
missingFlags = append(missingFlags, "--template")
}
if selectedTemplate != nil {
missing := MissingNetworks(selectedTemplate, inputs.RpcURLs)
for _, network := range missing {
missingFlags = append(missingFlags, fmt.Sprintf("--rpc-url=\"%s=<url>\"", network))
}
if inputs.WorkflowName == "" && selectedTemplate.ProjectDir == "" && len(selectedTemplate.Workflows) <= 1 {
missingFlags = append(missingFlags, "--workflow-name")
}
}
if len(missingFlags) > 0 {
ui.ErrorWithSuggestions(
"Non-interactive mode requires all inputs via flags",
missingFlags,
)
return fmt.Errorf("missing required flags for --non-interactive mode")
}
}

// Run the interactive wizard
result, err := RunWizard(inputs, isNewProject, startDir, workflowTemplates, selectedTemplate)
if err != nil {
// If stdin is not a terminal, the wizard will fail trying to open a TTY.
// Detect this via term.IsTerminal rather than matching third-party error strings.
if !term.IsTerminal(int(os.Stdin.Fd())) { // #nosec G115 -- stdin fd is always 0
var suggestions []string
if selectedTemplate != nil {
missing := MissingNetworks(selectedTemplate, inputs.RpcURLs)
for _, network := range missing {
suggestions = append(suggestions, fmt.Sprintf("--rpc-url=\"%s=<url>\"", network))
}
}
if len(suggestions) > 0 {
ui.ErrorWithSuggestions(
"Interactive mode requires a terminal (TTY). Provide the missing flags to run non-interactively",
suggestions,
)
} else {
ui.Error("Interactive mode requires a terminal (TTY). Use --non-interactive with all required flags, or run in a terminal")
}
return fmt.Errorf("interactive mode requires a terminal (TTY)")
}
return fmt.Errorf("wizard error: %w", err)
}
if result.Cancelled {
Expand Down
183 changes: 183 additions & 0 deletions cmd/creinit/creinit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -830,3 +830,186 @@ func TestBuiltInTemplateBackwardsCompat(t *testing.T) {
"built-in template should use user's workflow name")
require.FileExists(t, filepath.Join(projectRoot, "hello-wf", constants.DefaultWorkflowSettingsFileName))
}

func TestMissingNetworks(t *testing.T) {
cases := []struct {
name string
template *templaterepo.TemplateSummary
flags map[string]string
expected []string
}{
{
name: "nil template",
template: nil,
flags: nil,
expected: nil,
},
{
name: "no networks required",
template: &templaterepo.TemplateSummary{
TemplateMetadata: templaterepo.TemplateMetadata{},
},
flags: nil,
expected: nil,
},
{
name: "all provided",
template: &testMultiNetworkTemplate,
flags: map[string]string{
"ethereum-testnet-sepolia": "https://rpc1.example.com",
"ethereum-mainnet": "https://rpc2.example.com",
},
expected: nil,
},
{
name: "some missing",
template: &testMultiNetworkTemplate,
flags: map[string]string{
"ethereum-testnet-sepolia": "https://rpc1.example.com",
},
expected: []string{"ethereum-mainnet"},
},
{
name: "all missing",
template: &testMultiNetworkTemplate,
flags: map[string]string{},
expected: []string{"ethereum-testnet-sepolia", "ethereum-mainnet"},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := MissingNetworks(tc.template, tc.flags)
require.Equal(t, tc.expected, result)
})
}
}

func TestNonInteractiveMissingFlags(t *testing.T) {
sim := chainsim.NewSimulatedEnvironment(t)
defer sim.Close()

tempDir := t.TempDir()
restoreCwd, err := testutil.ChangeWorkingDirectory(tempDir)
require.NoError(t, err)
defer restoreCwd()

inputs := Inputs{
ProjectName: "proj",
TemplateName: "test-multichain",
WorkflowName: "",
NonInteractive: true,
RpcURLs: map[string]string{},
}

h := newHandlerWithRegistry(sim.NewRuntimeContext(), newMockRegistry())
require.NoError(t, h.ValidateInputs(inputs))
err = h.Execute(inputs)
require.Error(t, err)
require.Contains(t, err.Error(), "missing required flags for --non-interactive mode")
}

func TestNonInteractiveAllFlagsProvided(t *testing.T) {
sim := chainsim.NewSimulatedEnvironment(t)
defer sim.Close()

tempDir := t.TempDir()
restoreCwd, err := testutil.ChangeWorkingDirectory(tempDir)
require.NoError(t, err)
defer restoreCwd()

inputs := Inputs{
ProjectName: "niProj",
TemplateName: "hello-world-go",
WorkflowName: "my-wf",
NonInteractive: true,
}

h := newHandlerWithRegistry(sim.NewRuntimeContext(), newMockRegistry())
require.NoError(t, h.ValidateInputs(inputs))
require.NoError(t, h.Execute(inputs))

projectRoot := filepath.Join(tempDir, "niProj")
require.DirExists(t, filepath.Join(projectRoot, "my-wf"))
}

func TestInitRespectsProjectRootFlag(t *testing.T) {
sim := chainsim.NewSimulatedEnvironment(t)
defer sim.Close()

// CWD is a temp dir (simulating being "somewhere else")
cwdDir := t.TempDir()
restoreCwd, err := testutil.ChangeWorkingDirectory(cwdDir)
require.NoError(t, err)
defer restoreCwd()

// Target directory is a separate temp dir (simulating -R flag)
targetDir := t.TempDir()

inputs := Inputs{
ProjectName: "myproj",
TemplateName: "test-go",
WorkflowName: "mywf",
RpcURLs: map[string]string{"ethereum-testnet-sepolia": "https://rpc.example.com"},
ProjectRoot: targetDir,
}

ctx := sim.NewRuntimeContext()

h := newHandlerWithRegistry(ctx, newMockRegistry())
require.NoError(t, h.ValidateInputs(inputs))
require.NoError(t, h.Execute(inputs))

// Project should be created under targetDir, NOT cwdDir
projectRoot := filepath.Join(targetDir, "myproj")
validateInitProjectStructure(t, projectRoot, "mywf", GetTemplateFileListGo())

// Verify nothing was created in CWD
entries, err := os.ReadDir(cwdDir)
require.NoError(t, err)
require.Empty(t, entries, "CWD should be untouched when -R is provided")
}

func TestInitProjectRootFlagFindsExistingProject(t *testing.T) {
sim := chainsim.NewSimulatedEnvironment(t)
defer sim.Close()

// CWD is a clean temp dir with no project
cwdDir := t.TempDir()
restoreCwd, err := testutil.ChangeWorkingDirectory(cwdDir)
require.NoError(t, err)
defer restoreCwd()

// Create an "existing project" in a separate directory
existingProject := t.TempDir()
require.NoError(t, os.WriteFile(
filepath.Join(existingProject, constants.DefaultProjectSettingsFileName),
[]byte("name: existing"), 0600,
))
require.NoError(t, os.WriteFile(
filepath.Join(existingProject, constants.DefaultEnvFileName),
[]byte(""), 0600,
))

inputs := Inputs{
ProjectName: "",
TemplateName: "test-go",
WorkflowName: "new-workflow",
RpcURLs: map[string]string{"ethereum-testnet-sepolia": "https://rpc.example.com"},
ProjectRoot: existingProject,
}

ctx := sim.NewRuntimeContext()

h := newHandlerWithRegistry(ctx, newMockRegistry())
require.NoError(t, h.ValidateInputs(inputs))
require.NoError(t, h.Execute(inputs))

// Workflow should be scaffolded into the existing project
validateInitProjectStructure(
t,
existingProject,
"new-workflow",
GetTemplateFileListGo(),
)
}
16 changes: 16 additions & 0 deletions cmd/creinit/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,22 @@ func RunWizard(inputs Inputs, isNewProject bool, startDir string, templates []te
return result, nil
}

// MissingNetworks returns the network names from the template that were not
// provided via --rpc-url flags. Returns nil if all networks are covered or
// the template has no network requirements.
func MissingNetworks(template *templaterepo.TemplateSummary, flagRpcURLs map[string]string) []string {
if template == nil || len(template.Networks) == 0 {
return nil
}
var missing []string
for _, network := range template.Networks {
if _, ok := flagRpcURLs[network]; !ok {
missing = append(missing, network)
}
}
return missing
}

// validateRpcURL validates that a URL is a valid HTTP/HTTPS URL.
func validateRpcURL(rawURL string) error {
u, err := url.Parse(rawURL)
Expand Down
5 changes: 4 additions & 1 deletion cmd/template/help_template.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@
{{styleDim (printf "Use \"%s [command] --help\" for more information about a command." .CommandPath)}}
{{- end }}

{{- if not .HasParent}}

{{styleSuccess "Tip:"}} New here? Run:
{{styleCode "$ cre login"}}
to login into your cre account, then:
Expand All @@ -94,9 +96,10 @@
{{- if needsDeployAccess}}

🔑 Ready to deploy? Run:
$ cre account access
{{styleCode "$ cre account access"}}
to request deployment access.
{{- end}}
{{- end}}

{{styleSection "Need more help?"}}
Visit {{styleURL "https://docs.chain.link/cre"}}
Expand Down
Loading
Loading