// Package templates provides embedded project templates for seeding new repos. // // Templates are embedded at compile time from the templates/ subdirectory. // Each template contains starter files with {{VAR}} placeholders that get // interpolated when seeding a repository. package templates import ( "context" "embed" "encoding/base64" "fmt" "io/fs" "log/slog" "path/filepath" "regexp" "strings" "code.gitea.io/sdk/gitea" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) //go:embed all:templates var templatesFS embed.FS // availableTemplates lists all supported project templates. var availableTemplates = []port.TemplateInfo{ {Name: "default", Description: "Basic project with Dockerfile", Stack: "generic"}, {Name: "astro-landing", Description: "Astro landing page with Tailwind", Stack: "astro"}, {Name: "go-api", Description: "Go REST API with chi router", Stack: "go"}, } // templateNameRegex validates template names (alphanumeric, dash only). var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) // Provider implements port.TemplateProvider using embedded templates // and the Gitea API to seed repositories. type Provider struct { giteaClient *gitea.Client logger *slog.Logger } // Ensure Provider implements TemplateProvider. var _ port.TemplateProvider = (*Provider)(nil) // NewProvider creates a new template provider. // giteaClient is used to create files in repositories. // logger is optional; if nil, slog.Default() is used. func NewProvider(giteaClient *gitea.Client, logger *slog.Logger) *Provider { if logger == nil { logger = slog.Default() } return &Provider{ giteaClient: giteaClient, logger: logger, } } // SeedRepo populates a repository with template files. func (p *Provider) SeedRepo(ctx context.Context, owner, repo, templateName string, vars map[string]string) error { // Check for context cancellation select { case <-ctx.Done(): return ctx.Err() default: } // Validate template name (prevent path traversal, enforce naming convention) if !isValidTemplateName(templateName) { return fmt.Errorf("invalid template name: %s (must be lowercase alphanumeric with dashes)", templateName) } // Validate template exists templateDir := "templates/" + templateName if _, err := templatesFS.ReadDir(templateDir); err != nil { return fmt.Errorf("template not found: %s", templateName) } p.logger.Info("seeding repo from template", "owner", owner, "repo", repo, "template", templateName, ) // Walk template directory and create files var filesCreated int err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } // Read file content content, err := templatesFS.ReadFile(path) if err != nil { return fmt.Errorf("failed to read template file %s: %w", path, err) } // Interpolate variables interpolated := interpolateVars(string(content), vars) // Calculate relative path from template root relPath, err := filepath.Rel(templateDir, path) if err != nil { return fmt.Errorf("failed to get relative path: %w", err) } // Strip .tmpl extension (allows embedding go.mod as go.mod.tmpl) relPath = strings.TrimSuffix(relPath, ".tmpl") // Create file in repo via Gitea API // Gitea expects base64-encoded content encodedContent := base64.StdEncoding.EncodeToString([]byte(interpolated)) // For empty repos (AutoInit: false), the first file must create the branch // using NewBranchName. Subsequent files use the existing branch. opts := gitea.CreateFileOptions{ Content: encodedContent, FileOptions: gitea.FileOptions{ Message: "Add " + relPath + " from template", }, } if filesCreated == 0 { // First file: create the main branch opts.NewBranchName = "main" } else { // Subsequent files: use existing main branch opts.BranchName = "main" } _, _, err = p.giteaClient.CreateFile(owner, repo, relPath, opts) if err != nil { return fmt.Errorf("failed to create file %s: %w", relPath, err) } filesCreated++ return nil }) if err != nil { return fmt.Errorf("failed to seed repo from template %s: %w", templateName, err) } p.logger.Info("repo seeded successfully", "owner", owner, "repo", repo, "template", templateName, "files_created", filesCreated, ) return nil } // isValidTemplateName validates that a template name is safe. func isValidTemplateName(name string) bool { if name == "" || len(name) > 64 { return false } return templateNameRegex.MatchString(name) } // ListTemplates returns available templates. func (p *Provider) ListTemplates(ctx context.Context) ([]port.TemplateInfo, error) { // Check for context cancellation select { case <-ctx.Done(): return nil, ctx.Err() default: } result := make([]port.TemplateInfo, len(availableTemplates)) for i, t := range availableTemplates { result[i] = t // Populate files list files, err := listTemplateFiles(t.Name) if err == nil { result[i].Files = files } } return result, nil } // GetTemplate returns info about a specific template. func (p *Provider) GetTemplate(ctx context.Context, name string) (*port.TemplateInfo, error) { // Check for context cancellation select { case <-ctx.Done(): return nil, ctx.Err() default: } for _, t := range availableTemplates { if t.Name == name { result := t files, err := listTemplateFiles(name) if err == nil { result.Files = files } return &result, nil } } return nil, fmt.Errorf("%w: %s", domain.ErrTemplateNotFound, name) } // interpolateVars replaces {{VAR_NAME}} placeholders with values. func interpolateVars(content string, vars map[string]string) string { result := content for key, value := range vars { placeholder := "{{" + key + "}}" result = strings.ReplaceAll(result, placeholder, value) } return result } // listTemplateFiles returns the list of files in a template. func listTemplateFiles(templateName string) ([]string, error) { templateDir := "templates/" + templateName var files []string err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } relPath, err := filepath.Rel(templateDir, path) if err != nil { return err } // Strip .tmpl extension for display relPath = strings.TrimSuffix(relPath, ".tmpl") files = append(files, relPath) return nil }) return files, err }