rdev/internal/adapter/templates/components.go
jordan a8c8a0a14d
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: add GCS-based persistent media storage, AI generation pipeline, and composable skeleton packages
Adds complete media storage pipeline with GCS presigned uploads, AI image/video/text generation
via queue-based workers, realtime SSE event streaming, and comprehensive skeleton packages
(storage, mediagen, textgen, generation, realtime, persona, routing, ai-client). Includes
security fixes for media delete authorization, nil pointer guards in handlers, video persistence
via download-then-upload, consistent signed URLs, and Image→ImageIcon rename to avoid DOM collision.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:29:09 -07:00

342 lines
9.0 KiB
Go

package templates
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// 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",
},
}
// 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
}
// RenderSkeletonToDir renders the monorepo skeleton template to a local directory.
// This is used for testing templates locally without needing Gitea.
func RenderSkeletonToDir(outputDir string, vars map[string]string) error {
templateDir := "templates/skeleton"
return 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
relPath = strings.TrimSuffix(relPath, ".tmpl")
// Create output path
outPath := filepath.Join(outputDir, relPath)
// Create parent directories
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", outPath, err)
}
// Write file
if err := os.WriteFile(outPath, []byte(interpolated), 0644); err != nil {
return fmt.Errorf("failed to write file %s: %w", outPath, err)
}
return nil
})
}
// RenderComponentToDir renders a component template to a local directory.
// The destPath is relative to outputDir (e.g., "services/my-api").
func RenderComponentToDir(outputDir, componentType, destPath string, vars map[string]string) error {
// Validate component type exists
found := false
for _, t := range availableComponentTemplates {
if t.Type == componentType {
found = true
break
}
}
if !found {
return fmt.Errorf("unknown component type: %s", componentType)
}
templateDir := "templates/components/" + componentType
return fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Skip .woodpecker.step.yml.tmpl - it's for CI insertion, not file creation
if strings.HasSuffix(path, ".woodpecker.step.yml.tmpl") {
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")
// Create output path under destPath
outPath := filepath.Join(outputDir, destPath, relPath)
// Create parent directories
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return fmt.Errorf("failed to create directory for %s: %w", outPath, err)
}
// Write file
if err := os.WriteFile(outPath, []byte(interpolated), 0644); err != nil {
return fmt.Errorf("failed to write file %s: %w", outPath, err)
}
return nil
})
}