From 4c55e1ba41505fe642fd00610027a65d8e9e842b Mon Sep 17 00:00:00 2001 From: Solimander Date: Wed, 5 Nov 2025 14:57:38 -0800 Subject: [PATCH 1/9] feat: enhance project creation with template catalog support --- config/.gitignore | 11 ++ config/config_embeds.go | 3 - config/templates.yaml | 9 - pkg/commands/app/create.go | 309 +++++++++++++++--------------- pkg/commands/utils/interactive.go | 40 ++++ pkg/common/flags.go | 6 +- pkg/template/catalog.go | 207 ++++++++++++++++++++ pkg/template/config.go | 56 ------ 8 files changed, 419 insertions(+), 222 deletions(-) delete mode 100644 config/templates.yaml create mode 100644 pkg/template/catalog.go delete mode 100644 pkg/template/config.go diff --git a/config/.gitignore b/config/.gitignore index d6182c2..aff0602 100644 --- a/config/.gitignore +++ b/config/.gitignore @@ -30,6 +30,8 @@ temp_external/ # Environment .env +.env.* +!.env.example # Language-specific build outputs # Node.js @@ -40,6 +42,7 @@ yarn-error.log* *.tgz dist/ build/ +coverage/ # Python __pycache__/ @@ -65,6 +68,11 @@ wheels/ .tox/ .coverage .pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +env/ +venv/ # Rust target/ @@ -73,3 +81,6 @@ Cargo.lock # Go *.test *.prof + +# Misc +*.log diff --git a/config/config_embeds.go b/config/config_embeds.go index 3b053c5..69e703d 100644 --- a/config/config_embeds.go +++ b/config/config_embeds.go @@ -2,9 +2,6 @@ package config import _ "embed" -//go:embed templates.yaml -var TemplatesYaml string - //go:embed .gitignore var GitIgnore string diff --git a/config/templates.yaml b/config/templates.yaml deleted file mode 100644 index 28d890d..0000000 --- a/config/templates.yaml +++ /dev/null @@ -1,9 +0,0 @@ -framework: - tee: - template: "https://github.com/Layr-Labs/eigenx-templates" - version: "main" - languages: - - typescript - - golang - - rust - - python diff --git a/pkg/commands/app/create.go b/pkg/commands/app/create.go index 33b182b..97e13c7 100644 --- a/pkg/commands/app/create.go +++ b/pkg/commands/app/create.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/Layr-Labs/eigenx-cli/config" + "github.com/Layr-Labs/eigenx-cli/pkg/commands/utils" "github.com/Layr-Labs/eigenx-cli/pkg/common" "github.com/Layr-Labs/eigenx-cli/pkg/common/logger" "github.com/Layr-Labs/eigenx-cli/pkg/common/output" @@ -20,101 +21,160 @@ import ( var CreateCommand = &cli.Command{ Name: "create", Usage: "Create new app project from template", - ArgsUsage: "[name] [language]", + ArgsUsage: "[name] [language] [template-name]", Flags: append(common.GlobalFlags, []cli.Flag{ - common.TemplateFlag, + common.TemplateRepoFlag, common.TemplateVersionFlag, }...), Action: createAction, } -// Language configuration -var primaryLanguages = []string{"typescript", "golang", "rust", "python"} +const ( + envVarUseLocalTemplates = "EIGENX_USE_LOCAL_TEMPLATES" + envVarTemplatesPath = "EIGENX_TEMPLATES_PATH" +) -var shortNames = map[string]string{ - "ts": "typescript", - "go": "golang", - "rs": "rust", - "py": "python", -} +var ( + primaryLanguages = []string{"typescript", "golang", "rust", "python"} + + shortNames = map[string]string{ + "ts": "typescript", + "go": "golang", + "rs": "rust", + "py": "python", + } + + languageFiles = map[string][]string{ + "typescript": {"package.json"}, + "rust": {"Cargo.toml", "Dockerfile"}, + "golang": {"go.mod"}, + } +) -var languageFiles = map[string][]string{ - "typescript": {"package.json"}, - "rust": {"Cargo.toml", "Dockerfile"}, - "golang": {"go.mod"}, +type projectConfig struct { + name string + language string + templateName string + repoURL string + ref string + subPath string } func createAction(cCtx *cli.Context) error { + cfg, err := gatherProjectConfig(cCtx) + if err != nil { + return err + } + + // Check if directory exists + if _, err := os.Stat(cfg.name); err == nil { + return fmt.Errorf("directory %s already exists", cfg.name) + } + + // Create project directory + if err := os.MkdirAll(cfg.name, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", cfg.name, err) + } + + if err := populateProjectFromTemplate(cCtx, cfg); err != nil { + os.RemoveAll(cfg.name) + return err + } + + if cfg.subPath != "" { + if err := postProcessTemplate(cfg.name, cfg.language); err != nil { + return fmt.Errorf("failed to post-process template: %w", err) + } + } + + fmt.Printf("Successfully created %s project: %s\n", cfg.language, cfg.name) + return nil +} + +func gatherProjectConfig(cCtx *cli.Context) (*projectConfig, error) { + cfg := &projectConfig{} + // Get project name name := cCtx.Args().First() if name == "" { var err error name, err = output.InputString("Enter project name:", "", "", validateProjectName) if err != nil { - return fmt.Errorf("failed to get project name: %w", err) + return nil, fmt.Errorf("failed to get project name: %w", err) + } + } + cfg.name = name + + // Handle custom template repo + customTemplateRepo := cCtx.String(common.TemplateRepoFlag.Name) + if customTemplateRepo != "" { + cfg.repoURL = customTemplateRepo + cfg.ref = cCtx.String(common.TemplateVersionFlag.Name) + if cfg.ref == "" { + cfg.ref = "main" } + return cfg, nil } - // Check if directory exists - if _, err := os.Stat(name); err == nil { - return fmt.Errorf("directory %s already exists", name) - } - - // Get language - only needed for built-in templates - var language string - if cCtx.String(common.TemplateFlag.Name) == "" { - language = cCtx.Args().Get(1) - if language == "" { - var err error - language, err = output.SelectString("Select language:", primaryLanguages) - if err != nil { - return fmt.Errorf("failed to select language: %w", err) - } - } else { - // Resolve short names to full names - if fullName, exists := shortNames[language]; exists { - language = fullName - } + // Handle built-in templates + language := cCtx.Args().Get(1) + if language == "" { + var err error + language, err = output.SelectString("Select language:", primaryLanguages) + if err != nil { + return nil, fmt.Errorf("failed to select language: %w", err) + } + } else { + // Resolve short names to full names + if fullName, exists := shortNames[language]; exists { + language = fullName + } + if !slices.Contains(primaryLanguages, language) { + return nil, fmt.Errorf("unsupported language: %s", language) + } + } + cfg.language = language - // Validate language is supported - supported := slices.Contains(primaryLanguages, language) - if !supported { - return fmt.Errorf("unsupported language: %s", language) - } + // Get template name + templateName := cCtx.Args().Get(2) + if templateName == "" { + var err error + templateName, err = utils.SelectTemplateInteractive(language) + if err != nil { + return nil, fmt.Errorf("failed to select template: %w", err) } } + cfg.templateName = templateName - // Resolve template URL and subdirectory path - repoURL, ref, subPath, err := resolveTemplateSource(cCtx.String(common.TemplateFlag.Name), cCtx.String(common.TemplateVersionFlag.Name), language) + // Resolve template details from catalog + catalog, err := template.FetchTemplateCatalog() if err != nil { - return err + return nil, fmt.Errorf("failed to fetch template catalog: %w", err) } - // Create project directory - if err := os.MkdirAll(name, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", name, err) + matchedTemplate, err := catalog.GetTemplate(templateName, language) + if err != nil { + return nil, err } - // Setup GitFetcher - contextLogger := common.LoggerFromContext(cCtx) - tracker := common.ProgressTrackerFromContext(cCtx.Context) - - fetcher := &template.GitFetcher{ - Client: template.NewGitClient(), - Config: template.GitFetcherConfig{ - Verbose: cCtx.Bool("verbose"), - }, - Logger: *logger.NewProgressLogger(contextLogger, tracker), + cfg.repoURL = template.DefaultTemplateRepo + cfg.ref = template.DefaultTemplateVersion + if versionFlag := cCtx.String(common.TemplateVersionFlag.Name); versionFlag != "" { + cfg.ref = versionFlag } + cfg.subPath = matchedTemplate.Path + + return cfg, nil +} - // Check if we should use local templates (for development) - if os.Getenv("EIGENX_USE_LOCAL_TEMPLATES") == "true" { - // First try EIGENX_TEMPLATES_PATH env var, then look for the eigenx-templates directory as a sibling directory - eigenxTemplatesPath := os.Getenv("EIGENX_TEMPLATES_PATH") +func populateProjectFromTemplate(cCtx *cli.Context, cfg *projectConfig) error { + // Handle local templates for development + if os.Getenv(envVarUseLocalTemplates) == "true" { + eigenxTemplatesPath := os.Getenv(envVarTemplatesPath) if eigenxTemplatesPath == "" { // Look for eigenx-templates as a sibling directory for _, path := range []string{"eigenx-templates", "../eigenx-templates"} { - if _, err := os.Stat(filepath.Join(path, "templates/minimal")); err == nil { + if _, err := os.Stat(filepath.Join(path, "templates")); err == nil { eigenxTemplatesPath = path break } @@ -124,106 +184,58 @@ func createAction(cCtx *cli.Context) error { } } - // Use local templates from the eigenx-templates repository - localTemplatePath := filepath.Join(eigenxTemplatesPath, "templates/minimal", language) + localTemplatePath := filepath.Join(eigenxTemplatesPath, cfg.subPath) if _, err := os.Stat(localTemplatePath); os.IsNotExist(err) { return fmt.Errorf("local template not found at %s", localTemplatePath) } - // Copy local template to project directory - err = copyDir(localTemplatePath, name) - if err != nil { - os.RemoveAll(name) + if err := copyDir(localTemplatePath, cfg.name); err != nil { return fmt.Errorf("failed to copy local template: %w", err) } - contextLogger.Info("Using local template from %s", localTemplatePath) - } else { - if subPath != "" { - // Fetch only the subdirectory, if one is specified - err = fetcher.FetchSubdirectory(context.Background(), repoURL, ref, subPath, name) - } else { - // Fetch the full repository - err = fetcher.Fetch(context.Background(), repoURL, ref, name) - } - if err != nil { - // Cleanup on failure - os.RemoveAll(name) - return fmt.Errorf("failed to create project from template: %w", err) - } - } - // Post-process only internal templates - if subPath != "" { - if err := postProcessTemplate(name, language); err != nil { - return fmt.Errorf("failed to post-process template: %w", err) - } + contextLogger := common.LoggerFromContext(cCtx) + contextLogger.Info("Using local template from %s", localTemplatePath) + return nil } - fmt.Printf("Successfully created %s project: %s\n", language, name) - return nil -} - -// validateProjectName validates that a project name is valid -func validateProjectName(name string) error { - if name == "" { - return fmt.Errorf("project name cannot be empty") - } - if strings.Contains(name, " ") { - return fmt.Errorf("project name cannot contain spaces") - } - return nil -} + // Fetch from remote repository + contextLogger := common.LoggerFromContext(cCtx) + tracker := common.ProgressTrackerFromContext(cCtx.Context) -// resolveTemplateSource determines the repository URL, ref, and subdirectory path for a template -func resolveTemplateSource(templateFlag, templateVersionFlag, language string) (string, string, string, error) { - if templateFlag != "" { - // Custom template URL provided via --template flag - ref := templateVersionFlag - if ref == "" { - ref = "main" - } - return templateFlag, ref, "", nil + fetcher := &template.GitFetcher{ + Client: template.NewGitClient(), + Config: template.GitFetcherConfig{ + Verbose: cCtx.Bool("verbose"), + }, + Logger: *logger.NewProgressLogger(contextLogger, tracker), } - // Use template configuration system for defaults - config, err := template.LoadConfig() - if err != nil { - return "", "", "", fmt.Errorf("failed to load template config: %w", err) + var err error + if cfg.subPath != "" { + err = fetcher.FetchSubdirectory(context.Background(), cfg.repoURL, cfg.ref, cfg.subPath, cfg.name) + } else { + err = fetcher.Fetch(context.Background(), cfg.repoURL, cfg.ref, cfg.name) } - // Get template URL and version from config for "tee" framework - templateURL, version, err := template.GetTemplateURLs(config, "tee", language) if err != nil { - return "", "", "", fmt.Errorf("failed to get template URLs: %w", err) - } - - // Override version if --template-version flag provided - ref := version - if templateVersionFlag != "" { - ref = templateVersionFlag + return fmt.Errorf("failed to create project from template: %w", err) } - // For templates from config, assume they follow our subdirectory structure - subPath := fmt.Sprintf("templates/minimal/%s", language) - return templateURL, ref, subPath, nil + return nil } -// postProcessTemplate updates template files with project-specific values func postProcessTemplate(projectDir, language string) error { projectName := filepath.Base(projectDir) templateName := fmt.Sprintf("eigenx-tee-%s-app", language) - // Copy .gitignore from config directory if err := copyGitignore(projectDir); err != nil { return fmt.Errorf("failed to copy .gitignore: %w", err) } - // Copy shared template files (.env.example) if err := copySharedTemplateFiles(projectDir); err != nil { return fmt.Errorf("failed to copy shared template files: %w", err) } - // Update README.md title for all languages if err := updateProjectFile(projectDir, "README.md", templateName, projectName); err != nil { return err } @@ -240,15 +252,14 @@ func postProcessTemplate(projectDir, language string) error { return nil } -// copySharedTemplateFiles copies shared template files to the project directory func copySharedTemplateFiles(projectDir string) error { - // Write .env.example from embedded string + // Write .env.example envPath := filepath.Join(projectDir, ".env.example") if err := os.WriteFile(envPath, []byte(config.EnvExample), 0644); err != nil { return fmt.Errorf("failed to write .env.example: %w", err) } - // Write or append README.md from embedded string + // Write or append README.md readmePath := filepath.Join(projectDir, "README.md") if _, err := os.Stat(readmePath); err == nil { // README.md exists, append the content @@ -258,7 +269,6 @@ func copySharedTemplateFiles(projectDir string) error { } defer file.Close() - // Add newline before appending if _, err := file.WriteString("\n" + config.ReadMe); err != nil { return fmt.Errorf("failed to append to README.md: %w", err) } @@ -272,54 +282,54 @@ func copySharedTemplateFiles(projectDir string) error { return nil } -// copyGitignore copies the .gitignore from embedded config to the project directory if it doesn't exist func copyGitignore(projectDir string) error { destPath := filepath.Join(projectDir, ".gitignore") - // Check if .gitignore already exists + // Skip if .gitignore already exists if _, err := os.Stat(destPath); err == nil { - return nil // File already exists, skip copying + return nil } - // Use embedded config .gitignore - err := os.WriteFile(destPath, []byte(config.GitIgnore), 0644) - if err != nil { + if err := os.WriteFile(destPath, []byte(config.GitIgnore), 0644); err != nil { return fmt.Errorf("failed to write .gitignore: %w", err) } return nil } -// updateProjectFile updates a project file by replacing a specific string func updateProjectFile(projectDir, filename, oldString, newString string) error { filePath := filepath.Join(projectDir, filename) - // Read current file content, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("failed to read %s: %w", filename, err) } - // Replace the specified string newContent := strings.ReplaceAll(string(content), oldString, newString) - // Write back to file - err = os.WriteFile(filePath, []byte(newContent), 0644) - if err != nil { + if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil { return fmt.Errorf("failed to update %s: %w", filename, err) } return nil } -// copyDir recursively copies a directory from src to dst +func validateProjectName(name string) error { + if name == "" { + return fmt.Errorf("project name cannot be empty") + } + if strings.Contains(name, " ") { + return fmt.Errorf("project name cannot contain spaces") + } + return nil +} + func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - // Calculate destination path relPath, err := filepath.Rel(src, path) if err != nil { return err @@ -327,16 +337,13 @@ func copyDir(src, dst string) error { dstPath := filepath.Join(dst, relPath) if info.IsDir() { - // Create directory return os.MkdirAll(dstPath, info.Mode()) } - // Copy file return copyFile(path, dstPath, info.Mode()) }) } -// copyFile copies a single file from src to dst func copyFile(src, dst string, mode os.FileMode) error { srcFile, err := os.Open(src) if err != nil { diff --git a/pkg/commands/utils/interactive.go b/pkg/commands/utils/interactive.go index 8286690..46acb7f 100644 --- a/pkg/commands/utils/interactive.go +++ b/pkg/commands/utils/interactive.go @@ -11,6 +11,7 @@ import ( "github.com/Layr-Labs/eigenx-cli/pkg/common" "github.com/Layr-Labs/eigenx-cli/pkg/common/output" + "github.com/Layr-Labs/eigenx-cli/pkg/template" "github.com/Layr-Labs/eigenx-contracts/pkg/bindings/v1/AppController" dockercommand "github.com/docker/cli/cli/command" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -25,6 +26,45 @@ type registryInfo struct { Type string // "dockerhub", "ghcr", "gcr", "other" } +// SelectTemplateInteractive prompts the user to select a template from the catalog +func SelectTemplateInteractive(language string) (string, error) { + // Fetch the template catalog + catalog, err := template.FetchTemplateCatalog() + if err != nil { + return "", fmt.Errorf("failed to fetch template catalog: %w", err) + } + + // Get category descriptions for the selected language + categoryDescriptions := catalog.GetCategoryDescriptions(language) + if len(categoryDescriptions) == 0 { + return "", fmt.Errorf("no templates found for language %s", language) + } + + // Build display options: "category: description" + var options []string + var categories []string + for category, description := range categoryDescriptions { + categories = append(categories, category) + if description != "" { + options = append(options, fmt.Sprintf("%s: %s", category, description)) + } else { + options = append(options, category) + } + } + + // Prompt user to select + selected, err := output.SelectString("Select template:", options) + if err != nil { + return "", err + } + + // Extract category from selected option (format: "category: description" or just "category") + category := strings.Split(selected, ":")[0] + category = strings.TrimSpace(category) + + return category, nil +} + // SelectRegistryInteractive provides interactive selection of registry for image reference func SelectRegistryInteractive(registries []registryInfo, imageName string, tag string, promptMessage string, validator func(string) error) (string, error) { // If we have registries, offer them as choices diff --git a/pkg/common/flags.go b/pkg/common/flags.go index 7d65f51..b4d083f 100644 --- a/pkg/common/flags.go +++ b/pkg/common/flags.go @@ -44,9 +44,9 @@ var ( Usage: "Path to Dockerfile", } - TemplateFlag = &cli.StringFlag{ - Name: "template", - Usage: "Template repository URL", + TemplateRepoFlag = &cli.StringFlag{ + Name: "template-repo", + Usage: "Custom template repository URL (for non-catalog templates)", } TemplateVersionFlag = &cli.StringFlag{ diff --git a/pkg/template/catalog.go b/pkg/template/catalog.go new file mode 100644 index 0000000..9045e0b --- /dev/null +++ b/pkg/template/catalog.go @@ -0,0 +1,207 @@ +package template + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + // Default repository URL for templates + DefaultTemplateRepo = "https://github.com/Layr-Labs/eigenx-templates" + + // Default version/branch for templates + DefaultTemplateVersion = "main" + + // Default catalog URL in the eigenx-templates repository + DefaultCatalogURL = "https://raw.githubusercontent.com/Layr-Labs/eigenx-templates/main/templates.json" + + // Cache duration for the catalog (15 minutes) + CatalogCacheDuration = 15 * time.Minute +) + +// TemplateEntry represents a single template in the catalog +type TemplateEntry struct { + Path string `json:"path"` + Description string `json:"description"` +} + +// TemplateCatalog represents the structure of templates.json +// Organized by language first, then by category (e.g., "typescript" -> "minimal") +type TemplateCatalog struct { + Languages map[string]map[string]TemplateEntry `json:"-"` + raw map[string]interface{} +} + +// UnmarshalJSON implements custom JSON unmarshaling to handle nested structure +func (tc *TemplateCatalog) UnmarshalJSON(data []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + tc.raw = raw + tc.Languages = make(map[string]map[string]TemplateEntry) + + for language, value := range raw { + // Re-marshal and unmarshal to convert to map[string]TemplateEntry + languageData, err := json.Marshal(value) + if err != nil { + continue + } + + var templates map[string]TemplateEntry + if err := json.Unmarshal(languageData, &templates); err != nil { + continue + } + + tc.Languages[language] = templates + } + + return nil +} + +// GetTemplate finds a template by language and category +func (tc *TemplateCatalog) GetTemplate(category, language string) (*TemplateEntry, error) { + templates, exists := tc.Languages[language] + if !exists { + return nil, fmt.Errorf("language %q not found in catalog", language) + } + + template, exists := templates[category] + if !exists { + return nil, fmt.Errorf("category %q not found for language %q", category, language) + } + + return &template, nil +} + +// GetCategoryDescriptions returns a map of category names to their descriptions for a given language +func (tc *TemplateCatalog) GetCategoryDescriptions(language string) map[string]string { + templates, exists := tc.Languages[language] + if !exists { + return nil + } + + descriptions := make(map[string]string) + for category, template := range templates { + descriptions[category] = template.Description + } + return descriptions +} + +// GetSupportedLanguages returns a list of all unique languages in the catalog +func (tc *TemplateCatalog) GetSupportedLanguages() []string { + var languages []string + for lang := range tc.Languages { + languages = append(languages, lang) + } + return languages +} + +// catalogCache holds the cached catalog and its expiration time +type catalogCache struct { + catalog *TemplateCatalog + expiresAt time.Time + mu sync.RWMutex +} + +var cache = &catalogCache{} + +// FetchTemplateCatalog fetches and parses the template catalog from the remote URL +// It uses a 15-minute in-memory cache to avoid excessive network requests +// If EIGENX_USE_LOCAL_TEMPLATES is set, it looks for a local templates.json file +func FetchTemplateCatalog() (*TemplateCatalog, error) { + // Check if using local templates + if os.Getenv("EIGENX_USE_LOCAL_TEMPLATES") == "true" { + return fetchLocalCatalog() + } + + // Check cache first + cache.mu.RLock() + if cache.catalog != nil && time.Now().Before(cache.expiresAt) { + defer cache.mu.RUnlock() + return cache.catalog, nil + } + cache.mu.RUnlock() + + // Fetch from remote + catalog, err := fetchRemoteCatalog(DefaultCatalogURL) + if err != nil { + return nil, err + } + + // Update cache + cache.mu.Lock() + cache.catalog = catalog + cache.expiresAt = time.Now().Add(CatalogCacheDuration) + cache.mu.Unlock() + + return catalog, nil +} + +// fetchRemoteCatalog fetches the catalog from a remote URL +func fetchRemoteCatalog(url string) (*TemplateCatalog, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch template catalog: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch template catalog: HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read template catalog: %w", err) + } + + var catalog TemplateCatalog + if err := json.Unmarshal(data, &catalog); err != nil { + return nil, fmt.Errorf("failed to parse template catalog: %w", err) + } + + return &catalog, nil +} + +// fetchLocalCatalog looks for a local templates.json file +func fetchLocalCatalog() (*TemplateCatalog, error) { + // Look for EIGENX_TEMPLATES_PATH first + templatesPath := os.Getenv("EIGENX_TEMPLATES_PATH") + if templatesPath == "" { + // Look for eigenx-templates directory as a sibling + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("failed to get current directory: %w", err) + } + + // Try sibling directory + templatesPath = filepath.Join(filepath.Dir(cwd), "eigenx-templates") + if _, err := os.Stat(templatesPath); os.IsNotExist(err) { + return nil, fmt.Errorf("local templates directory not found: %s", templatesPath) + } + } + + catalogPath := filepath.Join(templatesPath, "templates.json") + data, err := os.ReadFile(catalogPath) + if err != nil { + return nil, fmt.Errorf("failed to read local template catalog: %w", err) + } + + var catalog TemplateCatalog + if err := json.Unmarshal(data, &catalog); err != nil { + return nil, fmt.Errorf("failed to parse local template catalog: %w", err) + } + + return &catalog, nil +} diff --git a/pkg/template/config.go b/pkg/template/config.go deleted file mode 100644 index df0169a..0000000 --- a/pkg/template/config.go +++ /dev/null @@ -1,56 +0,0 @@ -package template - -import ( - "fmt" - - "github.com/Layr-Labs/eigenx-cli/config" - - "gopkg.in/yaml.v3" -) - -type Config struct { - Framework map[string]FrameworkSpec `yaml:"framework"` -} - -type FrameworkSpec struct { - Template string `yaml:"template"` - Version string `yaml:"version"` - Languages []string `yaml:"languages"` -} - -func LoadConfig() (*Config, error) { - // pull from embedded string - data := []byte(config.TemplatesYaml) - - var config Config - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, err - } - - return &config, nil -} - -// GetTemplateURLs returns template URL & version for the requested framework + language. -// Fails fast if the framework does not exist, the template URL is blank, or the -// language is not declared in the framework's Languages slice. -func GetTemplateURLs(config *Config, framework, lang string) (string, string, error) { - fw, ok := config.Framework[framework] - if !ok { - return "", "", fmt.Errorf("unknown framework %q", framework) - } - if fw.Template == "" { - return "", "", fmt.Errorf("template URL missing for framework %q", framework) - } - - // Language gate – only enforce if Languages slice is populated - if len(fw.Languages) != 0 { - for _, l := range fw.Languages { - if l == lang { - return fw.Template, fw.Version, nil - } - } - return "", "", fmt.Errorf("language %q not available for framework %q", lang, framework) - } - - return fw.Template, fw.Version, nil -} From 39eaae5656e440a4181be393d2393ba4040cb86b Mon Sep 17 00:00:00 2001 From: Solimander Date: Wed, 5 Nov 2025 14:58:34 -0800 Subject: [PATCH 2/9] refactor: simplify template selection by removing unused categories array --- pkg/commands/utils/interactive.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/commands/utils/interactive.go b/pkg/commands/utils/interactive.go index 46acb7f..0866672 100644 --- a/pkg/commands/utils/interactive.go +++ b/pkg/commands/utils/interactive.go @@ -42,9 +42,7 @@ func SelectTemplateInteractive(language string) (string, error) { // Build display options: "category: description" var options []string - var categories []string for category, description := range categoryDescriptions { - categories = append(categories, category) if description != "" { options = append(options, fmt.Sprintf("%s: %s", category, description)) } else { From 50b50dcc587d0b80993b6afc2726b02b66b8ec15 Mon Sep 17 00:00:00 2001 From: Solimander Date: Wed, 5 Nov 2025 15:14:55 -0800 Subject: [PATCH 3/9] remove dead code --- pkg/template/catalog.go | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/pkg/template/catalog.go b/pkg/template/catalog.go index 9045e0b..9cefc43 100644 --- a/pkg/template/catalog.go +++ b/pkg/template/catalog.go @@ -38,34 +38,6 @@ type TemplateCatalog struct { raw map[string]interface{} } -// UnmarshalJSON implements custom JSON unmarshaling to handle nested structure -func (tc *TemplateCatalog) UnmarshalJSON(data []byte) error { - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - - tc.raw = raw - tc.Languages = make(map[string]map[string]TemplateEntry) - - for language, value := range raw { - // Re-marshal and unmarshal to convert to map[string]TemplateEntry - languageData, err := json.Marshal(value) - if err != nil { - continue - } - - var templates map[string]TemplateEntry - if err := json.Unmarshal(languageData, &templates); err != nil { - continue - } - - tc.Languages[language] = templates - } - - return nil -} - // GetTemplate finds a template by language and category func (tc *TemplateCatalog) GetTemplate(category, language string) (*TemplateEntry, error) { templates, exists := tc.Languages[language] From 5c8dfcfed998df5ce3e66d42a5d67f522ba4fa75 Mon Sep 17 00:00:00 2001 From: Solimander Date: Wed, 5 Nov 2025 15:15:25 -0800 Subject: [PATCH 4/9] feat: add minimal template option to app creation workflow --- .github/workflows/create.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create.yml b/.github/workflows/create.yml index d1ee089..7b12875 100644 --- a/.github/workflows/create.yml +++ b/.github/workflows/create.yml @@ -67,7 +67,7 @@ jobs: - name: Run eigenx app create run: | cd /tmp - eigenx app create --disable-telemetry my-awesome-app ts + eigenx app create --disable-telemetry my-awesome-app ts minimal - name: Verify app project created run: | From 611595b3b34953b8ee2e2d34539561da70a1cf2d Mon Sep 17 00:00:00 2001 From: Solimander Date: Wed, 5 Nov 2025 15:21:05 -0800 Subject: [PATCH 5/9] re-add custom unmarshall --- pkg/template/catalog.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/template/catalog.go b/pkg/template/catalog.go index 9cefc43..9045e0b 100644 --- a/pkg/template/catalog.go +++ b/pkg/template/catalog.go @@ -38,6 +38,34 @@ type TemplateCatalog struct { raw map[string]interface{} } +// UnmarshalJSON implements custom JSON unmarshaling to handle nested structure +func (tc *TemplateCatalog) UnmarshalJSON(data []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + tc.raw = raw + tc.Languages = make(map[string]map[string]TemplateEntry) + + for language, value := range raw { + // Re-marshal and unmarshal to convert to map[string]TemplateEntry + languageData, err := json.Marshal(value) + if err != nil { + continue + } + + var templates map[string]TemplateEntry + if err := json.Unmarshal(languageData, &templates); err != nil { + continue + } + + tc.Languages[language] = templates + } + + return nil +} + // GetTemplate finds a template by language and category func (tc *TemplateCatalog) GetTemplate(category, language string) (*TemplateEntry, error) { templates, exists := tc.Languages[language] From b5e6ccaef90269c57886ee578e2328ad365688bd Mon Sep 17 00:00:00 2001 From: Solimander Date: Wed, 5 Nov 2025 15:24:43 -0800 Subject: [PATCH 6/9] style: fix typo in UnmarshalJSON comment --- pkg/template/catalog.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/template/catalog.go b/pkg/template/catalog.go index 9045e0b..0495cb6 100644 --- a/pkg/template/catalog.go +++ b/pkg/template/catalog.go @@ -38,7 +38,7 @@ type TemplateCatalog struct { raw map[string]interface{} } -// UnmarshalJSON implements custom JSON unmarshaling to handle nested structure +// UnmarshalJSON implements custom JSON unmarshalling to handle nested structure func (tc *TemplateCatalog) UnmarshalJSON(data []byte) error { var raw map[string]interface{} if err := json.Unmarshal(data, &raw); err != nil { From e1050b5ce0c018beb61f355be781a090fb9bde9a Mon Sep 17 00:00:00 2001 From: Solimander Date: Wed, 5 Nov 2025 15:43:57 -0800 Subject: [PATCH 7/9] refactor: move template env vars to template package --- pkg/commands/app/create.go | 11 +++-------- pkg/template/catalog.go | 8 ++++++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/pkg/commands/app/create.go b/pkg/commands/app/create.go index 97e13c7..34ae0b4 100644 --- a/pkg/commands/app/create.go +++ b/pkg/commands/app/create.go @@ -29,11 +29,6 @@ var CreateCommand = &cli.Command{ Action: createAction, } -const ( - envVarUseLocalTemplates = "EIGENX_USE_LOCAL_TEMPLATES" - envVarTemplatesPath = "EIGENX_TEMPLATES_PATH" -) - var ( primaryLanguages = []string{"typescript", "golang", "rust", "python"} @@ -169,8 +164,8 @@ func gatherProjectConfig(cCtx *cli.Context) (*projectConfig, error) { func populateProjectFromTemplate(cCtx *cli.Context, cfg *projectConfig) error { // Handle local templates for development - if os.Getenv(envVarUseLocalTemplates) == "true" { - eigenxTemplatesPath := os.Getenv(envVarTemplatesPath) + if os.Getenv(template.EnvVarUseLocalTemplates) == "true" { + eigenxTemplatesPath := os.Getenv(template.EnvVarTemplatesPath) if eigenxTemplatesPath == "" { // Look for eigenx-templates as a sibling directory for _, path := range []string{"eigenx-templates", "../eigenx-templates"} { @@ -180,7 +175,7 @@ func populateProjectFromTemplate(cCtx *cli.Context, cfg *projectConfig) error { } } if eigenxTemplatesPath == "" { - return fmt.Errorf("cannot find eigenx-templates directory. Set EIGENX_TEMPLATES_PATH or ensure eigenx-templates is a sibling directory") + return fmt.Errorf("cannot find eigenx-templates directory. Set %s or ensure eigenx-templates is a sibling directory", template.EnvVarTemplatesPath) } } diff --git a/pkg/template/catalog.go b/pkg/template/catalog.go index 0495cb6..18e5847 100644 --- a/pkg/template/catalog.go +++ b/pkg/template/catalog.go @@ -12,6 +12,10 @@ import ( ) const ( + // Environment variable names + EnvVarUseLocalTemplates = "EIGENX_USE_LOCAL_TEMPLATES" + EnvVarTemplatesPath = "EIGENX_TEMPLATES_PATH" + // Default repository URL for templates DefaultTemplateRepo = "https://github.com/Layr-Labs/eigenx-templates" @@ -118,7 +122,7 @@ var cache = &catalogCache{} // If EIGENX_USE_LOCAL_TEMPLATES is set, it looks for a local templates.json file func FetchTemplateCatalog() (*TemplateCatalog, error) { // Check if using local templates - if os.Getenv("EIGENX_USE_LOCAL_TEMPLATES") == "true" { + if os.Getenv(EnvVarUseLocalTemplates) == "true" { return fetchLocalCatalog() } @@ -177,7 +181,7 @@ func fetchRemoteCatalog(url string) (*TemplateCatalog, error) { // fetchLocalCatalog looks for a local templates.json file func fetchLocalCatalog() (*TemplateCatalog, error) { // Look for EIGENX_TEMPLATES_PATH first - templatesPath := os.Getenv("EIGENX_TEMPLATES_PATH") + templatesPath := os.Getenv(EnvVarTemplatesPath) if templatesPath == "" { // Look for eigenx-templates directory as a sibling cwd, err := os.Getwd() From 2245164247b7770963c520a30c43b0d5f0d588b9 Mon Sep 17 00:00:00 2001 From: Solimander Date: Wed, 5 Nov 2025 16:00:29 -0800 Subject: [PATCH 8/9] feat: enhance template post-processing with configurable file updates --- pkg/commands/app/create.go | 44 ++++++++++++++++---------------------- pkg/template/catalog.go | 9 ++++---- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/pkg/commands/app/create.go b/pkg/commands/app/create.go index 34ae0b4..4b25847 100644 --- a/pkg/commands/app/create.go +++ b/pkg/commands/app/create.go @@ -6,7 +6,6 @@ import ( "io" "os" "path/filepath" - "slices" "strings" "github.com/Layr-Labs/eigenx-cli/config" @@ -38,21 +37,16 @@ var ( "rs": "rust", "py": "python", } - - languageFiles = map[string][]string{ - "typescript": {"package.json"}, - "rust": {"Cargo.toml", "Dockerfile"}, - "golang": {"go.mod"}, - } ) type projectConfig struct { - name string - language string - templateName string - repoURL string - ref string - subPath string + name string + language string + templateName string + templateEntry *template.TemplateEntry + repoURL string + ref string + subPath string } func createAction(cCtx *cli.Context) error { @@ -77,7 +71,7 @@ func createAction(cCtx *cli.Context) error { } if cfg.subPath != "" { - if err := postProcessTemplate(cfg.name, cfg.language); err != nil { + if err := postProcessTemplate(cfg.name, cfg.language, cfg.templateEntry); err != nil { return fmt.Errorf("failed to post-process template: %w", err) } } @@ -124,9 +118,6 @@ func gatherProjectConfig(cCtx *cli.Context) (*projectConfig, error) { if fullName, exists := shortNames[language]; exists { language = fullName } - if !slices.Contains(primaryLanguages, language) { - return nil, fmt.Errorf("unsupported language: %s", language) - } } cfg.language = language @@ -152,6 +143,7 @@ func gatherProjectConfig(cCtx *cli.Context) (*projectConfig, error) { return nil, err } + cfg.templateEntry = matchedTemplate cfg.repoURL = template.DefaultTemplateRepo cfg.ref = template.DefaultTemplateVersion if versionFlag := cCtx.String(common.TemplateVersionFlag.Name); versionFlag != "" { @@ -219,7 +211,7 @@ func populateProjectFromTemplate(cCtx *cli.Context, cfg *projectConfig) error { return nil } -func postProcessTemplate(projectDir, language string) error { +func postProcessTemplate(projectDir, language string, templateEntry *template.TemplateEntry) error { projectName := filepath.Base(projectDir) templateName := fmt.Sprintf("eigenx-tee-%s-app", language) @@ -231,16 +223,16 @@ func postProcessTemplate(projectDir, language string) error { return fmt.Errorf("failed to copy shared template files: %w", err) } - if err := updateProjectFile(projectDir, "README.md", templateName, projectName); err != nil { - return err + // Get files to update from template metadata, fallback to just README.md + filesToUpdate := templateEntry.PostProcess.ReplaceNameIn + if len(filesToUpdate) == 0 { + filesToUpdate = []string{"README.md"} } - // Update language-specific project files - if filenames, exists := languageFiles[language]; exists { - for _, filename := range filenames { - if err := updateProjectFile(projectDir, filename, templateName, projectName); err != nil { - return err - } + // Update all files specified in template metadata + for _, filename := range filesToUpdate { + if err := updateProjectFile(projectDir, filename, templateName, projectName); err != nil { + return err } } diff --git a/pkg/template/catalog.go b/pkg/template/catalog.go index 18e5847..3c2d461 100644 --- a/pkg/template/catalog.go +++ b/pkg/template/catalog.go @@ -33,13 +33,15 @@ const ( type TemplateEntry struct { Path string `json:"path"` Description string `json:"description"` + PostProcess struct { + ReplaceNameIn []string `json:"replaceNameIn,omitempty"` + } `json:"postProcess,omitempty"` } // TemplateCatalog represents the structure of templates.json // Organized by language first, then by category (e.g., "typescript" -> "minimal") type TemplateCatalog struct { Languages map[string]map[string]TemplateEntry `json:"-"` - raw map[string]interface{} } // UnmarshalJSON implements custom JSON unmarshalling to handle nested structure @@ -49,19 +51,18 @@ func (tc *TemplateCatalog) UnmarshalJSON(data []byte) error { return err } - tc.raw = raw tc.Languages = make(map[string]map[string]TemplateEntry) for language, value := range raw { // Re-marshal and unmarshal to convert to map[string]TemplateEntry languageData, err := json.Marshal(value) if err != nil { - continue + return fmt.Errorf("failed to marshal language %q data: %w", language, err) } var templates map[string]TemplateEntry if err := json.Unmarshal(languageData, &templates); err != nil { - continue + return fmt.Errorf("failed to unmarshal templates for language %q: %w", language, err) } tc.Languages[language] = templates From bfae220563cbe743746e25f0cfee6569292a2574 Mon Sep 17 00:00:00 2001 From: Solimander Date: Wed, 5 Nov 2025 16:04:59 -0800 Subject: [PATCH 9/9] refactor: sort template categories alphabetically --- pkg/commands/utils/interactive.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/commands/utils/interactive.go b/pkg/commands/utils/interactive.go index 0866672..240f0f6 100644 --- a/pkg/commands/utils/interactive.go +++ b/pkg/commands/utils/interactive.go @@ -2,9 +2,11 @@ package utils import ( "fmt" + "maps" "math/big" "os" "path/filepath" + "slices" "sort" "strings" "time" @@ -40,9 +42,13 @@ func SelectTemplateInteractive(language string) (string, error) { return "", fmt.Errorf("no templates found for language %s", language) } - // Build display options: "category: description" + // Sort categories alphabetically for consistent ordering + categories := slices.Sorted(maps.Keys(categoryDescriptions)) + + // Build display options in sorted order: "category: description" var options []string - for category, description := range categoryDescriptions { + for _, category := range categories { + description := categoryDescriptions[category] if description != "" { options = append(options, fmt.Sprintf("%s: %s", category, description)) } else {