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
89 changes: 81 additions & 8 deletions cmd/creinit/creinit.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,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 +49,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 +74,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 +141,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 +179,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, use its parent directory
if info, err := os.Stat(absRoot); err == nil && !info.IsDir() {
absRoot = filepath.Dir(absRoot)
}
startDir = absRoot
}

// Detect if we're in an existing project
existingProjectRoot, _, existingErr := h.findExistingProject(startDir)
isNewProject := existingErr != nil
Expand Down Expand Up @@ -218,9 +242,58 @@ 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 {
// Check if this is a TTY error and we can provide better guidance
if strings.Contains(err.Error(), "could not open a new TTY") && selectedTemplate != nil {
missing := MissingNetworks(selectedTemplate, inputs.RpcURLs)
if len(missing) > 0 {
suggestions := []string{
"Provide the missing RPC URLs to run non-interactively:",
}
for _, network := range missing {
suggestions = append(suggestions, fmt.Sprintf(" --rpc-url=\"%s=<url>\"", network))
}
ui.ErrorWithSuggestions(
fmt.Sprintf("Template %q requires %d network RPC URL(s) not provided via flags", selectedTemplate.Name, len(missing)),
suggestions,
)
return fmt.Errorf("missing required --rpc-url flags for non-interactive mode")
}
ui.ErrorWithHelp(
"Interactive mode requires a terminal (TTY)",
"Provide all flags to run non-interactively: --project-name, --workflow-name, --rpc-url",
)
return fmt.Errorf("wizard error: %w", err)
}
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