All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
342 lines
9.0 KiB
Go
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
|
|
})
|
|
}
|