diff --git a/cmd/creinit/creinit.go b/cmd/creinit/creinit.go index f68aea41..9aa6db55 100644 --- a/cmd/creinit/creinit.go +++ b/cmd/creinit/creinit.go @@ -196,22 +196,30 @@ func (h *handler) Execute(inputs Inputs) error { return fmt.Errorf("failed to fetch templates: %w", err) } + // Filter to only workflow templates (category == "workflow") + var workflowTemplates []templaterepo.TemplateSummary + for _, t := range templates { + if t.Category == templaterepo.CategoryWorkflow { + workflowTemplates = append(workflowTemplates, t) + } + } + // Resolve template from flag if provided var selectedTemplate *templaterepo.TemplateSummary if inputs.TemplateName != "" { - for i := range templates { - if templates[i].Name == inputs.TemplateName { - selectedTemplate = &templates[i] + for i := range workflowTemplates { + if workflowTemplates[i].Name == inputs.TemplateName || workflowTemplates[i].ID == inputs.TemplateName { + selectedTemplate = &workflowTemplates[i] break } } if selectedTemplate == nil { - return fmt.Errorf("template %q not found", inputs.TemplateName) + return fmt.Errorf("template %q not found. Run 'cre templates list' to see all available templates", inputs.TemplateName) } } // Run the interactive wizard - result, err := RunWizard(inputs, isNewProject, startDir, templates, selectedTemplate) + result, err := RunWizard(inputs, isNewProject, startDir, workflowTemplates, selectedTemplate) if err != nil { return fmt.Errorf("wizard error: %w", err) } @@ -287,22 +295,24 @@ func (h *handler) Execute(inputs Inputs) error { return fmt.Errorf("failed to scaffold template: %w", err) } + // Patch RPC URLs into project.yaml for all templates (including those with projectDir). + // Templates that ship their own project.yaml still need user-provided RPCs applied. + projectYAMLPath := filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName) + if isNewProject && h.pathExists(projectYAMLPath) { + if err := settings.PatchProjectRPCs(projectYAMLPath, networkRPCs); err != nil { + return fmt.Errorf("failed to update RPC URLs in project.yaml: %w", err) + } + } + // Templates with projectDir provide their own project structure — skip config generation. // Only built-in templates (no projectDir) need config files generated by the CLI. if selectedTemplate.ProjectDir == "" { - // Handle project.yaml - projectYAMLPath := filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName) - if isNewProject { - if h.pathExists(projectYAMLPath) { - if err := settings.PatchProjectRPCs(projectYAMLPath, networkRPCs); err != nil { - return fmt.Errorf("failed to update RPC URLs in project.yaml: %w", err) - } - } else { - networks := selectedTemplate.Networks - repl := settings.GetReplacementsWithNetworks(networks, networkRPCs) - if e := settings.FindOrCreateProjectSettings(projectRoot, repl); e != nil { - return e - } + // Generate project.yaml if the template didn't provide one + if isNewProject && !h.pathExists(projectYAMLPath) { + networks := selectedTemplate.Networks + repl := settings.GetReplacementsWithNetworks(networks, networkRPCs) + if e := settings.FindOrCreateProjectSettings(projectRoot, repl); e != nil { + return e } } @@ -449,7 +459,14 @@ func (h *handler) printSuccessMessage(projectRoot string, tmpl *templaterepo.Tem sb.WriteString(ui.RenderStep("2. Install Bun (if needed):") + "\n") sb.WriteString(" " + ui.RenderDim("npm install -g bun") + "\n\n") sb.WriteString(ui.RenderStep("3. Install dependencies:") + "\n") - sb.WriteString(" " + ui.RenderDim("bun install --cwd ./"+primaryWorkflow) + "\n\n") + if isMultiWorkflow { + for _, wf := range workflows { + sb.WriteString(" " + ui.RenderDim("bun install --cwd ./"+wf.Dir) + "\n") + } + } else { + sb.WriteString(" " + ui.RenderDim("bun install --cwd ./"+primaryWorkflow) + "\n") + } + sb.WriteString("\n") if isMultiWorkflow { sb.WriteString(ui.RenderStep("4. Run a workflow:") + "\n") diff --git a/cmd/creinit/creinit_test.go b/cmd/creinit/creinit_test.go index b0c4eef0..fffb1117 100644 --- a/cmd/creinit/creinit_test.go +++ b/cmd/creinit/creinit_test.go @@ -125,7 +125,7 @@ var testGoTemplate = templaterepo.TemplateSummary{ Title: "Test Go Template", Description: "A test Go template", Language: "go", - Category: "test", + Category: "workflow", Author: "Test", License: "MIT", Networks: []string{"ethereum-testnet-sepolia"}, @@ -146,7 +146,7 @@ var testTSTemplate = templaterepo.TemplateSummary{ Title: "Test TypeScript Template", Description: "A test TypeScript template", Language: "typescript", - Category: "test", + Category: "workflow", Author: "Test", License: "MIT", Workflows: []templaterepo.WorkflowDirEntry{{Dir: "my-workflow"}}, @@ -166,7 +166,7 @@ var testStarterTemplate = templaterepo.TemplateSummary{ Title: "Starter Go Template", Description: "A starter Go template", Language: "go", - Category: "test", + Category: "workflow", Author: "Test", License: "MIT", Workflows: []templaterepo.WorkflowDirEntry{{Dir: "my-workflow"}}, @@ -186,7 +186,7 @@ var testMultiNetworkTemplate = templaterepo.TemplateSummary{ Title: "Test Multi-Chain Template", Description: "A template requiring multiple chains", Language: "go", - Category: "test", + Category: "workflow", Author: "Test", License: "MIT", Networks: []string{"ethereum-testnet-sepolia", "ethereum-mainnet"}, @@ -207,7 +207,7 @@ var testBuiltInGoTemplate = templaterepo.TemplateSummary{ Title: "Hello World (Go)", Description: "A built-in Go template", Language: "go", - Category: "getting-started", + Category: "workflow", Author: "Test", License: "MIT", }, @@ -222,7 +222,7 @@ var testMultiWorkflowTemplate = templaterepo.TemplateSummary{ Title: "Bring Your Own Data (Go)", Description: "Bring your own off-chain data on-chain with PoR and NAV publishing.", Language: "go", - Category: "data-feeds", + Category: "workflow", Author: "Test", License: "MIT", Networks: []string{"ethereum-testnet-sepolia"}, @@ -247,7 +247,7 @@ var testSingleWorkflowWithPostInit = templaterepo.TemplateSummary{ Title: "KV Store (Go)", Description: "Read, increment, and write a counter in AWS S3.", Language: "go", - Category: "off-chain-storage", + Category: "workflow", Author: "Test", License: "MIT", Workflows: []templaterepo.WorkflowDirEntry{{Dir: "my-workflow"}}, @@ -261,6 +261,30 @@ var testSingleWorkflowWithPostInit = templaterepo.TemplateSummary{ }, } +var testProjectDirWithNetworks = templaterepo.TemplateSummary{ + TemplateMetadata: templaterepo.TemplateMetadata{ + Kind: "starter-template", + Name: "starter-with-projectdir", + Title: "Starter With ProjectDir", + Description: "A starter template that ships its own project structure", + Language: "typescript", + Category: "workflow", + Author: "Test", + License: "MIT", + ProjectDir: ".", + Networks: []string{"ethereum-testnet-sepolia", "ethereum-mainnet"}, + Workflows: []templaterepo.WorkflowDirEntry{ + {Dir: "my-workflow", Description: "Test workflow"}, + }, + }, + Path: "starter-templates/test/starter-with-projectdir", + Source: templaterepo.RepoSource{ + Owner: "test", + Repo: "templates", + Ref: "main", + }, +} + func newMockRegistry() *mockRegistry { return &mockRegistry{ templates: []templaterepo.TemplateSummary{ @@ -271,6 +295,7 @@ func newMockRegistry() *mockRegistry { testBuiltInGoTemplate, testMultiWorkflowTemplate, testSingleWorkflowWithPostInit, + testProjectDirWithNetworks, }, } } @@ -570,6 +595,45 @@ func TestInitRemoteTemplateKeepsProjectYAML(t *testing.T) { require.Contains(t, string(envContent), "GITHUB_API_TOKEN=test-token") } +func TestInitProjectDirTemplateRpcPatching(t *testing.T) { + sim := chainsim.NewSimulatedEnvironment(t) + defer sim.Close() + + tempDir := t.TempDir() + restoreCwd, err := testutil.ChangeWorkingDirectory(tempDir) + require.NoError(t, err) + defer restoreCwd() + + // Template with ProjectDir set AND Networks — the bug was that RPC URLs + // were silently dropped because the patching was inside the ProjectDir=="" block. + inputs := Inputs{ + ProjectName: "projectDirProj", + TemplateName: "starter-with-projectdir", + WorkflowName: "my-workflow", + RpcURLs: map[string]string{ + "ethereum-testnet-sepolia": "https://sepolia.custom.com", + "ethereum-mainnet": "https://mainnet.custom.com", + }, + } + + h := newHandlerWithRegistry(sim.NewRuntimeContext(), newMockRegistry()) + require.NoError(t, h.ValidateInputs(inputs)) + require.NoError(t, h.Execute(inputs)) + + projectRoot := filepath.Join(tempDir, "projectDirProj") + projectYAML, err := os.ReadFile(filepath.Join(projectRoot, constants.DefaultProjectSettingsFileName)) + require.NoError(t, err) + content := string(projectYAML) + + // User-provided RPCs must be patched even though ProjectDir is set + require.Contains(t, content, "https://sepolia.custom.com", + "user RPC URL for sepolia should be patched into project.yaml for templates with ProjectDir") + require.Contains(t, content, "https://mainnet.custom.com", + "user RPC URL for mainnet should be patched into project.yaml for templates with ProjectDir") + require.NotContains(t, content, "https://default-rpc.example.com", + "mock default URLs should be replaced by user-provided URLs") +} + func TestTemplateNotFound(t *testing.T) { sim := chainsim.NewSimulatedEnvironment(t) defer sim.Close() diff --git a/cmd/creinit/wizard.go b/cmd/creinit/wizard.go index c66049ec..d8bf052d 100644 --- a/cmd/creinit/wizard.go +++ b/cmd/creinit/wizard.go @@ -51,7 +51,7 @@ func (t templateItem) Title() string { func (t templateItem) Description() string { return t.TemplateSummary.Description } func (t templateItem) FilterValue() string { s := t.TemplateSummary - return s.Title + " " + s.Name + " " + s.Description + " " + s.Language + " " + s.Category + " " + strings.Join(s.Tags, " ") + return s.Title + " " + s.Name + " " + s.Description + " " + s.Language + " " + s.Category + " " + strings.Join(s.Tags, " ") + " " + strings.Join(s.Solutions, " ") + " " + strings.Join(s.Capabilities, " ") } // languageFilter controls template list filtering by language. @@ -110,7 +110,7 @@ func sortTemplates(templates []templaterepo.TemplateSummary) []templaterepo.Temp // // Title Go // Description line 1 -// Description line 2 +// Solutions: ... | Capabilities: ... type templateDelegate struct{} func (d templateDelegate) Height() int { return 3 } @@ -138,6 +138,7 @@ func (d templateDelegate) Render(w io.Writer, m list.Model, index int, item list titleStyle lipgloss.Style descStyle lipgloss.Style langStyle lipgloss.Style + tagStyle lipgloss.Style prefix string ) @@ -149,32 +150,47 @@ func (d templateDelegate) Render(w io.Writer, m list.Model, index int, item list titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorBlue500)).Bold(true) descStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorBlue300)) langStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorTeal400)).Bold(true) + tagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGray400)) case isDimmed: prefix = " " titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGray600)) descStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGray700)) langStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGray700)) + tagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGray700)) default: prefix = " " titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGray50)) descStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGray500)) langStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGray400)) + tagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ui.ColorGray500)) } // Line 1: title + language tag fmt.Fprintf(w, "%s%s %s", prefix, titleStyle.Render(title), langStyle.Render(lang)) - // Lines 2-3: description (word-wrapped, up to 2 lines) + // Line 2: description (truncated to single line) + fmt.Fprint(w, "\n") descLines := wrapText(desc, contentWidth) - for i := 0; i < 2; i++ { - fmt.Fprint(w, "\n") - if i < len(descLines) { - line := descLines[i] - if i == 1 && len(descLines) > 2 { - line += "..." - } - fmt.Fprintf(w, "%s%s", prefix, descStyle.Render(line)) + if len(descLines) > 0 { + line := descLines[0] + if len(descLines) > 1 { + line += "..." } + fmt.Fprintf(w, "%s%s", prefix, descStyle.Render(line)) + } + + // Line 3: solutions and capabilities metadata + fmt.Fprint(w, "\n") + var meta []string + if len(tmplItem.Solutions) > 0 { + meta = append(meta, formatSlugList(tmplItem.Solutions)) + } + if len(tmplItem.Capabilities) > 0 { + meta = append(meta, strings.Join(tmplItem.Capabilities, ", ")) + } + if len(meta) > 0 { + metaLine := strings.Join(meta, " | ") + fmt.Fprintf(w, "%s%s", prefix, tagStyle.Render(metaLine)) } } @@ -224,11 +240,21 @@ func wrapText(text string, maxWidth int) []string { return lines } +// formatSlugList converts slug-case values to human-readable labels (e.g., "defi-vault-operations" -> "Defi Vault Operations"). +func formatSlugList(slugs []string) string { + labels := make([]string, len(slugs)) + for i, s := range slugs { + labels[i] = strings.ReplaceAll(s, "-", " ") + } + return strings.Join(labels, ", ") +} + type wizardStep int const ( stepProjectName wizardStep = iota stepTemplate + stepTemplateConfirm stepNetworkRPCs stepWorkflowName stepDone @@ -448,6 +474,17 @@ func (m *wizardModel) advanceToNextStep() { continue } return + case stepTemplateConfirm: + // Show only when the template was pre-selected via --template flag + // (skipTemplate is true) and the wizard is interactive (at least + // one other step needs user input). If the user picked from the + // list they already know what they selected. + isFullyNonInteractive := m.skipProjectName && m.skipTemplate && m.skipNetworkRPCs && m.skipWorkflowName + if !m.skipTemplate || isFullyNonInteractive { + m.step++ + continue + } + return case stepNetworkRPCs: if m.skipNetworkRPCs { m.step++ @@ -604,6 +641,8 @@ func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case stepTemplate: // Forward non-key messages (e.g. FilterMatchesMsg) to the list m.templateList, cmd = m.templateList.Update(msg) + case stepTemplateConfirm: + // Nothing to update case stepDone: // Nothing to update } @@ -658,6 +697,11 @@ func (m wizardModel) handleEnter(msgs ...tea.Msg) (tea.Model, tea.Cmd) { m.step++ m.advanceToNextStep() + case stepTemplateConfirm: + // User pressed enter to confirm the selected template + m.step++ + m.advanceToNextStep() + case stepNetworkRPCs: value := strings.TrimSpace(m.rpcInputs[m.rpcCursor].Value()) network := m.networks[m.rpcCursor] @@ -728,7 +772,7 @@ func (m wizardModel) View() string { b.WriteString(m.dimStyle.Render(" Project: " + m.projectName)) b.WriteString("\n") } - if m.selectedTemplate != nil && m.step > stepTemplate { + if m.selectedTemplate != nil && m.step > stepTemplateConfirm { b.WriteString(m.dimStyle.Render(" Template: " + m.selectedTemplate.Title + " [" + m.selectedTemplate.Language + "]")) b.WriteString("\n") } @@ -815,6 +859,30 @@ func (m wizardModel) View() string { // Render the list b.WriteString(m.templateList.View()) + case stepTemplateConfirm: + tmpl := m.selectedTemplate + title := stripLangSuffix(tmpl.Title) + lang := shortLang(tmpl.Language) + + boxTitle := m.titleStyle.Render(title) + " " + m.tagStyle.Render(lang) + var boxContent strings.Builder + boxContent.WriteString(boxTitle) + if tmpl.Description != "" { + boxContent.WriteString("\n") + boxContent.WriteString(m.dimStyle.Render(tmpl.Description)) + } + + boxStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color(ui.ColorBlue500)). + Padding(0, 1). + MarginLeft(2) + + b.WriteString(m.promptStyle.Render(" Template selected")) + b.WriteString("\n\n") + b.WriteString(boxStyle.Render(boxContent.String())) + b.WriteString("\n") + case stepNetworkRPCs: b.WriteString(m.promptStyle.Render(" RPC URL overrides (optional)")) b.WriteString("\n") diff --git a/cmd/templates/list/list.go b/cmd/templates/list/list.go index d2874c98..45934209 100644 --- a/cmd/templates/list/list.go +++ b/cmd/templates/list/list.go @@ -2,6 +2,7 @@ package list import ( "fmt" + "strings" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -75,6 +76,11 @@ func (h *handler) Execute(refresh bool) error { ui.Line() for _, t := range templates { + // Only show workflow templates + if t.Category != templaterepo.CategoryWorkflow { + continue + } + title := t.Title if title == "" { title = t.Name @@ -92,6 +98,16 @@ func (h *handler) Execute(refresh bool) error { ui.Dim(fmt.Sprintf(" %s", t.Description)) } + if len(t.Solutions) > 0 { + ui.Dim(fmt.Sprintf(" Solutions: %s", strings.Join(t.Solutions, ", "))) + } + if len(t.Capabilities) > 0 { + ui.Dim(fmt.Sprintf(" Capabilities: %s", strings.Join(t.Capabilities, ", "))) + } + if len(t.Tags) > 0 { + ui.Dim(fmt.Sprintf(" Tags: %s", strings.Join(t.Tags, ", "))) + } + ui.Line() } diff --git a/internal/templaterepo/builtin.go b/internal/templaterepo/builtin.go index ffe40fdc..e69f0a27 100644 --- a/internal/templaterepo/builtin.go +++ b/internal/templaterepo/builtin.go @@ -20,15 +20,16 @@ var builtinTSFS embed.FS // BuiltInGoTemplate is the embedded hello-world Go template that is always available. var BuiltInGoTemplate = TemplateSummary{ TemplateMetadata: TemplateMetadata{ - Kind: "building-block", - Name: "hello-world-go", - Title: "Hello World (Go)", - Description: "A minimal cron-triggered workflow to get started from scratch", - Language: "go", - Category: "getting-started", - Author: "Chainlink", - License: "MIT", - Tags: []string{"cron", "starter", "minimal"}, + Kind: "building-block", + Name: "hello-world-go", + Title: "Hello World (Go)", + Description: "A minimal cron-triggered workflow to get started from scratch", + Language: "go", + Category: "workflow", + Capabilities: []string{"cron"}, + Author: "Chainlink", + License: "MIT", + Tags: []string{"cron", "starter", "minimal"}, }, Path: "builtin/hello-world-go", BuiltIn: true, @@ -37,15 +38,16 @@ var BuiltInGoTemplate = TemplateSummary{ // BuiltInTSTemplate is the embedded hello-world TypeScript template that is always available. var BuiltInTSTemplate = TemplateSummary{ TemplateMetadata: TemplateMetadata{ - Kind: "building-block", - Name: "hello-world-ts", - Title: "Hello World (TypeScript)", - Description: "A minimal cron-triggered workflow to get started from scratch", - Language: "typescript", - Category: "getting-started", - Author: "Chainlink", - License: "MIT", - Tags: []string{"cron", "starter", "minimal"}, + Kind: "building-block", + Name: "hello-world-ts", + Title: "Hello World (TypeScript)", + Description: "A minimal cron-triggered workflow to get started from scratch", + Language: "typescript", + Category: "workflow", + Capabilities: []string{"cron"}, + Author: "Chainlink", + License: "MIT", + Tags: []string{"cron", "starter", "minimal"}, }, Path: "builtin/hello-world-ts", BuiltIn: true, diff --git a/internal/templaterepo/types.go b/internal/templaterepo/types.go index 5481aa57..a472a45a 100644 --- a/internal/templaterepo/types.go +++ b/internal/templaterepo/types.go @@ -1,5 +1,8 @@ package templaterepo +// CategoryWorkflow is the category value for installable workflow templates. +const CategoryWorkflow = "workflow" + // WorkflowDirEntry describes a workflow directory inside a template. type WorkflowDirEntry struct { Dir string `yaml:"dir"` @@ -8,21 +11,23 @@ type WorkflowDirEntry struct { // TemplateMetadata represents the contents of a template.yaml file. type TemplateMetadata struct { - Kind string `yaml:"kind"` // "building-block" or "starter-template" - ID string `yaml:"id"` // Unique slug identifier (preferred over name) - Name string `yaml:"name"` // Unique slug identifier (deprecated, use id) - Title string `yaml:"title"` // Human-readable display name - Description string `yaml:"description"` // Short description - Language string `yaml:"language"` // "go" or "typescript" - Category string `yaml:"category"` // Topic category (e.g., "web3") - Author string `yaml:"author"` - License string `yaml:"license"` - Tags []string `yaml:"tags"` // Searchable tags - Exclude []string `yaml:"exclude"` // Files/dirs to exclude when copying - Networks []string `yaml:"networks"` // Required chain names (e.g., "ethereum-testnet-sepolia") - Workflows []WorkflowDirEntry `yaml:"workflows"` // Workflow directories inside the template - PostInit string `yaml:"postInit"` // Template-specific post-init instructions - ProjectDir string `yaml:"projectDir"` // CRE project directory within the template (e.g., "." or "cre-workflow") + Kind string `yaml:"kind"` // "building-block" or "starter-template" + ID string `yaml:"id"` // Unique slug identifier (preferred over name) + Name string `yaml:"name"` // Unique slug identifier (deprecated, use id) + Title string `yaml:"title"` // Human-readable display name + Description string `yaml:"description"` // Short description + Language string `yaml:"language"` // "go" or "typescript" + Category string `yaml:"category"` // Template type: "workflow" or "demo-app" + Solutions []string `yaml:"solutions"` // Solution categories (e.g., "defi-vault-operations") + Capabilities []string `yaml:"capabilities"` // CRE capabilities used (e.g., "cron", "http", "chain-read") + Author string `yaml:"author"` + License string `yaml:"license"` + Tags []string `yaml:"tags"` // Searchable tags + Exclude []string `yaml:"exclude"` // Files/dirs to exclude when copying + Networks []string `yaml:"networks"` // Required chain names (e.g., "ethereum-testnet-sepolia") + Workflows []WorkflowDirEntry `yaml:"workflows"` // Workflow directories inside the template + PostInit string `yaml:"postInit"` // Template-specific post-init instructions + ProjectDir string `yaml:"projectDir"` // CRE project directory within the template (e.g., "." or "cre-workflow") } // GetName returns the template identifier, preferring ID over Name for backward compatibility.