2026-01-29 00:32:04 +00:00
// Package builders provides build implementations for different project types.
package builders
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/build"
2026-02-04 17:59:10 +00:00
"github.com/host-uk/core/pkg/io"
2026-01-29 00:32:04 +00:00
)
// 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.
2026-02-04 17:59:10 +00:00
func ( b * TaskfileBuilder ) Detect ( fs io . Medium , dir string ) ( bool , error ) {
2026-01-29 00:32:04 +00:00
// Check for Taskfile.yml, Taskfile.yaml, or Taskfile
taskfiles := [ ] string {
"Taskfile.yml" ,
"Taskfile.yaml" ,
"Taskfile" ,
"taskfile.yml" ,
"taskfile.yaml" ,
}
for _ , tf := range taskfiles {
2026-02-04 17:59:10 +00:00
if fs . IsFile ( filepath . Join ( dir , tf ) ) {
2026-01-29 00:32:04 +00:00
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" )
}
2026-02-04 17:59:10 +00:00
if err := cfg . FS . EnsureDir ( outputDir ) ; err != nil {
2026-01-29 00:32:04 +00:00
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
2026-02-04 17:59:10 +00:00
found := b . findArtifacts ( cfg . FS , outputDir )
2026-01-29 00:32:04 +00:00
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
2026-02-04 17:59:10 +00:00
found := b . findArtifactsForTarget ( cfg . FS , outputDir , target )
2026-01-29 00:32:04 +00:00
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.
2026-02-04 17:59:10 +00:00
func ( b * TaskfileBuilder ) findArtifacts ( fs io . Medium , outputDir string ) [ ] build . Artifact {
2026-01-29 00:32:04 +00:00
var artifacts [ ] build . Artifact
2026-02-04 17:59:10 +00:00
entries , err := fs . List ( outputDir )
2026-01-29 00:32:04 +00:00
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.
2026-02-04 17:59:10 +00:00
func ( b * TaskfileBuilder ) findArtifactsForTarget ( fs io . Medium , outputDir string , target build . Target ) [ ] build . Artifact {
2026-01-29 00:32:04 +00:00
var artifacts [ ] build . Artifact
2026-01-29 14:28:23 +00:00
// 1. Look for platform-specific subdirectory: output/os_arch/
platformSubdir := filepath . Join ( outputDir , fmt . Sprintf ( "%s_%s" , target . OS , target . Arch ) )
2026-02-04 17:59:10 +00:00
if fs . IsDir ( platformSubdir ) {
entries , _ := fs . List ( platformSubdir )
2026-01-29 14:28:23 +00:00
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
2026-01-29 00:32:04 +00:00
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 {
2026-02-04 17:59:10 +00:00
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
}
2026-01-29 00:32:04 +00:00
2026-02-04 17:59:10 +00:00
artifacts = append ( artifacts , build . Artifact {
Path : fullPath ,
OS : target . OS ,
Arch : target . Arch ,
} )
}
2026-01-29 00:32:04 +00:00
}
if len ( artifacts ) > 0 {
break // Found matches, stop looking
}
}
return artifacts
}
2026-02-04 17:59:10 +00:00
// matchPattern implements glob matching for Taskfile artifacts.
func ( b * TaskfileBuilder ) matchPattern ( name , pattern string ) bool {
matched , _ := filepath . Match ( pattern , name )
return matched
}
2026-01-29 00:32:04 +00:00
// 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/" )
}