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: | 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..4b25847 100644 --- a/pkg/commands/app/create.go +++ b/pkg/commands/app/create.go @@ -6,10 +6,10 @@ import ( "io" "os" "path/filepath" - "slices" "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,235 +20,233 @@ 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"} +var ( + primaryLanguages = []string{"typescript", "golang", "rust", "python"} -var shortNames = map[string]string{ - "ts": "typescript", - "go": "golang", - "rs": "rust", - "py": "python", -} + shortNames = map[string]string{ + "ts": "typescript", + "go": "golang", + "rs": "rust", + "py": "python", + } +) -var languageFiles = map[string][]string{ - "typescript": {"package.json"}, - "rust": {"Cargo.toml", "Dockerfile"}, - "golang": {"go.mod"}, +type projectConfig struct { + name string + language string + templateName string + templateEntry *template.TemplateEntry + 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, cfg.templateEntry); 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) } } - - // Check if directory exists - if _, err := os.Stat(name); err == nil { - return fmt.Errorf("directory %s already exists", name) + 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 } - // 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 + } + } + 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.templateEntry = matchedTemplate + 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(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"} { - if _, err := os.Stat(filepath.Join(path, "templates/minimal")); err == nil { + if _, err := os.Stat(filepath.Join(path, "templates")); err == nil { eigenxTemplatesPath = path break } } 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) } } - // 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) + return fmt.Errorf("failed to create project from template: %w", err) } - // Override version if --template-version flag provided - ref := version - if templateVersionFlag != "" { - ref = templateVersionFlag - } - - // 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 { +func postProcessTemplate(projectDir, language string, templateEntry *template.TemplateEntry) 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 + // 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 } } 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 +256,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 +269,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 +324,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..240f0f6 100644 --- a/pkg/commands/utils/interactive.go +++ b/pkg/commands/utils/interactive.go @@ -2,15 +2,18 @@ package utils import ( "fmt" + "maps" "math/big" "os" "path/filepath" + "slices" "sort" "strings" "time" "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 +28,47 @@ 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) + } + + // 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 := range categories { + description := categoryDescriptions[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..3c2d461 --- /dev/null +++ b/pkg/template/catalog.go @@ -0,0 +1,212 @@ +package template + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "time" +) + +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" + + // 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"` + 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:"-"` +} + +// 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 { + return err + } + + 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 { + 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 { + return fmt.Errorf("failed to unmarshal templates for language %q: %w", language, err) + } + + 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(EnvVarUseLocalTemplates) == "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(EnvVarTemplatesPath) + 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 -}