rdev/internal/adapter/templates/provider.go
jordan c86516c53a feat: Add multi-domain support with auto-generated slugs for landing page cookbook
Landing page cookbook implementation (Weeks 1-4):

Domain Infrastructure:
- Add project_domains table with migration (013_project_domains.sql)
- Add ProjectDomain model with domain types (primary_auto, primary_custom, alias)
- Add SlugGenerator and ProjectDomainRepository interfaces
- Implement postgres adapters for domain and slug management

Service Layer:
- Add domain CRUD methods to ProjectInfraService
- Generate 8-char random slugs for auto-domains
- Support custom subdomains during project creation
- Add site_live health check to project status
- Trigger CI build after template seeding

Handler Updates:
- Add DomainService interface and adapter pattern
- Rewrite domain handlers to use database-backed service
- Add proper error handling for duplicate/missing domains

CI Integration:
- Add TriggerBuild to CIProvider interface
- Implement TriggerBuild in Woodpecker adapter
- Manually trigger initial build after template seed

Cookbook & Scripts:
- Add landing-test.sh script for E2E testing
- Add release.sh for version releases
- Add logs.sh for quick log access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:55:59 -07:00

242 lines
6.4 KiB
Go

// 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
}