From 9fe47a9bc6d8caba71af2c5f88816437dbb3c816 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 28 Jan 2026 17:59:02 +0000 Subject: [PATCH] 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 --- .gitignore | 4 + .goreleaser.yaml | 85 ---- cmd/core/cmd/build.go | 372 ++++++++++++++++- cmd/core/go.mod | 7 +- cmd/core/go.sum | 16 + go.work | 1 + pkg/build/archive.go | 191 +++++++++ pkg/build/archive_test.go | 308 ++++++++++++++ pkg/build/build.go | 61 +++ pkg/build/builders/go.go | 128 ++++++ pkg/build/builders/go_test.go | 385 ++++++++++++++++++ pkg/build/checksum.go | 102 +++++ pkg/build/checksum_test.go | 275 +++++++++++++ pkg/build/config.go | 161 ++++++++ pkg/build/config_test.go | 281 +++++++++++++ pkg/build/discovery.go | 92 +++++ pkg/build/discovery_test.go | 217 ++++++++++ pkg/build/go.mod | 16 + pkg/build/go.sum | 13 + .../testdata/config-project/.core/build.yaml | 25 ++ pkg/build/testdata/empty-project/.gitkeep | 0 pkg/build/testdata/go-project/go.mod | 3 + pkg/build/testdata/multi-project/go.mod | 3 + pkg/build/testdata/multi-project/package.json | 4 + pkg/build/testdata/node-project/package.json | 4 + pkg/build/testdata/php-project/composer.json | 4 + pkg/build/testdata/wails-project/go.mod | 3 + pkg/build/testdata/wails-project/wails.json | 4 + pkg/config/.goreleaser.yaml | 43 -- pkg/display/.goreleaser.yaml | 43 -- pkg/help/.goreleaser.yaml | 43 -- pkg/i18n/.goreleaser.yaml | 43 -- pkg/updater/.goreleaser.dist.yaml | 70 ---- pkg/updater/.goreleaser.yaml | 41 -- 34 files changed, 2675 insertions(+), 373 deletions(-) delete mode 100644 .goreleaser.yaml create mode 100644 pkg/build/archive.go create mode 100644 pkg/build/archive_test.go create mode 100644 pkg/build/build.go create mode 100644 pkg/build/builders/go.go create mode 100644 pkg/build/builders/go_test.go create mode 100644 pkg/build/checksum.go create mode 100644 pkg/build/checksum_test.go create mode 100644 pkg/build/config.go create mode 100644 pkg/build/config_test.go create mode 100644 pkg/build/discovery.go create mode 100644 pkg/build/discovery_test.go create mode 100644 pkg/build/go.mod create mode 100644 pkg/build/go.sum create mode 100644 pkg/build/testdata/config-project/.core/build.yaml create mode 100644 pkg/build/testdata/empty-project/.gitkeep create mode 100644 pkg/build/testdata/go-project/go.mod create mode 100644 pkg/build/testdata/multi-project/go.mod create mode 100644 pkg/build/testdata/multi-project/package.json create mode 100644 pkg/build/testdata/node-project/package.json create mode 100644 pkg/build/testdata/php-project/composer.json create mode 100644 pkg/build/testdata/wails-project/go.mod create mode 100644 pkg/build/testdata/wails-project/wails.json delete mode 100644 pkg/config/.goreleaser.yaml delete mode 100644 pkg/display/.goreleaser.yaml delete mode 100644 pkg/help/.goreleaser.yaml delete mode 100644 pkg/i18n/.goreleaser.yaml delete mode 100644 pkg/updater/.goreleaser.dist.yaml delete mode 100644 pkg/updater/.goreleaser.yaml diff --git a/.gitignore b/.gitignore index 587c30d..b70cb81 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 74346f6..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -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 diff --git a/cmd/core/cmd/build.go b/cmd/core/cmd/build.go index 71e9150..863f582 100644 --- a/cmd/core/cmd/build.go +++ b/cmd/core/cmd/build.go @@ -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 { diff --git a/cmd/core/go.mod b/cmd/core/go.mod index a1a9606..81acd6e 100644 --- a/cmd/core/go.mod +++ b/cmd/core/go.mod @@ -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 ) diff --git a/cmd/core/go.sum b/cmd/core/go.sum index b3b35d5..d1c07a0 100644 --- a/cmd/core/go.sum +++ b/cmd/core/go.sum @@ -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= diff --git a/go.work b/go.work index 641400e..0e39f88 100644 --- a/go.work +++ b/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 diff --git a/pkg/build/archive.go b/pkg/build/archive.go new file mode 100644 index 0000000..b0451f2 --- /dev/null +++ b/pkg/build/archive.go @@ -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 +} diff --git a/pkg/build/archive_test.go b/pkg/build/archive_test.go new file mode 100644 index 0000000..27d6660 --- /dev/null +++ b/pkg/build/archive_test.go @@ -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) +} diff --git a/pkg/build/build.go b/pkg/build/build.go new file mode 100644 index 0000000..78ccdc8 --- /dev/null +++ b/pkg/build/build.go @@ -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) +} diff --git a/pkg/build/builders/go.go b/pkg/build/builders/go.go new file mode 100644 index 0000000..63275d9 --- /dev/null +++ b/pkg/build/builders/go.go @@ -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) diff --git a/pkg/build/builders/go_test.go b/pkg/build/builders/go_test.go new file mode 100644 index 0000000..c46ad3b --- /dev/null +++ b/pkg/build/builders/go_test.go @@ -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() +} diff --git a/pkg/build/checksum.go b/pkg/build/checksum.go new file mode 100644 index 0000000..926ac45 --- /dev/null +++ b/pkg/build/checksum.go @@ -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 +} diff --git a/pkg/build/checksum_test.go b/pkg/build/checksum_test.go new file mode 100644 index 0000000..499c67d --- /dev/null +++ b/pkg/build/checksum_test.go @@ -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") + }) +} diff --git a/pkg/build/config.go b/pkg/build/config.go new file mode 100644 index 0000000..d3e02b4 --- /dev/null +++ b/pkg/build/config.go @@ -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 +} diff --git a/pkg/build/config_test.go b/pkg/build/config_test.go new file mode 100644 index 0000000..d12440f --- /dev/null +++ b/pkg/build/config_test.go @@ -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) + }) +} diff --git a/pkg/build/discovery.go b/pkg/build/discovery.go new file mode 100644 index 0000000..ba90b4d --- /dev/null +++ b/pkg/build/discovery.go @@ -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() +} diff --git a/pkg/build/discovery_test.go b/pkg/build/discovery_test.go new file mode 100644 index 0000000..dc1a1f9 --- /dev/null +++ b/pkg/build/discovery_test.go @@ -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) + } + }) + } +} diff --git a/pkg/build/go.mod b/pkg/build/go.mod new file mode 100644 index 0000000..ef4e5db --- /dev/null +++ b/pkg/build/go.mod @@ -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 +) diff --git a/pkg/build/go.sum b/pkg/build/go.sum new file mode 100644 index 0000000..ac71d4c --- /dev/null +++ b/pkg/build/go.sum @@ -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= diff --git a/pkg/build/testdata/config-project/.core/build.yaml b/pkg/build/testdata/config-project/.core/build.yaml new file mode 100644 index 0000000..ff3a997 --- /dev/null +++ b/pkg/build/testdata/config-project/.core/build.yaml @@ -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 diff --git a/pkg/build/testdata/empty-project/.gitkeep b/pkg/build/testdata/empty-project/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pkg/build/testdata/go-project/go.mod b/pkg/build/testdata/go-project/go.mod new file mode 100644 index 0000000..deedf38 --- /dev/null +++ b/pkg/build/testdata/go-project/go.mod @@ -0,0 +1,3 @@ +module example.com/go-project + +go 1.21 diff --git a/pkg/build/testdata/multi-project/go.mod b/pkg/build/testdata/multi-project/go.mod new file mode 100644 index 0000000..f45e24d --- /dev/null +++ b/pkg/build/testdata/multi-project/go.mod @@ -0,0 +1,3 @@ +module example.com/multi-project + +go 1.21 diff --git a/pkg/build/testdata/multi-project/package.json b/pkg/build/testdata/multi-project/package.json new file mode 100644 index 0000000..18c5954 --- /dev/null +++ b/pkg/build/testdata/multi-project/package.json @@ -0,0 +1,4 @@ +{ + "name": "multi-project", + "version": "1.0.0" +} diff --git a/pkg/build/testdata/node-project/package.json b/pkg/build/testdata/node-project/package.json new file mode 100644 index 0000000..6d873ce --- /dev/null +++ b/pkg/build/testdata/node-project/package.json @@ -0,0 +1,4 @@ +{ + "name": "node-project", + "version": "1.0.0" +} diff --git a/pkg/build/testdata/php-project/composer.json b/pkg/build/testdata/php-project/composer.json new file mode 100644 index 0000000..962108e --- /dev/null +++ b/pkg/build/testdata/php-project/composer.json @@ -0,0 +1,4 @@ +{ + "name": "vendor/php-project", + "type": "library" +} diff --git a/pkg/build/testdata/wails-project/go.mod b/pkg/build/testdata/wails-project/go.mod new file mode 100644 index 0000000..e4daed1 --- /dev/null +++ b/pkg/build/testdata/wails-project/go.mod @@ -0,0 +1,3 @@ +module example.com/wails-project + +go 1.21 diff --git a/pkg/build/testdata/wails-project/wails.json b/pkg/build/testdata/wails-project/wails.json new file mode 100644 index 0000000..aaa778f --- /dev/null +++ b/pkg/build/testdata/wails-project/wails.json @@ -0,0 +1,4 @@ +{ + "name": "wails-project", + "outputfilename": "wails-project" +} diff --git a/pkg/config/.goreleaser.yaml b/pkg/config/.goreleaser.yaml deleted file mode 100644 index 22db42f..0000000 --- a/pkg/config/.goreleaser.yaml +++ /dev/null @@ -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" diff --git a/pkg/display/.goreleaser.yaml b/pkg/display/.goreleaser.yaml deleted file mode 100644 index d7ae5b4..0000000 --- a/pkg/display/.goreleaser.yaml +++ /dev/null @@ -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" diff --git a/pkg/help/.goreleaser.yaml b/pkg/help/.goreleaser.yaml deleted file mode 100644 index 503dea8..0000000 --- a/pkg/help/.goreleaser.yaml +++ /dev/null @@ -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" diff --git a/pkg/i18n/.goreleaser.yaml b/pkg/i18n/.goreleaser.yaml deleted file mode 100644 index e73ee1f..0000000 --- a/pkg/i18n/.goreleaser.yaml +++ /dev/null @@ -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" diff --git a/pkg/updater/.goreleaser.dist.yaml b/pkg/updater/.goreleaser.dist.yaml deleted file mode 100644 index c997a59..0000000 --- a/pkg/updater/.goreleaser.dist.yaml +++ /dev/null @@ -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" diff --git a/pkg/updater/.goreleaser.yaml b/pkg/updater/.goreleaser.yaml deleted file mode 100644 index 2502008..0000000 --- a/pkg/updater/.goreleaser.yaml +++ /dev/null @@ -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"