// Package builders provides build implementations for different project types. package builders import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "forge.lthn.ai/core/cli/pkg/build" "forge.lthn.ai/core/cli/pkg/io" ) // TaskfileBuilder builds projects using Taskfile (https://taskfile.dev/). // This is a generic builder that can handle any project type that has a Taskfile. type TaskfileBuilder struct{} // NewTaskfileBuilder creates a new Taskfile builder. func NewTaskfileBuilder() *TaskfileBuilder { return &TaskfileBuilder{} } // Name returns the builder's identifier. func (b *TaskfileBuilder) Name() string { return "taskfile" } // Detect checks if a Taskfile exists in the directory. func (b *TaskfileBuilder) Detect(fs io.Medium, dir string) (bool, error) { // Check for Taskfile.yml, Taskfile.yaml, or Taskfile taskfiles := []string{ "Taskfile.yml", "Taskfile.yaml", "Taskfile", "taskfile.yml", "taskfile.yaml", } for _, tf := range taskfiles { if fs.IsFile(filepath.Join(dir, tf)) { return true, nil } } return false, nil } // Build runs the Taskfile build task for each target platform. func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { // Validate task CLI is available if err := b.validateTaskCli(); err != nil { return nil, err } // Create output directory outputDir := cfg.OutputDir if outputDir == "" { outputDir = filepath.Join(cfg.ProjectDir, "dist") } if err := cfg.FS.EnsureDir(outputDir); err != nil { return nil, fmt.Errorf("taskfile.Build: failed to create output directory: %w", err) } var artifacts []build.Artifact // If no targets specified, just run the build task once if len(targets) == 0 { if err := b.runTask(ctx, cfg, "", ""); err != nil { return nil, err } // Try to find artifacts in output directory found := b.findArtifacts(cfg.FS, outputDir) artifacts = append(artifacts, found...) } else { // Run build task for each target for _, target := range targets { if err := b.runTask(ctx, cfg, target.OS, target.Arch); err != nil { return nil, err } // Try to find artifacts for this target found := b.findArtifactsForTarget(cfg.FS, outputDir, target) artifacts = append(artifacts, found...) } } return artifacts, nil } // runTask executes the Taskfile build task. func (b *TaskfileBuilder) runTask(ctx context.Context, cfg *build.Config, goos, goarch string) error { // Build task command args := []string{"build"} // Pass variables if targets are specified if goos != "" { args = append(args, fmt.Sprintf("GOOS=%s", goos)) } if goarch != "" { args = append(args, fmt.Sprintf("GOARCH=%s", goarch)) } if cfg.OutputDir != "" { args = append(args, fmt.Sprintf("OUTPUT_DIR=%s", cfg.OutputDir)) } if cfg.Name != "" { args = append(args, fmt.Sprintf("NAME=%s", cfg.Name)) } if cfg.Version != "" { args = append(args, fmt.Sprintf("VERSION=%s", cfg.Version)) } cmd := exec.CommandContext(ctx, "task", args...) cmd.Dir = cfg.ProjectDir cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr // Set environment variables cmd.Env = os.Environ() if goos != "" { cmd.Env = append(cmd.Env, fmt.Sprintf("GOOS=%s", goos)) } if goarch != "" { cmd.Env = append(cmd.Env, fmt.Sprintf("GOARCH=%s", goarch)) } if cfg.OutputDir != "" { cmd.Env = append(cmd.Env, fmt.Sprintf("OUTPUT_DIR=%s", cfg.OutputDir)) } if cfg.Name != "" { cmd.Env = append(cmd.Env, fmt.Sprintf("NAME=%s", cfg.Name)) } if cfg.Version != "" { cmd.Env = append(cmd.Env, fmt.Sprintf("VERSION=%s", cfg.Version)) } if goos != "" && goarch != "" { fmt.Printf("Running task build for %s/%s\n", goos, goarch) } else { fmt.Println("Running task build") } if err := cmd.Run(); err != nil { return fmt.Errorf("taskfile.Build: task build failed: %w", err) } return nil } // findArtifacts searches for built artifacts in the output directory. func (b *TaskfileBuilder) findArtifacts(fs io.Medium, outputDir string) []build.Artifact { var artifacts []build.Artifact entries, err := fs.List(outputDir) if err != nil { return artifacts } for _, entry := range entries { if entry.IsDir() { continue } // Skip common non-artifact files name := entry.Name() if strings.HasPrefix(name, ".") || name == "CHECKSUMS.txt" { continue } artifacts = append(artifacts, build.Artifact{ Path: filepath.Join(outputDir, name), OS: "", Arch: "", }) } return artifacts } // findArtifactsForTarget searches for built artifacts for a specific target. func (b *TaskfileBuilder) findArtifactsForTarget(fs io.Medium, outputDir string, target build.Target) []build.Artifact { var artifacts []build.Artifact // 1. Look for platform-specific subdirectory: output/os_arch/ platformSubdir := filepath.Join(outputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch)) if fs.IsDir(platformSubdir) { entries, _ := fs.List(platformSubdir) for _, entry := range entries { if entry.IsDir() { // Handle .app bundles on macOS if target.OS == "darwin" && strings.HasSuffix(entry.Name(), ".app") { artifacts = append(artifacts, build.Artifact{ Path: filepath.Join(platformSubdir, entry.Name()), OS: target.OS, Arch: target.Arch, }) } continue } // Skip hidden files if strings.HasPrefix(entry.Name(), ".") { continue } artifacts = append(artifacts, build.Artifact{ Path: filepath.Join(platformSubdir, entry.Name()), OS: target.OS, Arch: target.Arch, }) } if len(artifacts) > 0 { return artifacts } } // 2. Look for files matching the target pattern in the root output dir patterns := []string{ fmt.Sprintf("*-%s-%s*", target.OS, target.Arch), fmt.Sprintf("*_%s_%s*", target.OS, target.Arch), fmt.Sprintf("*-%s*", target.Arch), } for _, pattern := range patterns { entries, _ := fs.List(outputDir) for _, entry := range entries { match := entry.Name() // Simple glob matching if b.matchPattern(match, pattern) { fullPath := filepath.Join(outputDir, match) if fs.IsDir(fullPath) { continue } artifacts = append(artifacts, build.Artifact{ Path: fullPath, OS: target.OS, Arch: target.Arch, }) } } if len(artifacts) > 0 { break // Found matches, stop looking } } return artifacts } // matchPattern implements glob matching for Taskfile artifacts. func (b *TaskfileBuilder) matchPattern(name, pattern string) bool { matched, _ := filepath.Match(pattern, name) return matched } // validateTaskCli checks if the task CLI is available. func (b *TaskfileBuilder) validateTaskCli() error { // Check PATH first if _, err := exec.LookPath("task"); err == nil { return nil } // Check common locations paths := []string{ "/usr/local/bin/task", "/opt/homebrew/bin/task", } for _, p := range paths { if _, err := os.Stat(p); err == nil { return nil } } return fmt.Errorf("taskfile: task CLI not found. Install with: brew install go-task (macOS), go install github.com/go-task/task/v3/cmd/task@latest, or see https://taskfile.dev/installation/") }