Weeks 1-7 of the template upgrade plan: - pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders - skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client - skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware) - components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth - cookbooks/feature-development.md with test and validation scripts - Handler tests for components, project management, and woodpecker webhook - 3 rounds of code review fixes applied Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
495 lines
14 KiB
Go
495 lines
14 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.
|
|
//
|
|
// Single-Commit Seeding:
|
|
// Template files are created in a single commit using Gitea's bulk file API
|
|
// (POST /repos/{owner}/{repo}/contents). This prevents multiple CI pipeline
|
|
// triggers that would occur with per-file commits.
|
|
package templates
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
giteaadapter "github.com/orchard9/rdev/internal/adapter/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"},
|
|
}
|
|
|
|
// skeletonTemplate is the monorepo skeleton template used for composable projects.
|
|
var skeletonTemplate = port.TemplateInfo{
|
|
Name: "skeleton",
|
|
Description: "Composable monorepo skeleton with services, workers, apps, and CLI directories",
|
|
Stack: "monorepo",
|
|
}
|
|
|
|
// availableComponentTemplates lists all supported component templates.
|
|
var availableComponentTemplates = []port.ComponentTemplateInfo{
|
|
{
|
|
Type: "service",
|
|
Description: "Go API service using pkg/ shared packages",
|
|
Stack: "go",
|
|
DefaultPort: 8080,
|
|
DestDir: "services",
|
|
},
|
|
{
|
|
Type: "worker",
|
|
Description: "Go background worker for async job processing",
|
|
Stack: "go",
|
|
DefaultPort: 0, // Workers don't expose ports
|
|
DestDir: "workers",
|
|
},
|
|
{
|
|
Type: "app-astro",
|
|
Description: "Astro landing page with Tailwind CSS",
|
|
Stack: "astro",
|
|
DefaultPort: 4321,
|
|
DestDir: "apps",
|
|
},
|
|
{
|
|
Type: "app-react",
|
|
Description: "React SPA with Vite, TypeScript, and Tailwind",
|
|
Stack: "react",
|
|
DefaultPort: 5173,
|
|
DestDir: "apps",
|
|
},
|
|
{
|
|
Type: "app-nextjs",
|
|
Description: "Next.js 14 dashboard with App Router and design system",
|
|
Stack: "nextjs",
|
|
DefaultPort: 3000,
|
|
DestDir: "apps",
|
|
},
|
|
{
|
|
Type: "cli",
|
|
Description: "Go CLI tool using Cobra",
|
|
Stack: "go",
|
|
DefaultPort: 0, // CLIs don't expose ports
|
|
DestDir: "cli",
|
|
},
|
|
}
|
|
|
|
// 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 {
|
|
bulkClient *giteaadapter.BulkFileClient
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// Ensure Provider implements TemplateProvider.
|
|
var _ port.TemplateProvider = (*Provider)(nil)
|
|
|
|
// NewProvider creates a new template provider.
|
|
// giteaURL is the Gitea server URL (e.g., https://git.threesix.ai)
|
|
// giteaToken is an API access token with repo permissions
|
|
// logger is optional; if nil, slog.Default() is used.
|
|
func NewProvider(giteaURL, giteaToken string, logger *slog.Logger) *Provider {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Provider{
|
|
bulkClient: giteaadapter.NewBulkFileClient(giteaURL, giteaToken),
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// SeedRepo populates a repository with template files in a single commit.
|
|
// All template files are collected and committed atomically using Gitea's
|
|
// bulk file API, preventing multiple CI pipeline triggers.
|
|
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,
|
|
)
|
|
|
|
// Collect all template files
|
|
var fileOps []giteaadapter.ChangeFileOperation
|
|
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")
|
|
|
|
// Gitea expects base64-encoded content
|
|
encodedContent := base64.StdEncoding.EncodeToString([]byte(interpolated))
|
|
|
|
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
|
|
Operation: "create",
|
|
Path: relPath,
|
|
Content: encodedContent,
|
|
})
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to collect template files: %w", err)
|
|
}
|
|
|
|
if len(fileOps) == 0 {
|
|
return fmt.Errorf("template %s contains no files", templateName)
|
|
}
|
|
|
|
// Create all files in a single commit
|
|
opts := giteaadapter.ChangeFilesOptions{
|
|
Files: fileOps,
|
|
Message: fmt.Sprintf("Initialize project from %s template", templateName),
|
|
NewBranchName: "main", // Create the main branch (repo is empty)
|
|
}
|
|
|
|
_, err = p.bulkClient.ChangeFiles(ctx, owner, repo, opts)
|
|
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", len(fileOps),
|
|
"commit_message", opts.Message,
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
// SeedSkeleton populates a repository with the monorepo skeleton template.
|
|
// This creates the base monorepo structure without any components.
|
|
func (p *Provider) SeedSkeleton(ctx context.Context, owner, repo string, vars map[string]string) error {
|
|
return p.SeedRepo(ctx, owner, repo, "skeleton", vars)
|
|
}
|
|
|
|
// GetSkeleton returns info about the monorepo skeleton template.
|
|
func (p *Provider) GetSkeleton(ctx context.Context) (*port.TemplateInfo, error) {
|
|
// Check for context cancellation
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
result := skeletonTemplate
|
|
files, err := listTemplateFiles("skeleton")
|
|
if err == nil {
|
|
result.Files = files
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// GetComponentTemplate returns info about a specific component template.
|
|
func (p *Provider) GetComponentTemplate(ctx context.Context, componentType string) (*port.ComponentTemplateInfo, error) {
|
|
// Check for context cancellation
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
for _, t := range availableComponentTemplates {
|
|
if t.Type == componentType {
|
|
result := t
|
|
files, err := listComponentTemplateFiles(componentType)
|
|
if err == nil {
|
|
result.Files = files
|
|
}
|
|
return &result, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
|
|
}
|
|
|
|
// ListComponentTemplates returns available component templates.
|
|
// If componentType is empty, returns all templates; otherwise filters by type.
|
|
func (p *Provider) ListComponentTemplates(ctx context.Context, componentType string) ([]port.ComponentTemplateInfo, error) {
|
|
// Check for context cancellation
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
var result []port.ComponentTemplateInfo
|
|
for _, t := range availableComponentTemplates {
|
|
if componentType != "" && t.Type != componentType {
|
|
continue
|
|
}
|
|
info := t
|
|
files, err := listComponentTemplateFiles(t.Type)
|
|
if err == nil {
|
|
info.Files = files
|
|
}
|
|
result = append(result, info)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetComponentFiles returns the files for a component template with variables interpolated.
|
|
func (p *Provider) GetComponentFiles(ctx context.Context, componentType string, destPath string, vars map[string]string) ([]port.ComponentFile, error) {
|
|
// Check for context cancellation
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Validate component type exists
|
|
found := false
|
|
for _, t := range availableComponentTemplates {
|
|
if t.Type == componentType {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("%w: component type %s", domain.ErrTemplateNotFound, componentType)
|
|
}
|
|
|
|
templateDir := "templates/components/" + componentType
|
|
var files []port.ComponentFile
|
|
|
|
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 component template file %s: %w", path, err)
|
|
}
|
|
|
|
// Interpolate variables
|
|
interpolated := interpolateVars(string(content), vars)
|
|
|
|
// Calculate relative path from component template root
|
|
relPath, err := filepath.Rel(templateDir, path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get relative path: %w", err)
|
|
}
|
|
|
|
// Strip .tmpl extension
|
|
relPath = strings.TrimSuffix(relPath, ".tmpl")
|
|
|
|
// Prepend destination path
|
|
fullPath := filepath.Join(destPath, relPath)
|
|
|
|
files = append(files, port.ComponentFile{
|
|
Path: fullPath,
|
|
Content: interpolated,
|
|
})
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to collect component template files: %w", err)
|
|
}
|
|
|
|
if len(files) == 0 {
|
|
return nil, fmt.Errorf("component template %s contains no files", componentType)
|
|
}
|
|
|
|
p.logger.Debug("prepared component files",
|
|
"component_type", componentType,
|
|
"dest_path", destPath,
|
|
"file_count", len(files),
|
|
)
|
|
|
|
return files, nil
|
|
}
|
|
|
|
// listComponentTemplateFiles returns the list of files in a component template.
|
|
func listComponentTemplateFiles(componentType string) ([]string, error) {
|
|
templateDir := "templates/components/" + componentType
|
|
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
|
|
}
|
|
|
|
// GetComponentWoodpeckerStep returns the .woodpecker.step.yml content for a component.
|
|
// This is the CI step that should be inserted into the main .woodpecker.yml file.
|
|
func (p *Provider) GetComponentWoodpeckerStep(ctx context.Context, componentType string, vars map[string]string) (string, error) {
|
|
// Check for context cancellation
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
default:
|
|
}
|
|
|
|
stepPath := "templates/components/" + componentType + "/.woodpecker.step.yml.tmpl"
|
|
content, err := templatesFS.ReadFile(stepPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read woodpecker step template: %w", err)
|
|
}
|
|
|
|
return interpolateVars(string(content), vars), nil
|
|
}
|