feat(build): implement core build system with cross-compilation
Add pkg/build package replacing goreleaser with native build system: - Project discovery (go.mod, wails.json, package.json, composer.json) - Go cross-compilation with GOOS/GOARCH, CGO_ENABLED=0, ldflags - Config loading from .core/build.yaml with sensible defaults - Archive creation (tar.gz for linux/darwin, zip for windows) - SHA256 checksum generation with CHECKSUMS.txt CLI integration via `core build`: - Auto-detect project type or specify with --type - Cross-compile with --targets (e.g., linux/amd64,darwin/arm64) - CI mode with --ci for JSON output - Archive/checksum flags (--archive, --checksum) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d6f1a86f2
commit
9fe47a9bc6
34 changed files with 2675 additions and 373 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
wails3
|
||||
build/
|
||||
!pkg/build
|
||||
!cmd/core-gui/build
|
||||
cmd/core-gui/build/bin
|
||||
.task
|
||||
|
|
@ -28,3 +29,6 @@ cmd/lthn-desktop/frontend.old/dist/
|
|||
!cmd/core-demo/build
|
||||
cmd/core-demo/build/bin/
|
||||
cmd/core-demo/public/dist/
|
||||
|
||||
# core build artifacts
|
||||
dist/
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
# Minimal Goreleaser configuration for host-uk/core
|
||||
# No functional changes to the codebase, just release automation.
|
||||
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
- id: core-cli
|
||||
main: ./cmd/core
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w
|
||||
mod_timestamp: '{{ .CommitDate }}'
|
||||
binary: core
|
||||
|
||||
- id: core-gui
|
||||
# Wails GUI apps often require CGO and platform specifics; we keep a minimal build here.
|
||||
# If GUI builds need special handling, they can be adjusted later.
|
||||
main: ./cmd/core-gui
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
goos:
|
||||
- darwin
|
||||
- windows
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w
|
||||
mod_timestamp: '{{ .CommitDate }}'
|
||||
binary: core-gui
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
builds:
|
||||
- core-cli
|
||||
- core-gui
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
files:
|
||||
- LICENSE.txt
|
||||
- README.md
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
dockers: []
|
||||
|
||||
nfpms: []
|
||||
|
||||
changelog:
|
||||
use: github
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: host-uk
|
||||
name: core
|
||||
draft: false
|
||||
prerelease: auto
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
|
@ -10,22 +11,74 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/pkg/build"
|
||||
"github.com/host-uk/core/pkg/build/builders"
|
||||
"github.com/leaanthony/clir"
|
||||
"github.com/leaanthony/debme"
|
||||
"github.com/leaanthony/gosod"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// Build command styles
|
||||
var (
|
||||
buildHeaderStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
||||
|
||||
buildTargetStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
|
||||
|
||||
buildSuccessStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||
|
||||
buildErrorStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#ef4444")) // red-500
|
||||
|
||||
buildDimStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||
)
|
||||
|
||||
//go:embed all:tmpl/gui
|
||||
var guiTemplate embed.FS
|
||||
|
||||
// AddBuildCommand adds the new build command and its subcommands to the clir app.
|
||||
func AddBuildCommand(app *clir.Cli) {
|
||||
buildCmd := app.NewSubCommand("build", "Builds a web application into a standalone desktop app.")
|
||||
buildCmd := app.NewSubCommand("build", "Build projects with auto-detection and cross-compilation")
|
||||
buildCmd.LongDescription("Builds the current project with automatic type detection.\n" +
|
||||
"Supports Go, Wails, Node.js, and PHP projects.\n" +
|
||||
"Configuration can be provided via .core/build.yaml or command-line flags.")
|
||||
|
||||
// --- `build from-path` command ---
|
||||
// Flags for the main build command
|
||||
var buildType string
|
||||
var ciMode bool
|
||||
var targets string
|
||||
var outputDir string
|
||||
var doArchive bool
|
||||
var doChecksum bool
|
||||
|
||||
buildCmd.StringFlag("type", "Builder type (go, wails, node, php) - auto-detected if not specified", &buildType)
|
||||
buildCmd.BoolFlag("ci", "CI mode - minimal output with JSON artifact list at the end", &ciMode)
|
||||
buildCmd.StringFlag("targets", "Comma-separated OS/arch pairs (e.g., linux/amd64,darwin/arm64)", &targets)
|
||||
buildCmd.StringFlag("output", "Output directory for artifacts (default: dist)", &outputDir)
|
||||
buildCmd.BoolFlag("archive", "Create archives (tar.gz for linux/darwin, zip for windows) - default: true", &doArchive)
|
||||
buildCmd.BoolFlag("checksum", "Generate SHA256 checksums and CHECKSUMS.txt - default: true", &doChecksum)
|
||||
|
||||
// Set defaults for archive and checksum (true by default)
|
||||
doArchive = true
|
||||
doChecksum = true
|
||||
|
||||
// Default action for `core build` (no subcommand)
|
||||
buildCmd.Action(func() error {
|
||||
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum)
|
||||
})
|
||||
|
||||
// --- `build from-path` command (legacy PWA/GUI build) ---
|
||||
fromPathCmd := buildCmd.NewSubCommand("from-path", "Build from a local directory.")
|
||||
var fromPath string
|
||||
fromPathCmd.StringFlag("path", "The path to the static web application files.", &fromPath)
|
||||
|
|
@ -36,7 +89,7 @@ func AddBuildCommand(app *clir.Cli) {
|
|||
return runBuild(fromPath)
|
||||
})
|
||||
|
||||
// --- `build pwa` command ---
|
||||
// --- `build pwa` command (legacy PWA build) ---
|
||||
pwaCmd := buildCmd.NewSubCommand("pwa", "Build from a live PWA URL.")
|
||||
var pwaURL string
|
||||
pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL)
|
||||
|
|
@ -48,6 +101,319 @@ func AddBuildCommand(app *clir.Cli) {
|
|||
})
|
||||
}
|
||||
|
||||
// runProjectBuild handles the main `core build` command with auto-detection.
|
||||
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool) error {
|
||||
// Get current working directory as project root
|
||||
projectDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
|
||||
// Load configuration from .core/build.yaml (or defaults)
|
||||
buildCfg, err := build.LoadConfig(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
// Detect project type if not specified
|
||||
var projectType build.ProjectType
|
||||
if buildType != "" {
|
||||
projectType = build.ProjectType(buildType)
|
||||
} else {
|
||||
projectType, err = build.PrimaryType(projectDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to detect project type: %w", err)
|
||||
}
|
||||
if projectType == "" {
|
||||
return fmt.Errorf("no supported project type detected in %s\n"+
|
||||
"Supported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)", projectDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine targets
|
||||
var buildTargets []build.Target
|
||||
if targetsFlag != "" {
|
||||
// Parse from command line
|
||||
buildTargets, err = parseTargets(targetsFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if len(buildCfg.Targets) > 0 {
|
||||
// Use config targets
|
||||
buildTargets = buildCfg.ToTargets()
|
||||
} else {
|
||||
// Fall back to current OS/arch
|
||||
buildTargets = []build.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
}
|
||||
|
||||
// Determine output directory
|
||||
if outputDir == "" {
|
||||
outputDir = "dist"
|
||||
}
|
||||
|
||||
// Determine binary name
|
||||
binaryName := buildCfg.Project.Binary
|
||||
if binaryName == "" {
|
||||
binaryName = buildCfg.Project.Name
|
||||
}
|
||||
if binaryName == "" {
|
||||
binaryName = filepath.Base(projectDir)
|
||||
}
|
||||
|
||||
// Print build info (unless CI mode)
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Building project\n", buildHeaderStyle.Render("Build:"))
|
||||
fmt.Printf(" Type: %s\n", buildTargetStyle.Render(string(projectType)))
|
||||
fmt.Printf(" Output: %s\n", buildTargetStyle.Render(outputDir))
|
||||
fmt.Printf(" Binary: %s\n", buildTargetStyle.Render(binaryName))
|
||||
fmt.Printf(" Targets: %s\n", buildTargetStyle.Render(formatTargets(buildTargets)))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Get the appropriate builder
|
||||
builder, err := getBuilder(projectType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create build config for the builder
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: binaryName,
|
||||
Version: buildCfg.Project.Name, // Could be enhanced with git describe
|
||||
LDFlags: buildCfg.Build.LDFlags,
|
||||
}
|
||||
|
||||
// Execute build
|
||||
ctx := context.Background()
|
||||
artifacts, err := builder.Build(ctx, cfg, buildTargets)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Build failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Built %d artifact(s)\n", buildSuccessStyle.Render("Success:"), len(artifacts))
|
||||
fmt.Println()
|
||||
for _, artifact := range artifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Archive artifacts if enabled
|
||||
var archivedArtifacts []build.Artifact
|
||||
if doArchive && len(artifacts) > 0 {
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Creating archives...\n", buildHeaderStyle.Render("Archive:"))
|
||||
}
|
||||
|
||||
archivedArtifacts, err = build.ArchiveAll(artifacts)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Archive failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
for _, artifact := range archivedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute checksums if enabled
|
||||
var checksummedArtifacts []build.Artifact
|
||||
if doChecksum && len(archivedArtifacts) > 0 {
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
|
||||
}
|
||||
|
||||
checksummedArtifacts, err = build.ChecksumAll(archivedArtifacts)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Write CHECKSUMS.txt
|
||||
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
|
||||
if err := build.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
for _, artifact := range checksummedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
)
|
||||
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
|
||||
}
|
||||
|
||||
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
|
||||
if err != nil {
|
||||
relChecksumPath = checksumPath
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relChecksumPath),
|
||||
)
|
||||
}
|
||||
} else if doChecksum && len(artifacts) > 0 && !doArchive {
|
||||
// Checksum raw binaries if archiving is disabled
|
||||
if !ciMode {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
|
||||
}
|
||||
|
||||
checksummedArtifacts, err = build.ChecksumAll(artifacts)
|
||||
if err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Write CHECKSUMS.txt
|
||||
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
|
||||
if err := build.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
|
||||
if !ciMode {
|
||||
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !ciMode {
|
||||
for _, artifact := range checksummedArtifacts {
|
||||
relPath, err := filepath.Rel(projectDir, artifact.Path)
|
||||
if err != nil {
|
||||
relPath = artifact.Path
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relPath),
|
||||
)
|
||||
fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum))
|
||||
}
|
||||
|
||||
relChecksumPath, err := filepath.Rel(projectDir, checksumPath)
|
||||
if err != nil {
|
||||
relChecksumPath = checksumPath
|
||||
}
|
||||
fmt.Printf(" %s %s\n",
|
||||
buildSuccessStyle.Render("✓"),
|
||||
buildTargetStyle.Render(relChecksumPath),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Output results for CI mode
|
||||
if ciMode {
|
||||
// Determine which artifacts to output (prefer checksummed > archived > raw)
|
||||
var outputArtifacts []build.Artifact
|
||||
if len(checksummedArtifacts) > 0 {
|
||||
outputArtifacts = checksummedArtifacts
|
||||
} else if len(archivedArtifacts) > 0 {
|
||||
outputArtifacts = archivedArtifacts
|
||||
} else {
|
||||
outputArtifacts = artifacts
|
||||
}
|
||||
|
||||
// JSON output for CI
|
||||
output, err := json.MarshalIndent(outputArtifacts, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal artifacts: %w", err)
|
||||
}
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTargets parses a comma-separated list of OS/arch pairs.
|
||||
func parseTargets(targetsFlag string) ([]build.Target, error) {
|
||||
parts := strings.Split(targetsFlag, ",")
|
||||
var targets []build.Target
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
osArch := strings.Split(part, "/")
|
||||
if len(osArch) != 2 {
|
||||
return nil, fmt.Errorf("invalid target format %q, expected OS/arch (e.g., linux/amd64)", part)
|
||||
}
|
||||
|
||||
targets = append(targets, build.Target{
|
||||
OS: strings.TrimSpace(osArch[0]),
|
||||
Arch: strings.TrimSpace(osArch[1]),
|
||||
})
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return nil, fmt.Errorf("no valid targets specified")
|
||||
}
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// formatTargets returns a human-readable string of targets.
|
||||
func formatTargets(targets []build.Target) string {
|
||||
var parts []string
|
||||
for _, t := range targets {
|
||||
parts = append(parts, t.String())
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// getBuilder returns the appropriate builder for the project type.
|
||||
func getBuilder(projectType build.ProjectType) (build.Builder, error) {
|
||||
switch projectType {
|
||||
case build.ProjectTypeGo, build.ProjectTypeWails:
|
||||
return builders.NewGoBuilder(), nil
|
||||
case build.ProjectTypeNode:
|
||||
return nil, fmt.Errorf("Node.js builder not yet implemented")
|
||||
case build.ProjectTypePHP:
|
||||
return nil, fmt.Errorf("PHP builder not yet implemented")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported project type: %s", projectType)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PWA Build Logic ---
|
||||
|
||||
func runPwaBuild(pwaURL string) error {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ module github.com/host-uk/core/cmd/core
|
|||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/host-uk/core/pkg/git v0.0.0
|
||||
github.com/host-uk/core/pkg/repos v0.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be
|
||||
github.com/host-uk/core/pkg/build v0.0.0
|
||||
github.com/host-uk/core/pkg/cache v0.0.0-20260128153551-31712611be1c
|
||||
github.com/host-uk/core/pkg/git v0.0.0
|
||||
github.com/host-uk/core/pkg/repos v0.0.0
|
||||
github.com/leaanthony/clir v1.7.0
|
||||
github.com/leaanthony/debme v1.2.1
|
||||
github.com/leaanthony/gosod v1.0.4
|
||||
|
|
@ -38,6 +40,7 @@ require (
|
|||
|
||||
replace (
|
||||
github.com/host-uk/core => ../../
|
||||
github.com/host-uk/core/pkg/build => ../../pkg/build
|
||||
github.com/host-uk/core/pkg/git => ../../pkg/git
|
||||
github.com/host-uk/core/pkg/repos => ../../pkg/repos
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,27 @@
|
|||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ=
|
||||
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
|
||||
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/host-uk/core/pkg/cache v0.0.0-20260128153551-31712611be1c h1:VPqkoWMBZ1f+LG3wEGqbwybsfdGog2QFVVyJ2rT0+5I=
|
||||
github.com/host-uk/core/pkg/cache v0.0.0-20260128153551-31712611be1c/go.mod h1:jXl2UpzWLT/RK6Ufqxew+/JJqxDE03wvMHjmqQCTDP0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
|
|
@ -35,6 +44,9 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
|||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
|
||||
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
|
|
@ -43,7 +55,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
|
|
@ -51,6 +66,7 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
|
|
|
|||
1
go.work
1
go.work
|
|
@ -7,6 +7,7 @@ use (
|
|||
./cmd/core-mcp
|
||||
./cmd/examples/core-static-di
|
||||
./cmd/lthn-desktop
|
||||
./pkg/build
|
||||
./pkg/cache
|
||||
./pkg/config
|
||||
./pkg/core
|
||||
|
|
|
|||
191
pkg/build/archive.go
Normal file
191
pkg/build/archive.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// Package build provides project type detection and cross-compilation for the Core build system.
|
||||
package build
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Archive creates an archive for a single artifact.
|
||||
// Uses tar.gz for linux/darwin and zip for windows.
|
||||
// The archive is created alongside the binary (e.g., dist/myapp_linux_amd64.tar.gz).
|
||||
// Returns a new Artifact with Path pointing to the archive.
|
||||
func Archive(artifact Artifact) (Artifact, error) {
|
||||
if artifact.Path == "" {
|
||||
return Artifact{}, fmt.Errorf("build.Archive: artifact path is empty")
|
||||
}
|
||||
|
||||
// Verify the source file exists
|
||||
info, err := os.Stat(artifact.Path)
|
||||
if err != nil {
|
||||
return Artifact{}, fmt.Errorf("build.Archive: source file not found: %w", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return Artifact{}, fmt.Errorf("build.Archive: source path is a directory, expected file")
|
||||
}
|
||||
|
||||
// Determine archive type based on OS
|
||||
var archivePath string
|
||||
var archiveFunc func(src, dst string) error
|
||||
|
||||
if artifact.OS == "windows" {
|
||||
archivePath = archiveFilename(artifact, ".zip")
|
||||
archiveFunc = createZipArchive
|
||||
} else {
|
||||
archivePath = archiveFilename(artifact, ".tar.gz")
|
||||
archiveFunc = createTarGzArchive
|
||||
}
|
||||
|
||||
// Create the archive
|
||||
if err := archiveFunc(artifact.Path, archivePath); err != nil {
|
||||
return Artifact{}, fmt.Errorf("build.Archive: failed to create archive: %w", err)
|
||||
}
|
||||
|
||||
return Artifact{
|
||||
Path: archivePath,
|
||||
OS: artifact.OS,
|
||||
Arch: artifact.Arch,
|
||||
Checksum: artifact.Checksum,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ArchiveAll archives all artifacts.
|
||||
// Returns a slice of new artifacts pointing to the archives.
|
||||
func ArchiveAll(artifacts []Artifact) ([]Artifact, error) {
|
||||
if len(artifacts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var archived []Artifact
|
||||
for _, artifact := range artifacts {
|
||||
arch, err := Archive(artifact)
|
||||
if err != nil {
|
||||
return archived, fmt.Errorf("build.ArchiveAll: failed to archive %s: %w", artifact.Path, err)
|
||||
}
|
||||
archived = append(archived, arch)
|
||||
}
|
||||
|
||||
return archived, nil
|
||||
}
|
||||
|
||||
// archiveFilename generates the archive filename based on the artifact and extension.
|
||||
// Format: dist/myapp_linux_amd64.tar.gz (binary name taken from artifact path).
|
||||
func archiveFilename(artifact Artifact, ext string) string {
|
||||
// Get the directory containing the binary (e.g., dist/linux_amd64)
|
||||
dir := filepath.Dir(artifact.Path)
|
||||
// Go up one level to the output directory (e.g., dist)
|
||||
outputDir := filepath.Dir(dir)
|
||||
|
||||
// Get the binary name without extension
|
||||
binaryName := filepath.Base(artifact.Path)
|
||||
binaryName = strings.TrimSuffix(binaryName, ".exe")
|
||||
|
||||
// Construct archive name: myapp_linux_amd64.tar.gz
|
||||
archiveName := fmt.Sprintf("%s_%s_%s%s", binaryName, artifact.OS, artifact.Arch, ext)
|
||||
|
||||
return filepath.Join(outputDir, archiveName)
|
||||
}
|
||||
|
||||
// createTarGzArchive creates a tar.gz archive containing a single file.
|
||||
func createTarGzArchive(src, dst string) error {
|
||||
// Open the source file
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file: %w", err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
srcInfo, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat source file: %w", err)
|
||||
}
|
||||
|
||||
// Create the destination file
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create archive file: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// Create gzip writer
|
||||
gzWriter := gzip.NewWriter(dstFile)
|
||||
defer gzWriter.Close()
|
||||
|
||||
// Create tar writer
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
defer tarWriter.Close()
|
||||
|
||||
// Create tar header
|
||||
header, err := tar.FileInfoHeader(srcInfo, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tar header: %w", err)
|
||||
}
|
||||
// Use just the filename, not the full path
|
||||
header.Name = filepath.Base(src)
|
||||
|
||||
// Write header
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("failed to write tar header: %w", err)
|
||||
}
|
||||
|
||||
// Write file content
|
||||
if _, err := io.Copy(tarWriter, srcFile); err != nil {
|
||||
return fmt.Errorf("failed to write file content to tar: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createZipArchive creates a zip archive containing a single file.
|
||||
func createZipArchive(src, dst string) error {
|
||||
// Open the source file
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open source file: %w", err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
srcInfo, err := srcFile.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat source file: %w", err)
|
||||
}
|
||||
|
||||
// Create the destination file
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create archive file: %w", err)
|
||||
}
|
||||
defer dstFile.Close()
|
||||
|
||||
// Create zip writer
|
||||
zipWriter := zip.NewWriter(dstFile)
|
||||
defer zipWriter.Close()
|
||||
|
||||
// Create zip header
|
||||
header, err := zip.FileInfoHeader(srcInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create zip header: %w", err)
|
||||
}
|
||||
// Use just the filename, not the full path
|
||||
header.Name = filepath.Base(src)
|
||||
header.Method = zip.Deflate
|
||||
|
||||
// Create file in archive
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create zip entry: %w", err)
|
||||
}
|
||||
|
||||
// Write file content
|
||||
if _, err := io.Copy(writer, srcFile); err != nil {
|
||||
return fmt.Errorf("failed to write file content to zip: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
308
pkg/build/archive_test.go
Normal file
308
pkg/build/archive_test.go
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
package build
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupArchiveTestFile creates a test binary file in a temp directory with the standard structure.
|
||||
// Returns the path to the binary and the output directory.
|
||||
func setupArchiveTestFile(t *testing.T, name, os_, arch string) (binaryPath string, outputDir string) {
|
||||
t.Helper()
|
||||
|
||||
outputDir = t.TempDir()
|
||||
|
||||
// Create platform directory: dist/os_arch
|
||||
platformDir := filepath.Join(outputDir, os_+"_"+arch)
|
||||
err := os.MkdirAll(platformDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test binary
|
||||
binaryPath = filepath.Join(platformDir, name)
|
||||
content := []byte("#!/bin/bash\necho 'Hello, World!'\n")
|
||||
err = os.WriteFile(binaryPath, content, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
return binaryPath, outputDir
|
||||
}
|
||||
|
||||
func TestArchive_Good(t *testing.T) {
|
||||
t.Run("creates tar.gz for linux", func(t *testing.T) {
|
||||
binaryPath, outputDir := setupArchiveTestFile(t, "myapp", "linux", "amd64")
|
||||
|
||||
artifact := Artifact{
|
||||
Path: binaryPath,
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
result, err := Archive(artifact)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify archive was created
|
||||
expectedPath := filepath.Join(outputDir, "myapp_linux_amd64.tar.gz")
|
||||
assert.Equal(t, expectedPath, result.Path)
|
||||
assert.FileExists(t, result.Path)
|
||||
|
||||
// Verify OS and Arch are preserved
|
||||
assert.Equal(t, "linux", result.OS)
|
||||
assert.Equal(t, "amd64", result.Arch)
|
||||
|
||||
// Verify archive content
|
||||
verifyTarGzContent(t, result.Path, "myapp")
|
||||
})
|
||||
|
||||
t.Run("creates tar.gz for darwin", func(t *testing.T) {
|
||||
binaryPath, outputDir := setupArchiveTestFile(t, "myapp", "darwin", "arm64")
|
||||
|
||||
artifact := Artifact{
|
||||
Path: binaryPath,
|
||||
OS: "darwin",
|
||||
Arch: "arm64",
|
||||
}
|
||||
|
||||
result, err := Archive(artifact)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedPath := filepath.Join(outputDir, "myapp_darwin_arm64.tar.gz")
|
||||
assert.Equal(t, expectedPath, result.Path)
|
||||
assert.FileExists(t, result.Path)
|
||||
|
||||
verifyTarGzContent(t, result.Path, "myapp")
|
||||
})
|
||||
|
||||
t.Run("creates zip for windows", func(t *testing.T) {
|
||||
binaryPath, outputDir := setupArchiveTestFile(t, "myapp.exe", "windows", "amd64")
|
||||
|
||||
artifact := Artifact{
|
||||
Path: binaryPath,
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
result, err := Archive(artifact)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Windows archives should strip .exe from archive name
|
||||
expectedPath := filepath.Join(outputDir, "myapp_windows_amd64.zip")
|
||||
assert.Equal(t, expectedPath, result.Path)
|
||||
assert.FileExists(t, result.Path)
|
||||
|
||||
verifyZipContent(t, result.Path, "myapp.exe")
|
||||
})
|
||||
|
||||
t.Run("preserves checksum field", func(t *testing.T) {
|
||||
binaryPath, _ := setupArchiveTestFile(t, "myapp", "linux", "amd64")
|
||||
|
||||
artifact := Artifact{
|
||||
Path: binaryPath,
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
Checksum: "abc123",
|
||||
}
|
||||
|
||||
result, err := Archive(artifact)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "abc123", result.Checksum)
|
||||
})
|
||||
}
|
||||
|
||||
func TestArchive_Bad(t *testing.T) {
|
||||
t.Run("returns error for empty path", func(t *testing.T) {
|
||||
artifact := Artifact{
|
||||
Path: "",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
result, err := Archive(artifact)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "artifact path is empty")
|
||||
assert.Empty(t, result.Path)
|
||||
})
|
||||
|
||||
t.Run("returns error for non-existent file", func(t *testing.T) {
|
||||
artifact := Artifact{
|
||||
Path: "/nonexistent/path/binary",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
result, err := Archive(artifact)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "source file not found")
|
||||
assert.Empty(t, result.Path)
|
||||
})
|
||||
|
||||
t.Run("returns error for directory path", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
artifact := Artifact{
|
||||
Path: dir,
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
result, err := Archive(artifact)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "source path is a directory")
|
||||
assert.Empty(t, result.Path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestArchiveAll_Good(t *testing.T) {
|
||||
t.Run("archives multiple artifacts", func(t *testing.T) {
|
||||
outputDir := t.TempDir()
|
||||
|
||||
// Create multiple binaries
|
||||
var artifacts []Artifact
|
||||
targets := []struct {
|
||||
os_ string
|
||||
arch string
|
||||
}{
|
||||
{"linux", "amd64"},
|
||||
{"linux", "arm64"},
|
||||
{"darwin", "arm64"},
|
||||
{"windows", "amd64"},
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
platformDir := filepath.Join(outputDir, target.os_+"_"+target.arch)
|
||||
err := os.MkdirAll(platformDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
name := "myapp"
|
||||
if target.os_ == "windows" {
|
||||
name = "myapp.exe"
|
||||
}
|
||||
|
||||
binaryPath := filepath.Join(platformDir, name)
|
||||
err = os.WriteFile(binaryPath, []byte("binary content"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
artifacts = append(artifacts, Artifact{
|
||||
Path: binaryPath,
|
||||
OS: target.os_,
|
||||
Arch: target.arch,
|
||||
})
|
||||
}
|
||||
|
||||
results, err := ArchiveAll(artifacts)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 4)
|
||||
|
||||
// Verify all archives were created
|
||||
for i, result := range results {
|
||||
assert.FileExists(t, result.Path)
|
||||
assert.Equal(t, artifacts[i].OS, result.OS)
|
||||
assert.Equal(t, artifacts[i].Arch, result.Arch)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns nil for empty slice", func(t *testing.T) {
|
||||
results, err := ArchiveAll([]Artifact{})
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, results)
|
||||
})
|
||||
|
||||
t.Run("returns nil for nil slice", func(t *testing.T) {
|
||||
results, err := ArchiveAll(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, results)
|
||||
})
|
||||
}
|
||||
|
||||
func TestArchiveAll_Bad(t *testing.T) {
|
||||
t.Run("returns partial results on error", func(t *testing.T) {
|
||||
binaryPath, _ := setupArchiveTestFile(t, "myapp", "linux", "amd64")
|
||||
|
||||
artifacts := []Artifact{
|
||||
{Path: binaryPath, OS: "linux", Arch: "amd64"},
|
||||
{Path: "/nonexistent/binary", OS: "linux", Arch: "arm64"}, // This will fail
|
||||
}
|
||||
|
||||
results, err := ArchiveAll(artifacts)
|
||||
assert.Error(t, err)
|
||||
// Should have the first successful result
|
||||
assert.Len(t, results, 1)
|
||||
assert.FileExists(t, results[0].Path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestArchiveFilename_Good(t *testing.T) {
|
||||
t.Run("generates correct tar.gz filename", func(t *testing.T) {
|
||||
artifact := Artifact{
|
||||
Path: "/output/linux_amd64/myapp",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
filename := archiveFilename(artifact, ".tar.gz")
|
||||
assert.Equal(t, "/output/myapp_linux_amd64.tar.gz", filename)
|
||||
})
|
||||
|
||||
t.Run("generates correct zip filename", func(t *testing.T) {
|
||||
artifact := Artifact{
|
||||
Path: "/output/windows_amd64/myapp.exe",
|
||||
OS: "windows",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
filename := archiveFilename(artifact, ".zip")
|
||||
assert.Equal(t, "/output/myapp_windows_amd64.zip", filename)
|
||||
})
|
||||
|
||||
t.Run("handles nested output directories", func(t *testing.T) {
|
||||
artifact := Artifact{
|
||||
Path: "/project/dist/linux_arm64/cli",
|
||||
OS: "linux",
|
||||
Arch: "arm64",
|
||||
}
|
||||
|
||||
filename := archiveFilename(artifact, ".tar.gz")
|
||||
assert.Equal(t, "/project/dist/cli_linux_arm64.tar.gz", filename)
|
||||
})
|
||||
}
|
||||
|
||||
// verifyTarGzContent opens a tar.gz file and verifies it contains the expected file.
|
||||
func verifyTarGzContent(t *testing.T, archivePath, expectedName string) {
|
||||
t.Helper()
|
||||
|
||||
file, err := os.Open(archivePath)
|
||||
require.NoError(t, err)
|
||||
defer file.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(file)
|
||||
require.NoError(t, err)
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
header, err := tarReader.Next()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedName, header.Name)
|
||||
|
||||
// Verify there's only one file
|
||||
_, err = tarReader.Next()
|
||||
assert.Equal(t, io.EOF, err)
|
||||
}
|
||||
|
||||
// verifyZipContent opens a zip file and verifies it contains the expected file.
|
||||
func verifyZipContent(t *testing.T, archivePath, expectedName string) {
|
||||
t.Helper()
|
||||
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
require.Len(t, reader.File, 1)
|
||||
assert.Equal(t, expectedName, reader.File[0].Name)
|
||||
}
|
||||
61
pkg/build/build.go
Normal file
61
pkg/build/build.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Package build provides project type detection and cross-compilation for the Core build system.
|
||||
// It supports Go, Wails, Node.js, and PHP projects with automatic detection based on
|
||||
// marker files (go.mod, wails.json, package.json, composer.json).
|
||||
package build
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// ProjectType represents a detected project type.
|
||||
type ProjectType string
|
||||
|
||||
const (
|
||||
ProjectTypeGo ProjectType = "go"
|
||||
ProjectTypeWails ProjectType = "wails"
|
||||
ProjectTypeNode ProjectType = "node"
|
||||
ProjectTypePHP ProjectType = "php"
|
||||
)
|
||||
|
||||
// Target represents a build target platform.
|
||||
type Target struct {
|
||||
OS string
|
||||
Arch string
|
||||
}
|
||||
|
||||
// String returns the target in GOOS/GOARCH format.
|
||||
func (t Target) String() string {
|
||||
return t.OS + "/" + t.Arch
|
||||
}
|
||||
|
||||
// Artifact represents a build output file.
|
||||
type Artifact struct {
|
||||
Path string
|
||||
OS string
|
||||
Arch string
|
||||
Checksum string
|
||||
}
|
||||
|
||||
// Config holds build configuration.
|
||||
type Config struct {
|
||||
// ProjectDir is the root directory of the project.
|
||||
ProjectDir string
|
||||
// OutputDir is where build artifacts are placed.
|
||||
OutputDir string
|
||||
// Name is the output binary name.
|
||||
Name string
|
||||
// Version is the build version string.
|
||||
Version string
|
||||
// LDFlags are additional linker flags.
|
||||
LDFlags []string
|
||||
}
|
||||
|
||||
// Builder defines the interface for project-specific build implementations.
|
||||
type Builder interface {
|
||||
// Name returns the builder's identifier.
|
||||
Name() string
|
||||
// Detect checks if this builder can handle the project in the given directory.
|
||||
Detect(dir string) (bool, error)
|
||||
// Build compiles the project for the specified targets.
|
||||
Build(ctx context.Context, cfg *Config, targets []Target) ([]Artifact, error)
|
||||
}
|
||||
128
pkg/build/builders/go.go
Normal file
128
pkg/build/builders/go.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// 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"
|
||||
)
|
||||
|
||||
// GoBuilder implements the Builder interface for Go projects.
|
||||
type GoBuilder struct{}
|
||||
|
||||
// NewGoBuilder creates a new GoBuilder instance.
|
||||
func NewGoBuilder() *GoBuilder {
|
||||
return &GoBuilder{}
|
||||
}
|
||||
|
||||
// Name returns the builder's identifier.
|
||||
func (b *GoBuilder) Name() string {
|
||||
return "go"
|
||||
}
|
||||
|
||||
// Detect checks if this builder can handle the project in the given directory.
|
||||
// Uses IsGoProject from the build package which checks for go.mod or wails.json.
|
||||
func (b *GoBuilder) Detect(dir string) (bool, error) {
|
||||
return build.IsGoProject(dir), nil
|
||||
}
|
||||
|
||||
// Build compiles the Go project for the specified targets.
|
||||
// It sets GOOS, GOARCH, and CGO_ENABLED environment variables,
|
||||
// applies ldflags and trimpath, and runs go build.
|
||||
func (b *GoBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("builders.GoBuilder.Build: config is nil")
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return nil, fmt.Errorf("builders.GoBuilder.Build: no targets specified")
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("builders.GoBuilder.Build: failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
var artifacts []build.Artifact
|
||||
|
||||
for _, target := range targets {
|
||||
artifact, err := b.buildTarget(ctx, cfg, target)
|
||||
if err != nil {
|
||||
return artifacts, fmt.Errorf("builders.GoBuilder.Build: failed to build %s: %w", target.String(), err)
|
||||
}
|
||||
artifacts = append(artifacts, artifact)
|
||||
}
|
||||
|
||||
return artifacts, nil
|
||||
}
|
||||
|
||||
// buildTarget compiles for a single target platform.
|
||||
func (b *GoBuilder) buildTarget(ctx context.Context, cfg *build.Config, target build.Target) (build.Artifact, error) {
|
||||
// Determine output binary name
|
||||
binaryName := cfg.Name
|
||||
if binaryName == "" {
|
||||
binaryName = filepath.Base(cfg.ProjectDir)
|
||||
}
|
||||
|
||||
// Add .exe extension for Windows
|
||||
if target.OS == "windows" && !strings.HasSuffix(binaryName, ".exe") {
|
||||
binaryName += ".exe"
|
||||
}
|
||||
|
||||
// Create platform-specific output path: output/os_arch/binary
|
||||
platformDir := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch))
|
||||
if err := os.MkdirAll(platformDir, 0755); err != nil {
|
||||
return build.Artifact{}, fmt.Errorf("failed to create platform directory: %w", err)
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(platformDir, binaryName)
|
||||
|
||||
// Build the go build arguments
|
||||
args := []string{"build"}
|
||||
|
||||
// Add trimpath flag
|
||||
args = append(args, "-trimpath")
|
||||
|
||||
// Add ldflags if specified
|
||||
if len(cfg.LDFlags) > 0 {
|
||||
ldflags := strings.Join(cfg.LDFlags, " ")
|
||||
args = append(args, "-ldflags", ldflags)
|
||||
}
|
||||
|
||||
// Add output path
|
||||
args = append(args, "-o", outputPath)
|
||||
|
||||
// Add the project directory as the build target (current directory)
|
||||
args = append(args, ".")
|
||||
|
||||
// Create the command
|
||||
cmd := exec.CommandContext(ctx, "go", args...)
|
||||
cmd.Dir = cfg.ProjectDir
|
||||
|
||||
// Set up environment
|
||||
env := os.Environ()
|
||||
env = append(env, fmt.Sprintf("GOOS=%s", target.OS))
|
||||
env = append(env, fmt.Sprintf("GOARCH=%s", target.Arch))
|
||||
env = append(env, "CGO_ENABLED=0") // CGO disabled by default for cross-compilation
|
||||
cmd.Env = env
|
||||
|
||||
// Capture output for error messages
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return build.Artifact{}, fmt.Errorf("go build failed: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return build.Artifact{
|
||||
Path: outputPath,
|
||||
OS: target.OS,
|
||||
Arch: target.Arch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure GoBuilder implements the Builder interface.
|
||||
var _ build.Builder = (*GoBuilder)(nil)
|
||||
385
pkg/build/builders/go_test.go
Normal file
385
pkg/build/builders/go_test.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
package builders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/host-uk/core/pkg/build"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupGoTestProject creates a minimal Go project for testing.
|
||||
func setupGoTestProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create a minimal go.mod
|
||||
goMod := `module testproject
|
||||
|
||||
go 1.21
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a minimal main.go
|
||||
mainGo := `package main
|
||||
|
||||
func main() {
|
||||
println("hello")
|
||||
}
|
||||
`
|
||||
err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestGoBuilder_Name_Good(t *testing.T) {
|
||||
builder := NewGoBuilder()
|
||||
assert.Equal(t, "go", builder.Name())
|
||||
}
|
||||
|
||||
func TestGoBuilder_Detect_Good(t *testing.T) {
|
||||
t.Run("detects Go project with go.mod", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := NewGoBuilder()
|
||||
detected, err := builder.Detect(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, detected)
|
||||
})
|
||||
|
||||
t.Run("detects Wails project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := NewGoBuilder()
|
||||
detected, err := builder.Detect(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, detected)
|
||||
})
|
||||
|
||||
t.Run("returns false for non-Go project", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a Node.js project instead
|
||||
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := NewGoBuilder()
|
||||
detected, err := builder.Detect(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, detected)
|
||||
})
|
||||
|
||||
t.Run("returns false for empty directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
builder := NewGoBuilder()
|
||||
detected, err := builder.Detect(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, detected)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGoBuilder_Build_Good(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
t.Run("builds for current platform", func(t *testing.T) {
|
||||
projectDir := setupGoTestProject(t)
|
||||
outputDir := t.TempDir()
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: "testbinary",
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, artifacts, 1)
|
||||
|
||||
// Verify artifact properties
|
||||
artifact := artifacts[0]
|
||||
assert.Equal(t, runtime.GOOS, artifact.OS)
|
||||
assert.Equal(t, runtime.GOARCH, artifact.Arch)
|
||||
|
||||
// Verify binary was created
|
||||
assert.FileExists(t, artifact.Path)
|
||||
|
||||
// Verify the path is in the expected location
|
||||
expectedName := "testbinary"
|
||||
if runtime.GOOS == "windows" {
|
||||
expectedName += ".exe"
|
||||
}
|
||||
assert.Contains(t, artifact.Path, expectedName)
|
||||
})
|
||||
|
||||
t.Run("builds multiple targets", func(t *testing.T) {
|
||||
projectDir := setupGoTestProject(t)
|
||||
outputDir := t.TempDir()
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: "multitest",
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: "linux", Arch: "amd64"},
|
||||
{OS: "linux", Arch: "arm64"},
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, artifacts, 2)
|
||||
|
||||
// Verify both artifacts were created
|
||||
for i, artifact := range artifacts {
|
||||
assert.Equal(t, targets[i].OS, artifact.OS)
|
||||
assert.Equal(t, targets[i].Arch, artifact.Arch)
|
||||
assert.FileExists(t, artifact.Path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adds .exe extension for Windows", func(t *testing.T) {
|
||||
projectDir := setupGoTestProject(t)
|
||||
outputDir := t.TempDir()
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: "wintest",
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: "windows", Arch: "amd64"},
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, artifacts, 1)
|
||||
|
||||
// Verify .exe extension
|
||||
assert.True(t, filepath.Ext(artifacts[0].Path) == ".exe")
|
||||
assert.FileExists(t, artifacts[0].Path)
|
||||
})
|
||||
|
||||
t.Run("uses directory name when Name not specified", func(t *testing.T) {
|
||||
projectDir := setupGoTestProject(t)
|
||||
outputDir := t.TempDir()
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: "", // Empty name
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, artifacts, 1)
|
||||
|
||||
// Binary should use the project directory base name
|
||||
baseName := filepath.Base(projectDir)
|
||||
if runtime.GOOS == "windows" {
|
||||
baseName += ".exe"
|
||||
}
|
||||
assert.Contains(t, artifacts[0].Path, baseName)
|
||||
})
|
||||
|
||||
t.Run("applies ldflags", func(t *testing.T) {
|
||||
projectDir := setupGoTestProject(t)
|
||||
outputDir := t.TempDir()
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: "ldflagstest",
|
||||
LDFlags: []string{"-s", "-w"}, // Strip debug info
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, artifacts, 1)
|
||||
assert.FileExists(t, artifacts[0].Path)
|
||||
})
|
||||
|
||||
t.Run("creates output directory if missing", func(t *testing.T) {
|
||||
projectDir := setupGoTestProject(t)
|
||||
outputDir := filepath.Join(t.TempDir(), "nested", "output")
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: "nestedtest",
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, artifacts, 1)
|
||||
assert.FileExists(t, artifacts[0].Path)
|
||||
assert.DirExists(t, outputDir)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGoBuilder_Build_Bad(t *testing.T) {
|
||||
t.Run("returns error for nil config", func(t *testing.T) {
|
||||
builder := NewGoBuilder()
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), nil, []build.Target{{OS: "linux", Arch: "amd64"}})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, artifacts)
|
||||
assert.Contains(t, err.Error(), "config is nil")
|
||||
})
|
||||
|
||||
t.Run("returns error for empty targets", func(t *testing.T) {
|
||||
projectDir := setupGoTestProject(t)
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: t.TempDir(),
|
||||
Name: "test",
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, []build.Target{})
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, artifacts)
|
||||
assert.Contains(t, err.Error(), "no targets specified")
|
||||
})
|
||||
|
||||
t.Run("returns error for invalid project directory", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: "/nonexistent/path",
|
||||
OutputDir: t.TempDir(),
|
||||
Name: "test",
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, artifacts)
|
||||
})
|
||||
|
||||
t.Run("returns error for invalid Go code", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create go.mod
|
||||
err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\ngo 1.21"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create invalid Go code
|
||||
err = os.WriteFile(filepath.Join(dir, "main.go"), []byte("this is not valid go code"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: dir,
|
||||
OutputDir: t.TempDir(),
|
||||
Name: "test",
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "go build failed")
|
||||
assert.Empty(t, artifacts)
|
||||
})
|
||||
|
||||
t.Run("returns partial artifacts on partial failure", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Create a project that will fail on one target
|
||||
// Using an invalid arch for linux
|
||||
projectDir := setupGoTestProject(t)
|
||||
outputDir := t.TempDir()
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: outputDir,
|
||||
Name: "partialtest",
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH}, // This should succeed
|
||||
{OS: "linux", Arch: "invalid_arch"}, // This should fail
|
||||
}
|
||||
|
||||
artifacts, err := builder.Build(context.Background(), cfg, targets)
|
||||
// Should return error for the failed build
|
||||
assert.Error(t, err)
|
||||
// Should have the successful artifact
|
||||
assert.Len(t, artifacts, 1)
|
||||
})
|
||||
|
||||
t.Run("respects context cancellation", func(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
projectDir := setupGoTestProject(t)
|
||||
|
||||
builder := NewGoBuilder()
|
||||
cfg := &build.Config{
|
||||
ProjectDir: projectDir,
|
||||
OutputDir: t.TempDir(),
|
||||
Name: "canceltest",
|
||||
}
|
||||
targets := []build.Target{
|
||||
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
||||
}
|
||||
|
||||
// Create an already cancelled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
artifacts, err := builder.Build(ctx, cfg, targets)
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, artifacts)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGoBuilder_Interface_Good(t *testing.T) {
|
||||
// Verify GoBuilder implements Builder interface
|
||||
var _ build.Builder = (*GoBuilder)(nil)
|
||||
var _ build.Builder = NewGoBuilder()
|
||||
}
|
||||
102
pkg/build/checksum.go
Normal file
102
pkg/build/checksum.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Package build provides project type detection and cross-compilation for the Core build system.
|
||||
package build
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Checksum computes SHA256 for an artifact and returns the artifact with the Checksum field filled.
|
||||
func Checksum(artifact Artifact) (Artifact, error) {
|
||||
if artifact.Path == "" {
|
||||
return Artifact{}, fmt.Errorf("build.Checksum: artifact path is empty")
|
||||
}
|
||||
|
||||
// Open the file
|
||||
file, err := os.Open(artifact.Path)
|
||||
if err != nil {
|
||||
return Artifact{}, fmt.Errorf("build.Checksum: failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Compute SHA256 hash
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return Artifact{}, fmt.Errorf("build.Checksum: failed to hash file: %w", err)
|
||||
}
|
||||
|
||||
checksum := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
return Artifact{
|
||||
Path: artifact.Path,
|
||||
OS: artifact.OS,
|
||||
Arch: artifact.Arch,
|
||||
Checksum: checksum,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChecksumAll computes checksums for all artifacts.
|
||||
// Returns a slice of artifacts with their Checksum fields filled.
|
||||
func ChecksumAll(artifacts []Artifact) ([]Artifact, error) {
|
||||
if len(artifacts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var checksummed []Artifact
|
||||
for _, artifact := range artifacts {
|
||||
cs, err := Checksum(artifact)
|
||||
if err != nil {
|
||||
return checksummed, fmt.Errorf("build.ChecksumAll: failed to checksum %s: %w", artifact.Path, err)
|
||||
}
|
||||
checksummed = append(checksummed, cs)
|
||||
}
|
||||
|
||||
return checksummed, nil
|
||||
}
|
||||
|
||||
// WriteChecksumFile writes a CHECKSUMS.txt file with the format:
|
||||
//
|
||||
// sha256hash filename1
|
||||
// sha256hash filename2
|
||||
//
|
||||
// The artifacts should have their Checksum fields filled (call ChecksumAll first).
|
||||
// Filenames are relative to the output directory (just the basename).
|
||||
func WriteChecksumFile(artifacts []Artifact, path string) error {
|
||||
if len(artifacts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the content
|
||||
var lines []string
|
||||
for _, artifact := range artifacts {
|
||||
if artifact.Checksum == "" {
|
||||
return fmt.Errorf("build.WriteChecksumFile: artifact %s has no checksum", artifact.Path)
|
||||
}
|
||||
filename := filepath.Base(artifact.Path)
|
||||
lines = append(lines, fmt.Sprintf("%s %s", artifact.Checksum, filename))
|
||||
}
|
||||
|
||||
// Sort lines for consistent output
|
||||
sort.Strings(lines)
|
||||
|
||||
content := strings.Join(lines, "\n") + "\n"
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("build.WriteChecksumFile: failed to create directory: %w", err)
|
||||
}
|
||||
|
||||
// Write the file
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("build.WriteChecksumFile: failed to write file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
275
pkg/build/checksum_test.go
Normal file
275
pkg/build/checksum_test.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package build
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupChecksumTestFile creates a test file with known content.
|
||||
func setupChecksumTestFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "testfile")
|
||||
err := os.WriteFile(path, []byte(content), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func TestChecksum_Good(t *testing.T) {
|
||||
t.Run("computes SHA256 checksum", func(t *testing.T) {
|
||||
// Known SHA256 of "Hello, World!\n"
|
||||
path := setupChecksumTestFile(t, "Hello, World!\n")
|
||||
expectedChecksum := "c98c24b677eff44860afea6f493bbaec5bb1c4cbb209c6fc2bbb47f66ff2ad31"
|
||||
|
||||
artifact := Artifact{
|
||||
Path: path,
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
result, err := Checksum(artifact)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expectedChecksum, result.Checksum)
|
||||
})
|
||||
|
||||
t.Run("preserves artifact fields", func(t *testing.T) {
|
||||
path := setupChecksumTestFile(t, "test content")
|
||||
|
||||
artifact := Artifact{
|
||||
Path: path,
|
||||
OS: "darwin",
|
||||
Arch: "arm64",
|
||||
}
|
||||
|
||||
result, err := Checksum(artifact)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, path, result.Path)
|
||||
assert.Equal(t, "darwin", result.OS)
|
||||
assert.Equal(t, "arm64", result.Arch)
|
||||
assert.NotEmpty(t, result.Checksum)
|
||||
})
|
||||
|
||||
t.Run("produces 64 character hex string", func(t *testing.T) {
|
||||
path := setupChecksumTestFile(t, "any content")
|
||||
|
||||
artifact := Artifact{Path: path, OS: "linux", Arch: "amd64"}
|
||||
|
||||
result, err := Checksum(artifact)
|
||||
require.NoError(t, err)
|
||||
|
||||
// SHA256 produces 32 bytes = 64 hex characters
|
||||
assert.Len(t, result.Checksum, 64)
|
||||
})
|
||||
|
||||
t.Run("different content produces different checksums", func(t *testing.T) {
|
||||
path1 := setupChecksumTestFile(t, "content one")
|
||||
path2 := setupChecksumTestFile(t, "content two")
|
||||
|
||||
result1, err := Checksum(Artifact{Path: path1, OS: "linux", Arch: "amd64"})
|
||||
require.NoError(t, err)
|
||||
|
||||
result2, err := Checksum(Artifact{Path: path2, OS: "linux", Arch: "amd64"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, result1.Checksum, result2.Checksum)
|
||||
})
|
||||
|
||||
t.Run("same content produces same checksum", func(t *testing.T) {
|
||||
content := "identical content"
|
||||
path1 := setupChecksumTestFile(t, content)
|
||||
path2 := setupChecksumTestFile(t, content)
|
||||
|
||||
result1, err := Checksum(Artifact{Path: path1, OS: "linux", Arch: "amd64"})
|
||||
require.NoError(t, err)
|
||||
|
||||
result2, err := Checksum(Artifact{Path: path2, OS: "linux", Arch: "amd64"})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, result1.Checksum, result2.Checksum)
|
||||
})
|
||||
}
|
||||
|
||||
func TestChecksum_Bad(t *testing.T) {
|
||||
t.Run("returns error for empty path", func(t *testing.T) {
|
||||
artifact := Artifact{
|
||||
Path: "",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
result, err := Checksum(artifact)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "artifact path is empty")
|
||||
assert.Empty(t, result.Checksum)
|
||||
})
|
||||
|
||||
t.Run("returns error for non-existent file", func(t *testing.T) {
|
||||
artifact := Artifact{
|
||||
Path: "/nonexistent/path/file",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
}
|
||||
|
||||
result, err := Checksum(artifact)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to open file")
|
||||
assert.Empty(t, result.Checksum)
|
||||
})
|
||||
}
|
||||
|
||||
func TestChecksumAll_Good(t *testing.T) {
|
||||
t.Run("checksums multiple artifacts", func(t *testing.T) {
|
||||
paths := []string{
|
||||
setupChecksumTestFile(t, "content one"),
|
||||
setupChecksumTestFile(t, "content two"),
|
||||
setupChecksumTestFile(t, "content three"),
|
||||
}
|
||||
|
||||
artifacts := []Artifact{
|
||||
{Path: paths[0], OS: "linux", Arch: "amd64"},
|
||||
{Path: paths[1], OS: "darwin", Arch: "arm64"},
|
||||
{Path: paths[2], OS: "windows", Arch: "amd64"},
|
||||
}
|
||||
|
||||
results, err := ChecksumAll(artifacts)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 3)
|
||||
|
||||
for i, result := range results {
|
||||
assert.Equal(t, artifacts[i].Path, result.Path)
|
||||
assert.Equal(t, artifacts[i].OS, result.OS)
|
||||
assert.Equal(t, artifacts[i].Arch, result.Arch)
|
||||
assert.NotEmpty(t, result.Checksum)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns nil for empty slice", func(t *testing.T) {
|
||||
results, err := ChecksumAll([]Artifact{})
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, results)
|
||||
})
|
||||
|
||||
t.Run("returns nil for nil slice", func(t *testing.T) {
|
||||
results, err := ChecksumAll(nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, results)
|
||||
})
|
||||
}
|
||||
|
||||
func TestChecksumAll_Bad(t *testing.T) {
|
||||
t.Run("returns partial results on error", func(t *testing.T) {
|
||||
path := setupChecksumTestFile(t, "valid content")
|
||||
|
||||
artifacts := []Artifact{
|
||||
{Path: path, OS: "linux", Arch: "amd64"},
|
||||
{Path: "/nonexistent/file", OS: "linux", Arch: "arm64"}, // This will fail
|
||||
}
|
||||
|
||||
results, err := ChecksumAll(artifacts)
|
||||
assert.Error(t, err)
|
||||
// Should have the first successful result
|
||||
assert.Len(t, results, 1)
|
||||
assert.NotEmpty(t, results[0].Checksum)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteChecksumFile_Good(t *testing.T) {
|
||||
t.Run("writes checksum file with correct format", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
checksumPath := filepath.Join(dir, "CHECKSUMS.txt")
|
||||
|
||||
artifacts := []Artifact{
|
||||
{Path: "/output/app_linux_amd64.tar.gz", Checksum: "abc123def456", OS: "linux", Arch: "amd64"},
|
||||
{Path: "/output/app_darwin_arm64.tar.gz", Checksum: "789xyz000111", OS: "darwin", Arch: "arm64"},
|
||||
}
|
||||
|
||||
err := WriteChecksumFile(artifacts, checksumPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Read and verify content
|
||||
content, err := os.ReadFile(checksumPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
|
||||
require.Len(t, lines, 2)
|
||||
|
||||
// Lines should be sorted alphabetically
|
||||
assert.Equal(t, "789xyz000111 app_darwin_arm64.tar.gz", lines[0])
|
||||
assert.Equal(t, "abc123def456 app_linux_amd64.tar.gz", lines[1])
|
||||
})
|
||||
|
||||
t.Run("creates parent directories", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
checksumPath := filepath.Join(dir, "nested", "deep", "CHECKSUMS.txt")
|
||||
|
||||
artifacts := []Artifact{
|
||||
{Path: "/output/app.tar.gz", Checksum: "abc123", OS: "linux", Arch: "amd64"},
|
||||
}
|
||||
|
||||
err := WriteChecksumFile(artifacts, checksumPath)
|
||||
require.NoError(t, err)
|
||||
assert.FileExists(t, checksumPath)
|
||||
})
|
||||
|
||||
t.Run("does nothing for empty artifacts", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
checksumPath := filepath.Join(dir, "CHECKSUMS.txt")
|
||||
|
||||
err := WriteChecksumFile([]Artifact{}, checksumPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// File should not exist
|
||||
_, err = os.Stat(checksumPath)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
})
|
||||
|
||||
t.Run("does nothing for nil artifacts", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
checksumPath := filepath.Join(dir, "CHECKSUMS.txt")
|
||||
|
||||
err := WriteChecksumFile(nil, checksumPath)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("uses only basename for filenames", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
checksumPath := filepath.Join(dir, "CHECKSUMS.txt")
|
||||
|
||||
artifacts := []Artifact{
|
||||
{Path: "/some/deep/nested/path/myapp_linux_amd64.tar.gz", Checksum: "checksum123", OS: "linux", Arch: "amd64"},
|
||||
}
|
||||
|
||||
err := WriteChecksumFile(artifacts, checksumPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
content, err := os.ReadFile(checksumPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should only contain the basename
|
||||
assert.Contains(t, string(content), "myapp_linux_amd64.tar.gz")
|
||||
assert.NotContains(t, string(content), "/some/deep/nested/path/")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWriteChecksumFile_Bad(t *testing.T) {
|
||||
t.Run("returns error for artifact without checksum", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
checksumPath := filepath.Join(dir, "CHECKSUMS.txt")
|
||||
|
||||
artifacts := []Artifact{
|
||||
{Path: "/output/app.tar.gz", Checksum: "", OS: "linux", Arch: "amd64"}, // No checksum
|
||||
}
|
||||
|
||||
err := WriteChecksumFile(artifacts, checksumPath)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "has no checksum")
|
||||
})
|
||||
}
|
||||
161
pkg/build/config.go
Normal file
161
pkg/build/config.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// Package build provides project type detection and cross-compilation for the Core build system.
|
||||
// This file handles configuration loading from .core/build.yaml files.
|
||||
package build
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ConfigFileName is the name of the build configuration file.
|
||||
const ConfigFileName = "build.yaml"
|
||||
|
||||
// ConfigDir is the directory where build configuration is stored.
|
||||
const ConfigDir = ".core"
|
||||
|
||||
// BuildConfig holds the complete build configuration loaded from .core/build.yaml.
|
||||
// This is distinct from Config which holds runtime build parameters.
|
||||
type BuildConfig struct {
|
||||
// Version is the config file format version.
|
||||
Version int `yaml:"version"`
|
||||
// Project contains project metadata.
|
||||
Project Project `yaml:"project"`
|
||||
// Build contains build settings.
|
||||
Build Build `yaml:"build"`
|
||||
// Targets defines the build targets.
|
||||
Targets []TargetConfig `yaml:"targets"`
|
||||
}
|
||||
|
||||
// Project holds project metadata.
|
||||
type Project struct {
|
||||
// Name is the project name.
|
||||
Name string `yaml:"name"`
|
||||
// Description is a brief description of the project.
|
||||
Description string `yaml:"description"`
|
||||
// Main is the path to the main package (e.g., ./cmd/core).
|
||||
Main string `yaml:"main"`
|
||||
// Binary is the output binary name.
|
||||
Binary string `yaml:"binary"`
|
||||
}
|
||||
|
||||
// Build holds build-time settings.
|
||||
type Build struct {
|
||||
// CGO enables CGO for the build.
|
||||
CGO bool `yaml:"cgo"`
|
||||
// Flags are additional build flags (e.g., ["-trimpath"]).
|
||||
Flags []string `yaml:"flags"`
|
||||
// LDFlags are linker flags (e.g., ["-s", "-w"]).
|
||||
LDFlags []string `yaml:"ldflags"`
|
||||
// Env are additional environment variables.
|
||||
Env []string `yaml:"env"`
|
||||
}
|
||||
|
||||
// TargetConfig defines a build target in the config file.
|
||||
// This is separate from Target to allow for additional config-specific fields.
|
||||
type TargetConfig struct {
|
||||
// OS is the target operating system (e.g., "linux", "darwin", "windows").
|
||||
OS string `yaml:"os"`
|
||||
// Arch is the target architecture (e.g., "amd64", "arm64").
|
||||
Arch string `yaml:"arch"`
|
||||
}
|
||||
|
||||
// LoadConfig loads build configuration from the .core/build.yaml file in the given directory.
|
||||
// If the config file does not exist, it returns DefaultConfig().
|
||||
// Returns an error if the file exists but cannot be parsed.
|
||||
func LoadConfig(dir string) (*BuildConfig, error) {
|
||||
configPath := filepath.Join(dir, ConfigDir, ConfigFileName)
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return DefaultConfig(), nil
|
||||
}
|
||||
return nil, fmt.Errorf("build.LoadConfig: failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
var cfg BuildConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("build.LoadConfig: failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
// Apply defaults for any missing fields
|
||||
applyDefaults(&cfg)
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible defaults for Go projects.
|
||||
func DefaultConfig() *BuildConfig {
|
||||
return &BuildConfig{
|
||||
Version: 1,
|
||||
Project: Project{
|
||||
Name: "",
|
||||
Main: ".",
|
||||
Binary: "",
|
||||
},
|
||||
Build: Build{
|
||||
CGO: false,
|
||||
Flags: []string{"-trimpath"},
|
||||
LDFlags: []string{"-s", "-w"},
|
||||
Env: []string{},
|
||||
},
|
||||
Targets: []TargetConfig{
|
||||
{OS: "linux", Arch: "amd64"},
|
||||
{OS: "linux", Arch: "arm64"},
|
||||
{OS: "darwin", Arch: "amd64"},
|
||||
{OS: "darwin", Arch: "arm64"},
|
||||
{OS: "windows", Arch: "amd64"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// applyDefaults fills in default values for any empty fields in the config.
|
||||
func applyDefaults(cfg *BuildConfig) {
|
||||
defaults := DefaultConfig()
|
||||
|
||||
if cfg.Version == 0 {
|
||||
cfg.Version = defaults.Version
|
||||
}
|
||||
|
||||
if cfg.Project.Main == "" {
|
||||
cfg.Project.Main = defaults.Project.Main
|
||||
}
|
||||
|
||||
if cfg.Build.Flags == nil {
|
||||
cfg.Build.Flags = defaults.Build.Flags
|
||||
}
|
||||
|
||||
if cfg.Build.LDFlags == nil {
|
||||
cfg.Build.LDFlags = defaults.Build.LDFlags
|
||||
}
|
||||
|
||||
if cfg.Build.Env == nil {
|
||||
cfg.Build.Env = defaults.Build.Env
|
||||
}
|
||||
|
||||
if len(cfg.Targets) == 0 {
|
||||
cfg.Targets = defaults.Targets
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigPath returns the path to the build config file for a given directory.
|
||||
func ConfigPath(dir string) string {
|
||||
return filepath.Join(dir, ConfigDir, ConfigFileName)
|
||||
}
|
||||
|
||||
// ConfigExists checks if a build config file exists in the given directory.
|
||||
func ConfigExists(dir string) bool {
|
||||
return fileExists(ConfigPath(dir))
|
||||
}
|
||||
|
||||
// ToTargets converts TargetConfig slice to Target slice for use with builders.
|
||||
func (cfg *BuildConfig) ToTargets() []Target {
|
||||
targets := make([]Target, len(cfg.Targets))
|
||||
for i, t := range cfg.Targets {
|
||||
targets[i] = Target{OS: t.OS, Arch: t.Arch}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
281
pkg/build/config_test.go
Normal file
281
pkg/build/config_test.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package build
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupConfigTestDir creates a temp directory with optional .core/build.yaml content.
|
||||
func setupConfigTestDir(t *testing.T, configContent string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
if configContent != "" {
|
||||
coreDir := filepath.Join(dir, ConfigDir)
|
||||
err := os.MkdirAll(coreDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
configPath := filepath.Join(coreDir, ConfigFileName)
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestLoadConfig_Good(t *testing.T) {
|
||||
t.Run("loads valid config", func(t *testing.T) {
|
||||
content := `
|
||||
version: 1
|
||||
project:
|
||||
name: myapp
|
||||
description: A test application
|
||||
main: ./cmd/myapp
|
||||
binary: myapp
|
||||
build:
|
||||
cgo: true
|
||||
flags:
|
||||
- -trimpath
|
||||
- -race
|
||||
ldflags:
|
||||
- -s
|
||||
- -w
|
||||
env:
|
||||
- FOO=bar
|
||||
targets:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
- os: darwin
|
||||
arch: arm64
|
||||
`
|
||||
dir := setupConfigTestDir(t, content)
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, 1, cfg.Version)
|
||||
assert.Equal(t, "myapp", cfg.Project.Name)
|
||||
assert.Equal(t, "A test application", cfg.Project.Description)
|
||||
assert.Equal(t, "./cmd/myapp", cfg.Project.Main)
|
||||
assert.Equal(t, "myapp", cfg.Project.Binary)
|
||||
assert.True(t, cfg.Build.CGO)
|
||||
assert.Equal(t, []string{"-trimpath", "-race"}, cfg.Build.Flags)
|
||||
assert.Equal(t, []string{"-s", "-w"}, cfg.Build.LDFlags)
|
||||
assert.Equal(t, []string{"FOO=bar"}, cfg.Build.Env)
|
||||
assert.Len(t, cfg.Targets, 2)
|
||||
assert.Equal(t, "linux", cfg.Targets[0].OS)
|
||||
assert.Equal(t, "amd64", cfg.Targets[0].Arch)
|
||||
assert.Equal(t, "darwin", cfg.Targets[1].OS)
|
||||
assert.Equal(t, "arm64", cfg.Targets[1].Arch)
|
||||
})
|
||||
|
||||
t.Run("returns defaults when config file missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
defaults := DefaultConfig()
|
||||
assert.Equal(t, defaults.Version, cfg.Version)
|
||||
assert.Equal(t, defaults.Project.Main, cfg.Project.Main)
|
||||
assert.Equal(t, defaults.Build.CGO, cfg.Build.CGO)
|
||||
assert.Equal(t, defaults.Build.Flags, cfg.Build.Flags)
|
||||
assert.Equal(t, defaults.Build.LDFlags, cfg.Build.LDFlags)
|
||||
assert.Equal(t, defaults.Targets, cfg.Targets)
|
||||
})
|
||||
|
||||
t.Run("applies defaults for missing fields", func(t *testing.T) {
|
||||
content := `
|
||||
version: 2
|
||||
project:
|
||||
name: partial
|
||||
`
|
||||
dir := setupConfigTestDir(t, content)
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Explicit values preserved
|
||||
assert.Equal(t, 2, cfg.Version)
|
||||
assert.Equal(t, "partial", cfg.Project.Name)
|
||||
|
||||
// Defaults applied
|
||||
defaults := DefaultConfig()
|
||||
assert.Equal(t, defaults.Project.Main, cfg.Project.Main)
|
||||
assert.Equal(t, defaults.Build.Flags, cfg.Build.Flags)
|
||||
assert.Equal(t, defaults.Build.LDFlags, cfg.Build.LDFlags)
|
||||
assert.Equal(t, defaults.Targets, cfg.Targets)
|
||||
})
|
||||
|
||||
t.Run("preserves empty arrays when explicitly set", func(t *testing.T) {
|
||||
content := `
|
||||
version: 1
|
||||
project:
|
||||
name: noflags
|
||||
build:
|
||||
flags: []
|
||||
ldflags: []
|
||||
targets:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
`
|
||||
dir := setupConfigTestDir(t, content)
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Empty arrays are preserved (not replaced with defaults)
|
||||
assert.Empty(t, cfg.Build.Flags)
|
||||
assert.Empty(t, cfg.Build.LDFlags)
|
||||
// Targets explicitly set
|
||||
assert.Len(t, cfg.Targets, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadConfig_Bad(t *testing.T) {
|
||||
t.Run("returns error for invalid YAML", func(t *testing.T) {
|
||||
content := `
|
||||
version: 1
|
||||
project:
|
||||
name: [invalid yaml
|
||||
`
|
||||
dir := setupConfigTestDir(t, content)
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, cfg)
|
||||
assert.Contains(t, err.Error(), "failed to parse config file")
|
||||
})
|
||||
|
||||
t.Run("returns error for unreadable file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
coreDir := filepath.Join(dir, ConfigDir)
|
||||
err := os.MkdirAll(coreDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create config as a directory instead of file
|
||||
configPath := filepath.Join(coreDir, ConfigFileName)
|
||||
err = os.Mkdir(configPath, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := LoadConfig(dir)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, cfg)
|
||||
assert.Contains(t, err.Error(), "failed to read config file")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultConfig_Good(t *testing.T) {
|
||||
t.Run("returns sensible defaults", func(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
assert.Equal(t, 1, cfg.Version)
|
||||
assert.Equal(t, ".", cfg.Project.Main)
|
||||
assert.Empty(t, cfg.Project.Name)
|
||||
assert.Empty(t, cfg.Project.Binary)
|
||||
assert.False(t, cfg.Build.CGO)
|
||||
assert.Contains(t, cfg.Build.Flags, "-trimpath")
|
||||
assert.Contains(t, cfg.Build.LDFlags, "-s")
|
||||
assert.Contains(t, cfg.Build.LDFlags, "-w")
|
||||
assert.Empty(t, cfg.Build.Env)
|
||||
|
||||
// Default targets cover common platforms
|
||||
assert.Len(t, cfg.Targets, 5)
|
||||
hasLinuxAmd64 := false
|
||||
hasDarwinArm64 := false
|
||||
hasWindowsAmd64 := false
|
||||
for _, t := range cfg.Targets {
|
||||
if t.OS == "linux" && t.Arch == "amd64" {
|
||||
hasLinuxAmd64 = true
|
||||
}
|
||||
if t.OS == "darwin" && t.Arch == "arm64" {
|
||||
hasDarwinArm64 = true
|
||||
}
|
||||
if t.OS == "windows" && t.Arch == "amd64" {
|
||||
hasWindowsAmd64 = true
|
||||
}
|
||||
}
|
||||
assert.True(t, hasLinuxAmd64)
|
||||
assert.True(t, hasDarwinArm64)
|
||||
assert.True(t, hasWindowsAmd64)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigPath_Good(t *testing.T) {
|
||||
t.Run("returns correct path", func(t *testing.T) {
|
||||
path := ConfigPath("/project/root")
|
||||
assert.Equal(t, "/project/root/.core/build.yaml", path)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigExists_Good(t *testing.T) {
|
||||
t.Run("returns true when config exists", func(t *testing.T) {
|
||||
dir := setupConfigTestDir(t, "version: 1")
|
||||
assert.True(t, ConfigExists(dir))
|
||||
})
|
||||
|
||||
t.Run("returns false when config missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, ConfigExists(dir))
|
||||
})
|
||||
|
||||
t.Run("returns false when .core dir missing", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, ConfigExists(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildConfig_ToTargets_Good(t *testing.T) {
|
||||
t.Run("converts TargetConfig to Target", func(t *testing.T) {
|
||||
cfg := &BuildConfig{
|
||||
Targets: []TargetConfig{
|
||||
{OS: "linux", Arch: "amd64"},
|
||||
{OS: "darwin", Arch: "arm64"},
|
||||
{OS: "windows", Arch: "386"},
|
||||
},
|
||||
}
|
||||
|
||||
targets := cfg.ToTargets()
|
||||
require.Len(t, targets, 3)
|
||||
|
||||
assert.Equal(t, Target{OS: "linux", Arch: "amd64"}, targets[0])
|
||||
assert.Equal(t, Target{OS: "darwin", Arch: "arm64"}, targets[1])
|
||||
assert.Equal(t, Target{OS: "windows", Arch: "386"}, targets[2])
|
||||
})
|
||||
|
||||
t.Run("returns empty slice for no targets", func(t *testing.T) {
|
||||
cfg := &BuildConfig{
|
||||
Targets: []TargetConfig{},
|
||||
}
|
||||
|
||||
targets := cfg.ToTargets()
|
||||
assert.Empty(t, targets)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadConfig_Testdata tests loading from the testdata fixture.
|
||||
func TestLoadConfig_Testdata(t *testing.T) {
|
||||
t.Run("loads config-project fixture", func(t *testing.T) {
|
||||
cfg, err := LoadConfig("testdata/config-project")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, 1, cfg.Version)
|
||||
assert.Equal(t, "example-cli", cfg.Project.Name)
|
||||
assert.Equal(t, "An example CLI application", cfg.Project.Description)
|
||||
assert.Equal(t, "./cmd/example", cfg.Project.Main)
|
||||
assert.Equal(t, "example", cfg.Project.Binary)
|
||||
assert.False(t, cfg.Build.CGO)
|
||||
assert.Equal(t, []string{"-trimpath"}, cfg.Build.Flags)
|
||||
assert.Equal(t, []string{"-s", "-w"}, cfg.Build.LDFlags)
|
||||
assert.Len(t, cfg.Targets, 3)
|
||||
})
|
||||
}
|
||||
92
pkg/build/discovery.go
Normal file
92
pkg/build/discovery.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package build
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Marker files for project type detection.
|
||||
const (
|
||||
markerGoMod = "go.mod"
|
||||
markerWails = "wails.json"
|
||||
markerNodePackage = "package.json"
|
||||
markerComposer = "composer.json"
|
||||
)
|
||||
|
||||
// projectMarker maps a marker file to its project type.
|
||||
type projectMarker struct {
|
||||
file string
|
||||
projectType ProjectType
|
||||
}
|
||||
|
||||
// markers defines the detection order. More specific types come first.
|
||||
// Wails projects have both wails.json and go.mod, so wails is checked first.
|
||||
var markers = []projectMarker{
|
||||
{markerWails, ProjectTypeWails},
|
||||
{markerGoMod, ProjectTypeGo},
|
||||
{markerNodePackage, ProjectTypeNode},
|
||||
{markerComposer, ProjectTypePHP},
|
||||
}
|
||||
|
||||
// Discover detects project types in the given directory by checking for marker files.
|
||||
// Returns a slice of detected project types, ordered by priority (most specific first).
|
||||
// For example, a Wails project returns [wails, go] since it has both wails.json and go.mod.
|
||||
func Discover(dir string) ([]ProjectType, error) {
|
||||
var detected []ProjectType
|
||||
|
||||
for _, m := range markers {
|
||||
path := filepath.Join(dir, m.file)
|
||||
if fileExists(path) {
|
||||
// Avoid duplicates (shouldn't happen with current markers, but defensive)
|
||||
if !slices.Contains(detected, m.projectType) {
|
||||
detected = append(detected, m.projectType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return detected, nil
|
||||
}
|
||||
|
||||
// PrimaryType returns the most specific project type detected in the directory.
|
||||
// Returns empty string if no project type is detected.
|
||||
func PrimaryType(dir string) (ProjectType, error) {
|
||||
types, err := Discover(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(types) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return types[0], nil
|
||||
}
|
||||
|
||||
// IsGoProject checks if the directory contains a Go project (go.mod or wails.json).
|
||||
func IsGoProject(dir string) bool {
|
||||
return fileExists(filepath.Join(dir, markerGoMod)) ||
|
||||
fileExists(filepath.Join(dir, markerWails))
|
||||
}
|
||||
|
||||
// IsWailsProject checks if the directory contains a Wails project.
|
||||
func IsWailsProject(dir string) bool {
|
||||
return fileExists(filepath.Join(dir, markerWails))
|
||||
}
|
||||
|
||||
// IsNodeProject checks if the directory contains a Node.js project.
|
||||
func IsNodeProject(dir string) bool {
|
||||
return fileExists(filepath.Join(dir, markerNodePackage))
|
||||
}
|
||||
|
||||
// IsPHPProject checks if the directory contains a PHP project.
|
||||
func IsPHPProject(dir string) bool {
|
||||
return fileExists(filepath.Join(dir, markerComposer))
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists and is not a directory.
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
217
pkg/build/discovery_test.go
Normal file
217
pkg/build/discovery_test.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
package build
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupTestDir creates a temporary directory with the specified marker files.
|
||||
func setupTestDir(t *testing.T, markers ...string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, m := range markers {
|
||||
path := filepath.Join(dir, m)
|
||||
err := os.WriteFile(path, []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestDiscover_Good(t *testing.T) {
|
||||
t.Run("detects Go project", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "go.mod")
|
||||
types, err := Discover(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []ProjectType{ProjectTypeGo}, types)
|
||||
})
|
||||
|
||||
t.Run("detects Wails project with priority over Go", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "wails.json", "go.mod")
|
||||
types, err := Discover(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []ProjectType{ProjectTypeWails, ProjectTypeGo}, types)
|
||||
})
|
||||
|
||||
t.Run("detects Node.js project", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "package.json")
|
||||
types, err := Discover(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []ProjectType{ProjectTypeNode}, types)
|
||||
})
|
||||
|
||||
t.Run("detects PHP project", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "composer.json")
|
||||
types, err := Discover(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []ProjectType{ProjectTypePHP}, types)
|
||||
})
|
||||
|
||||
t.Run("detects multiple project types", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "go.mod", "package.json")
|
||||
types, err := Discover(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []ProjectType{ProjectTypeGo, ProjectTypeNode}, types)
|
||||
})
|
||||
|
||||
t.Run("empty directory returns empty slice", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
types, err := Discover(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, types)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscover_Bad(t *testing.T) {
|
||||
t.Run("non-existent directory returns empty slice", func(t *testing.T) {
|
||||
types, err := Discover("/non/existent/path")
|
||||
assert.NoError(t, err) // os.Stat fails silently in fileExists
|
||||
assert.Empty(t, types)
|
||||
})
|
||||
|
||||
t.Run("directory marker is ignored", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create go.mod as a directory instead of a file
|
||||
err := os.Mkdir(filepath.Join(dir, "go.mod"), 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
types, err := Discover(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, types)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrimaryType_Good(t *testing.T) {
|
||||
t.Run("returns wails for wails project", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "wails.json", "go.mod")
|
||||
primary, err := PrimaryType(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ProjectTypeWails, primary)
|
||||
})
|
||||
|
||||
t.Run("returns go for go-only project", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "go.mod")
|
||||
primary, err := PrimaryType(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, ProjectTypeGo, primary)
|
||||
})
|
||||
|
||||
t.Run("returns empty string for empty directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
primary, err := PrimaryType(dir)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, primary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsGoProject_Good(t *testing.T) {
|
||||
t.Run("true with go.mod", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "go.mod")
|
||||
assert.True(t, IsGoProject(dir))
|
||||
})
|
||||
|
||||
t.Run("true with wails.json", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "wails.json")
|
||||
assert.True(t, IsGoProject(dir))
|
||||
})
|
||||
|
||||
t.Run("false without markers", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, IsGoProject(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsWailsProject_Good(t *testing.T) {
|
||||
t.Run("true with wails.json", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "wails.json")
|
||||
assert.True(t, IsWailsProject(dir))
|
||||
})
|
||||
|
||||
t.Run("false with only go.mod", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "go.mod")
|
||||
assert.False(t, IsWailsProject(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsNodeProject_Good(t *testing.T) {
|
||||
t.Run("true with package.json", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "package.json")
|
||||
assert.True(t, IsNodeProject(dir))
|
||||
})
|
||||
|
||||
t.Run("false without package.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, IsNodeProject(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsPHPProject_Good(t *testing.T) {
|
||||
t.Run("true with composer.json", func(t *testing.T) {
|
||||
dir := setupTestDir(t, "composer.json")
|
||||
assert.True(t, IsPHPProject(dir))
|
||||
})
|
||||
|
||||
t.Run("false without composer.json", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, IsPHPProject(dir))
|
||||
})
|
||||
}
|
||||
|
||||
func TestTarget_Good(t *testing.T) {
|
||||
target := Target{OS: "linux", Arch: "amd64"}
|
||||
assert.Equal(t, "linux/amd64", target.String())
|
||||
}
|
||||
|
||||
func TestFileExists_Good(t *testing.T) {
|
||||
t.Run("returns true for existing file", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.txt")
|
||||
err := os.WriteFile(path, []byte("content"), 0644)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, fileExists(path))
|
||||
})
|
||||
|
||||
t.Run("returns false for directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
assert.False(t, fileExists(dir))
|
||||
})
|
||||
|
||||
t.Run("returns false for non-existent path", func(t *testing.T) {
|
||||
assert.False(t, fileExists("/non/existent/file"))
|
||||
})
|
||||
}
|
||||
|
||||
// TestDiscover_Testdata tests discovery using the testdata fixtures.
|
||||
// These serve as integration tests with realistic project structures.
|
||||
func TestDiscover_Testdata(t *testing.T) {
|
||||
testdataDir := "testdata"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
dir string
|
||||
expected []ProjectType
|
||||
}{
|
||||
{"go-project", "go-project", []ProjectType{ProjectTypeGo}},
|
||||
{"wails-project", "wails-project", []ProjectType{ProjectTypeWails, ProjectTypeGo}},
|
||||
{"node-project", "node-project", []ProjectType{ProjectTypeNode}},
|
||||
{"php-project", "php-project", []ProjectType{ProjectTypePHP}},
|
||||
{"multi-project", "multi-project", []ProjectType{ProjectTypeGo, ProjectTypeNode}},
|
||||
{"empty-project", "empty-project", []ProjectType{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := filepath.Join(testdataDir, tt.dir)
|
||||
types, err := Discover(dir)
|
||||
assert.NoError(t, err)
|
||||
if len(tt.expected) == 0 {
|
||||
assert.Empty(t, types)
|
||||
} else {
|
||||
assert.Equal(t, tt.expected, types)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
16
pkg/build/go.mod
Normal file
16
pkg/build/go.mod
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
module github.com/host-uk/core/pkg/build
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
)
|
||||
13
pkg/build/go.sum
Normal file
13
pkg/build/go.sum
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
25
pkg/build/testdata/config-project/.core/build.yaml
vendored
Normal file
25
pkg/build/testdata/config-project/.core/build.yaml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Example build configuration for Core build system
|
||||
version: 1
|
||||
|
||||
project:
|
||||
name: example-cli
|
||||
description: An example CLI application
|
||||
main: ./cmd/example
|
||||
binary: example
|
||||
|
||||
build:
|
||||
cgo: false
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s
|
||||
- -w
|
||||
env: []
|
||||
|
||||
targets:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
- os: darwin
|
||||
arch: arm64
|
||||
- os: windows
|
||||
arch: amd64
|
||||
0
pkg/build/testdata/empty-project/.gitkeep
vendored
Normal file
0
pkg/build/testdata/empty-project/.gitkeep
vendored
Normal file
3
pkg/build/testdata/go-project/go.mod
vendored
Normal file
3
pkg/build/testdata/go-project/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/go-project
|
||||
|
||||
go 1.21
|
||||
3
pkg/build/testdata/multi-project/go.mod
vendored
Normal file
3
pkg/build/testdata/multi-project/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/multi-project
|
||||
|
||||
go 1.21
|
||||
4
pkg/build/testdata/multi-project/package.json
vendored
Normal file
4
pkg/build/testdata/multi-project/package.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "multi-project",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
4
pkg/build/testdata/node-project/package.json
vendored
Normal file
4
pkg/build/testdata/node-project/package.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "node-project",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
4
pkg/build/testdata/php-project/composer.json
vendored
Normal file
4
pkg/build/testdata/php-project/composer.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "vendor/php-project",
|
||||
"type": "library"
|
||||
}
|
||||
3
pkg/build/testdata/wails-project/go.mod
vendored
Normal file
3
pkg/build/testdata/wails-project/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module example.com/wails-project
|
||||
|
||||
go 1.21
|
||||
4
pkg/build/testdata/wails-project/wails.json
vendored
Normal file
4
pkg/build/testdata/wails-project/wails.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "wails-project",
|
||||
"outputfilename": "wails-project"
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./...
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
archives:
|
||||
- replacements:
|
||||
darwin: Darwin
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
brews:
|
||||
- name: config
|
||||
tap:
|
||||
owner: Snider
|
||||
name: homebrew-tap
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: goreleaser@carlosbecker.com
|
||||
homepage: "https://github.com/Snider/config"
|
||||
description: "Config module for the Core Framework"
|
||||
license: "EUPL-1.2"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./...
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
archives:
|
||||
- replacements:
|
||||
darwin: Darwin
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
brews:
|
||||
- name: display
|
||||
tap:
|
||||
owner: Snider
|
||||
name: homebrew-tap
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: goreleaser@carlosbecker.com
|
||||
homepage: "https://github.com/Snider/display"
|
||||
description: "A display module for the core web3 framework"
|
||||
license: "EUPL-1.2"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./...
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
archives:
|
||||
- replacements:
|
||||
darwin: Darwin
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
brews:
|
||||
- name: help
|
||||
tap:
|
||||
owner: Snider
|
||||
name: homebrew-tap
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: goreleaser@carlosbecker.com
|
||||
homepage: "https://github.com/Snider/help"
|
||||
description: "Help module for the core framework"
|
||||
license: "EUPL-1.2"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./...
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
archives:
|
||||
- replacements:
|
||||
darwin: Darwin
|
||||
linux: Linux
|
||||
windows: Windows
|
||||
386: i386
|
||||
amd64: x86_64
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
brews:
|
||||
- name: i18n
|
||||
tap:
|
||||
owner: snider
|
||||
name: homebrew-tap
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: goreleaser@carlosbecker.com
|
||||
homepage: "https://github.com/snider/i18n"
|
||||
description: "A demo CLI for i18n"
|
||||
license: "EUPL-1.2"
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
|
||||
# The project name.
|
||||
project_name: updater
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./...
|
||||
|
||||
builds:
|
||||
- # The main binary to build.
|
||||
main: ./cmd/updater
|
||||
# GOOS and GOARCH to build for.
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
# CGO_ENABLED=0 is required for cross-compilation.
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
|
||||
archives:
|
||||
- # The format of the archive.
|
||||
format: tar.gz
|
||||
# This is a list of format overrides for specific platforms.
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
checksum:
|
||||
# The name of the checksums file.
|
||||
name_template: 'checksums.txt'
|
||||
|
||||
snapshot:
|
||||
# The name of the snapshot release.
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
changelog:
|
||||
# Sort the changelog by commit date.
|
||||
sort: asc
|
||||
# Filter out certain commit messages.
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
|
||||
brews:
|
||||
- # The name of the Homebrew formula.
|
||||
name: updater
|
||||
# The GitHub repository to push the formula to.
|
||||
tap:
|
||||
owner: your-username
|
||||
name: homebrew-tap
|
||||
# The commit author.
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: goreleaser@example.com
|
||||
# The homepage of the project.
|
||||
homepage: "https://github.com/your-username/updater"
|
||||
# A description of the project.
|
||||
description: "A demo CLI for the updater"
|
||||
# The license of the project.
|
||||
license: "MIT"
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./...
|
||||
builds:
|
||||
- main: ./cmd/updater
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
archives:
|
||||
- format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
brews:
|
||||
- name: updater
|
||||
tap:
|
||||
owner: your-username
|
||||
name: homebrew-tap
|
||||
commit_author:
|
||||
name: goreleaserbot
|
||||
email: goreleaser@example.com
|
||||
homepage: "https://github.com/your-username/updater"
|
||||
description: "A demo CLI for the updater"
|
||||
license: "MIT"
|
||||
Loading…
Add table
Reference in a new issue