Migrate pkg/build to io.Medium abstraction (#287)

* chore(io): Migrate pkg/build to Medium abstraction

- Updated io.Medium interface with Open() and Create() methods to support streaming.
- Migrated pkg/build, pkg/build/builders, and pkg/build/signing to use io.Medium.
- Added FS field to build.Config and updated build.Builder interface.
- Refactored checksum and archive logic to use io.Medium streaming.
- Updated pkg/release and pkg/build/buildcmd to use io.Local.
- Updated unit tests to match new signatures.

* chore(io): Migrate pkg/build to Medium abstraction (fix CI)

- Fixed formatting in pkg/build/builders/wails.go.
- Fixed TestLoadConfig_Testdata and TestDiscover_Testdata to use absolute paths with io.Local to ensure compatibility with GitHub CI.
- Verified that all build and release tests pass.

* chore(io): Migrate pkg/build to Medium abstraction (fix CI paths)

- Ensured that outputDir and configPath are absolute in runProjectBuild.
- Fixed TestLoadConfig_Testdata and TestDiscover_Testdata to use absolute paths correctly.
- Verified that all build and release tests pass locally.

* chore(io): Migrate pkg/build to Medium abstraction (final fix)

- Improved io.Local to handle relative paths relative to CWD when rooted at "/".
- This makes io.Local a drop-in replacement for the 'os' package for most use cases.
- Ensured absolute paths are used in build logic and tests where appropriate.
- Fixed formatting and cleaned up debug prints.

* chore(io): address code review and fix CI

- Fix MockFile.Read to return io.EOF
- Use filepath.Match in TaskfileBuilder for precise globbing
- Stream xz data in createTarXzArchive to avoid in-memory string conversion
- Fix TestPath_RootFilesystem in local medium tests
- Fix formatting in pkg/build/buildcmd/cmd_project.go

* chore(io): resolve merge conflicts and final migration of pkg/build

- Resolved merge conflicts in pkg/io/io.go, pkg/io/local/client.go, and pkg/release/release.go.
- Reconciled io.Medium interface with upstream changes (unifying to fs.File for Open).
- Integrated upstream validatePath logic into the local medium.
- Completed migration of pkg/build and related packages to io.Medium.
- Addressed previous code review feedback on MockMedium and TaskfileBuilder.

* chore(io): resolve merge conflicts and finalize migration

- Resolved merge conflicts with dev branch.
- Unified io.Medium interface (Open returns fs.File, Create returns io.WriteCloser).
- Integrated upstream validatePath logic.
- Ensured all tests pass across pkg/io, pkg/build, and pkg/release.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-04 17:59:10 +00:00 committed by GitHub
parent 8f369000ad
commit 552feb9d45
29 changed files with 492 additions and 299 deletions

View file

@ -8,11 +8,11 @@ import (
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Snider/Borg/pkg/compress"
io_interface "github.com/host-uk/core/pkg/io"
)
// ArchiveFormat specifies the compression format for archives.
@ -31,28 +31,28 @@ const (
// 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) {
return ArchiveWithFormat(artifact, ArchiveFormatGzip)
func Archive(fs io_interface.Medium, artifact Artifact) (Artifact, error) {
return ArchiveWithFormat(fs, artifact, ArchiveFormatGzip)
}
// ArchiveXZ creates an archive for a single artifact using xz compression.
// Uses tar.xz for linux/darwin and zip for windows.
// Returns a new Artifact with Path pointing to the archive.
func ArchiveXZ(artifact Artifact) (Artifact, error) {
return ArchiveWithFormat(artifact, ArchiveFormatXZ)
func ArchiveXZ(fs io_interface.Medium, artifact Artifact) (Artifact, error) {
return ArchiveWithFormat(fs, artifact, ArchiveFormatXZ)
}
// ArchiveWithFormat creates an archive for a single artifact with the specified format.
// Uses tar.gz or tar.xz for linux/darwin and zip for windows.
// The archive is created alongside the binary (e.g., dist/myapp_linux_amd64.tar.xz).
// Returns a new Artifact with Path pointing to the archive.
func ArchiveWithFormat(artifact Artifact, format ArchiveFormat) (Artifact, error) {
func ArchiveWithFormat(fs io_interface.Medium, artifact Artifact, format ArchiveFormat) (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)
info, err := fs.Stat(artifact.Path)
if err != nil {
return Artifact{}, fmt.Errorf("build.Archive: source file not found: %w", err)
}
@ -62,7 +62,7 @@ func ArchiveWithFormat(artifact Artifact, format ArchiveFormat) (Artifact, error
// Determine archive type based on OS and format
var archivePath string
var archiveFunc func(src, dst string) error
var archiveFunc func(fs io_interface.Medium, src, dst string) error
if artifact.OS == "windows" {
archivePath = archiveFilename(artifact, ".zip")
@ -79,7 +79,7 @@ func ArchiveWithFormat(artifact Artifact, format ArchiveFormat) (Artifact, error
}
// Create the archive
if err := archiveFunc(artifact.Path, archivePath); err != nil {
if err := archiveFunc(fs, artifact.Path, archivePath); err != nil {
return Artifact{}, fmt.Errorf("build.Archive: failed to create archive: %w", err)
}
@ -93,26 +93,26 @@ func ArchiveWithFormat(artifact Artifact, format ArchiveFormat) (Artifact, error
// ArchiveAll archives all artifacts using gzip compression.
// Returns a slice of new artifacts pointing to the archives.
func ArchiveAll(artifacts []Artifact) ([]Artifact, error) {
return ArchiveAllWithFormat(artifacts, ArchiveFormatGzip)
func ArchiveAll(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) {
return ArchiveAllWithFormat(fs, artifacts, ArchiveFormatGzip)
}
// ArchiveAllXZ archives all artifacts using xz compression.
// Returns a slice of new artifacts pointing to the archives.
func ArchiveAllXZ(artifacts []Artifact) ([]Artifact, error) {
return ArchiveAllWithFormat(artifacts, ArchiveFormatXZ)
func ArchiveAllXZ(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) {
return ArchiveAllWithFormat(fs, artifacts, ArchiveFormatXZ)
}
// ArchiveAllWithFormat archives all artifacts with the specified format.
// Returns a slice of new artifacts pointing to the archives.
func ArchiveAllWithFormat(artifacts []Artifact, format ArchiveFormat) ([]Artifact, error) {
func ArchiveAllWithFormat(fs io_interface.Medium, artifacts []Artifact, format ArchiveFormat) ([]Artifact, error) {
if len(artifacts) == 0 {
return nil, nil
}
var archived []Artifact
for _, artifact := range artifacts {
arch, err := ArchiveWithFormat(artifact, format)
arch, err := ArchiveWithFormat(fs, artifact, format)
if err != nil {
return archived, fmt.Errorf("build.ArchiveAll: failed to archive %s: %w", artifact.Path, err)
}
@ -142,9 +142,9 @@ func archiveFilename(artifact Artifact, ext string) string {
// createTarXzArchive creates a tar.xz archive containing a single file.
// Uses Borg's compress package for xz compression.
func createTarXzArchive(src, dst string) error {
func createTarXzArchive(fs io_interface.Medium, src, dst string) error {
// Open the source file
srcFile, err := os.Open(src)
srcFile, err := fs.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
@ -185,7 +185,13 @@ func createTarXzArchive(src, dst string) error {
}
// Write to destination file
if err := os.WriteFile(dst, xzData, 0644); err != nil {
dstFile, err := fs.Create(dst)
if err != nil {
return fmt.Errorf("failed to create archive file: %w", err)
}
defer func() { _ = dstFile.Close() }()
if _, err := dstFile.Write(xzData); err != nil {
return fmt.Errorf("failed to write archive file: %w", err)
}
@ -193,9 +199,9 @@ func createTarXzArchive(src, dst string) error {
}
// createTarGzArchive creates a tar.gz archive containing a single file.
func createTarGzArchive(src, dst string) error {
func createTarGzArchive(fs io_interface.Medium, src, dst string) error {
// Open the source file
srcFile, err := os.Open(src)
srcFile, err := fs.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
@ -207,7 +213,7 @@ func createTarGzArchive(src, dst string) error {
}
// Create the destination file
dstFile, err := os.Create(dst)
dstFile, err := fs.Create(dst)
if err != nil {
return fmt.Errorf("failed to create archive file: %w", err)
}
@ -243,9 +249,9 @@ func createTarGzArchive(src, dst string) error {
}
// createZipArchive creates a zip archive containing a single file.
func createZipArchive(src, dst string) error {
func createZipArchive(fs io_interface.Medium, src, dst string) error {
// Open the source file
srcFile, err := os.Open(src)
srcFile, err := fs.Open(src)
if err != nil {
return fmt.Errorf("failed to open source file: %w", err)
}
@ -257,7 +263,7 @@ func createZipArchive(src, dst string) error {
}
// Create the destination file
dstFile, err := os.Create(dst)
dstFile, err := fs.Create(dst)
if err != nil {
return fmt.Errorf("failed to create archive file: %w", err)
}

View file

@ -11,6 +11,7 @@ import (
"testing"
"github.com/Snider/Borg/pkg/compress"
io_interface "github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -37,6 +38,7 @@ func setupArchiveTestFile(t *testing.T, name, os_, arch string) (binaryPath stri
}
func TestArchive_Good(t *testing.T) {
fs := io_interface.Local
t.Run("creates tar.gz for linux", func(t *testing.T) {
binaryPath, outputDir := setupArchiveTestFile(t, "myapp", "linux", "amd64")
@ -46,7 +48,7 @@ func TestArchive_Good(t *testing.T) {
Arch: "amd64",
}
result, err := Archive(artifact)
result, err := Archive(fs, artifact)
require.NoError(t, err)
// Verify archive was created
@ -71,7 +73,7 @@ func TestArchive_Good(t *testing.T) {
Arch: "arm64",
}
result, err := Archive(artifact)
result, err := Archive(fs, artifact)
require.NoError(t, err)
expectedPath := filepath.Join(outputDir, "myapp_darwin_arm64.tar.gz")
@ -90,7 +92,7 @@ func TestArchive_Good(t *testing.T) {
Arch: "amd64",
}
result, err := Archive(artifact)
result, err := Archive(fs, artifact)
require.NoError(t, err)
// Windows archives should strip .exe from archive name
@ -111,7 +113,7 @@ func TestArchive_Good(t *testing.T) {
Checksum: "abc123",
}
result, err := Archive(artifact)
result, err := Archive(fs, artifact)
require.NoError(t, err)
assert.Equal(t, "abc123", result.Checksum)
})
@ -125,7 +127,7 @@ func TestArchive_Good(t *testing.T) {
Arch: "amd64",
}
result, err := ArchiveXZ(artifact)
result, err := ArchiveXZ(fs, artifact)
require.NoError(t, err)
expectedPath := filepath.Join(outputDir, "myapp_linux_amd64.tar.xz")
@ -144,7 +146,7 @@ func TestArchive_Good(t *testing.T) {
Arch: "arm64",
}
result, err := ArchiveWithFormat(artifact, ArchiveFormatXZ)
result, err := ArchiveWithFormat(fs, artifact, ArchiveFormatXZ)
require.NoError(t, err)
expectedPath := filepath.Join(outputDir, "myapp_darwin_arm64.tar.xz")
@ -163,7 +165,7 @@ func TestArchive_Good(t *testing.T) {
Arch: "amd64",
}
result, err := ArchiveWithFormat(artifact, ArchiveFormatXZ)
result, err := ArchiveWithFormat(fs, artifact, ArchiveFormatXZ)
require.NoError(t, err)
// Windows should still get .zip regardless of format
@ -176,6 +178,7 @@ func TestArchive_Good(t *testing.T) {
}
func TestArchive_Bad(t *testing.T) {
fs := io_interface.Local
t.Run("returns error for empty path", func(t *testing.T) {
artifact := Artifact{
Path: "",
@ -183,7 +186,7 @@ func TestArchive_Bad(t *testing.T) {
Arch: "amd64",
}
result, err := Archive(artifact)
result, err := Archive(fs, artifact)
assert.Error(t, err)
assert.Contains(t, err.Error(), "artifact path is empty")
assert.Empty(t, result.Path)
@ -196,7 +199,7 @@ func TestArchive_Bad(t *testing.T) {
Arch: "amd64",
}
result, err := Archive(artifact)
result, err := Archive(fs, artifact)
assert.Error(t, err)
assert.Contains(t, err.Error(), "source file not found")
assert.Empty(t, result.Path)
@ -211,7 +214,7 @@ func TestArchive_Bad(t *testing.T) {
Arch: "amd64",
}
result, err := Archive(artifact)
result, err := Archive(fs, artifact)
assert.Error(t, err)
assert.Contains(t, err.Error(), "source path is a directory")
assert.Empty(t, result.Path)
@ -219,6 +222,7 @@ func TestArchive_Bad(t *testing.T) {
}
func TestArchiveAll_Good(t *testing.T) {
fs := io_interface.Local
t.Run("archives multiple artifacts", func(t *testing.T) {
outputDir := t.TempDir()
@ -255,7 +259,7 @@ func TestArchiveAll_Good(t *testing.T) {
})
}
results, err := ArchiveAll(artifacts)
results, err := ArchiveAll(fs, artifacts)
require.NoError(t, err)
require.Len(t, results, 4)
@ -268,19 +272,20 @@ func TestArchiveAll_Good(t *testing.T) {
})
t.Run("returns nil for empty slice", func(t *testing.T) {
results, err := ArchiveAll([]Artifact{})
results, err := ArchiveAll(fs, []Artifact{})
assert.NoError(t, err)
assert.Nil(t, results)
})
t.Run("returns nil for nil slice", func(t *testing.T) {
results, err := ArchiveAll(nil)
results, err := ArchiveAll(fs, nil)
assert.NoError(t, err)
assert.Nil(t, results)
})
}
func TestArchiveAll_Bad(t *testing.T) {
fs := io_interface.Local
t.Run("returns partial results on error", func(t *testing.T) {
binaryPath, _ := setupArchiveTestFile(t, "myapp", "linux", "amd64")
@ -289,7 +294,7 @@ func TestArchiveAll_Bad(t *testing.T) {
{Path: "/nonexistent/binary", OS: "linux", Arch: "arm64"}, // This will fail
}
results, err := ArchiveAll(artifacts)
results, err := ArchiveAll(fs, artifacts)
assert.Error(t, err)
// Should have the first successful result
assert.Len(t, results, 1)

View file

@ -5,6 +5,8 @@ package build
import (
"context"
"github.com/host-uk/core/pkg/io"
)
// ProjectType represents a detected project type.
@ -49,6 +51,8 @@ type Artifact struct {
// Config holds build configuration.
type Config struct {
// FS is the medium used for file operations.
FS io.Medium
// ProjectDir is the root directory of the project.
ProjectDir string
// OutputDir is where build artifacts are placed.
@ -78,7 +82,7 @@ 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)
Detect(fs io.Medium, dir string) (bool, error)
// Build compiles the project for the specified targets.
Build(ctx context.Context, cfg *Config, targets []Target) ([]Artifact, error)
}

View file

@ -18,10 +18,14 @@ import (
"github.com/host-uk/core/pkg/build/builders"
"github.com/host-uk/core/pkg/build/signing"
"github.com/host-uk/core/pkg/i18n"
"github.com/host-uk/core/pkg/io"
)
// runProjectBuild handles the main `core build` command with auto-detection.
func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool, verbose bool) error {
// Use local filesystem as the default medium
fs := io.Local
// Get current working directory as project root
projectDir, err := os.Getwd()
if err != nil {
@ -29,7 +33,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
}
// Load configuration from .core/build.yaml (or defaults)
buildCfg, err := build.LoadConfig(projectDir)
buildCfg, err := build.LoadConfig(fs, projectDir)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "load config"}), err)
}
@ -39,7 +43,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
if buildType != "" {
projectType = build.ProjectType(buildType)
} else {
projectType, err = build.PrimaryType(projectDir)
projectType, err = build.PrimaryType(fs, projectDir)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "detect project type"}), err)
}
@ -70,6 +74,15 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
if outputDir == "" {
outputDir = "dist"
}
if !filepath.IsAbs(outputDir) {
outputDir = filepath.Join(projectDir, outputDir)
}
outputDir = filepath.Clean(outputDir)
// Ensure config path is absolute if provided
if configPath != "" && !filepath.IsAbs(configPath) {
configPath = filepath.Join(projectDir, configPath)
}
// Determine binary name
binaryName := buildCfg.Project.Binary
@ -98,6 +111,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
// Create build config for the builder
cfg := &build.Config{
FS: fs,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: binaryName,
@ -161,7 +175,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
signingArtifacts[i] = signing.Artifact{Path: a.Path, OS: a.OS, Arch: a.Arch}
}
if err := signing.SignBinaries(ctx, signCfg, signingArtifacts); err != nil {
if err := signing.SignBinaries(ctx, fs, signCfg, signingArtifacts); err != nil {
if !ciMode {
fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.signing_failed"), err)
}
@ -169,7 +183,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
}
if signCfg.MacOS.Notarize {
if err := signing.NotarizeBinaries(ctx, signCfg, signingArtifacts); err != nil {
if err := signing.NotarizeBinaries(ctx, fs, signCfg, signingArtifacts); err != nil {
if !ciMode {
fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.notarization_failed"), err)
}
@ -186,7 +200,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.archive")), i18n.T("cmd.build.creating_archives"))
}
archivedArtifacts, err = build.ArchiveAll(artifacts)
archivedArtifacts, err = build.ArchiveAll(fs, artifacts)
if err != nil {
if !ciMode {
fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.archive_failed"), err)
@ -256,12 +270,13 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
// computeAndWriteChecksums computes checksums for artifacts and writes CHECKSUMS.txt.
func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, artifacts []build.Artifact, signCfg signing.SignConfig, ciMode bool, verbose bool) ([]build.Artifact, error) {
fs := io.Local
if verbose && !ciMode {
fmt.Println()
fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.checksum")), i18n.T("cmd.build.computing_checksums"))
}
checksummedArtifacts, err := build.ChecksumAll(artifacts)
checksummedArtifacts, err := build.ChecksumAll(fs, artifacts)
if err != nil {
if !ciMode {
fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.checksum_failed"), err)
@ -271,7 +286,7 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string,
// Write CHECKSUMS.txt
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
if err := build.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
if err := build.WriteChecksumFile(fs, checksummedArtifacts, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("common.error.failed", map[string]any{"Action": "write CHECKSUMS.txt"}), err)
}
@ -280,7 +295,7 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string,
// Sign checksums with GPG
if signCfg.Enabled {
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
if err := signing.SignChecksums(ctx, fs, signCfg, checksumPath); err != nil {
if !ciMode {
fmt.Printf("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.gpg_signing_failed"), err)
}

View file

@ -10,6 +10,7 @@ import (
"strings"
"github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/io"
)
// DockerBuilder builds Docker images.
@ -26,9 +27,9 @@ func (b *DockerBuilder) Name() string {
}
// Detect checks if a Dockerfile exists in the directory.
func (b *DockerBuilder) Detect(dir string) (bool, error) {
func (b *DockerBuilder) Detect(fs io.Medium, dir string) (bool, error) {
dockerfilePath := filepath.Join(dir, "Dockerfile")
if _, err := os.Stat(dockerfilePath); err == nil {
if fs.IsFile(dockerfilePath) {
return true, nil
}
return false, nil
@ -53,7 +54,7 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets []
}
// Validate Dockerfile exists
if _, err := os.Stat(dockerfile); err != nil {
if !cfg.FS.IsFile(dockerfile) {
return nil, fmt.Errorf("docker.Build: Dockerfile not found: %s", dockerfile)
}
@ -150,7 +151,7 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets []
args = append(args, cfg.ProjectDir)
// Create output directory
if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil {
if err := cfg.FS.EnsureDir(cfg.OutputDir); err != nil {
return nil, fmt.Errorf("docker.Build: failed to create output directory: %w", err)
}

View file

@ -10,6 +10,7 @@ import (
"strings"
"github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/io"
)
// GoBuilder implements the Builder interface for Go projects.
@ -27,8 +28,8 @@ func (b *GoBuilder) Name() string {
// 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
func (b *GoBuilder) Detect(fs io.Medium, dir string) (bool, error) {
return build.IsGoProject(fs, dir), nil
}
// Build compiles the Go project for the specified targets.
@ -44,7 +45,7 @@ func (b *GoBuilder) Build(ctx context.Context, cfg *build.Config, targets []buil
}
// Ensure output directory exists
if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil {
if err := cfg.FS.EnsureDir(cfg.OutputDir); err != nil {
return nil, fmt.Errorf("builders.GoBuilder.Build: failed to create output directory: %w", err)
}
@ -76,7 +77,7 @@ func (b *GoBuilder) buildTarget(ctx context.Context, cfg *build.Config, target b
// 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 {
if err := cfg.FS.EnsureDir(platformDir); err != nil {
return build.Artifact{}, fmt.Errorf("failed to create platform directory: %w", err)
}

View file

@ -8,6 +8,7 @@ import (
"testing"
"github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -44,13 +45,14 @@ func TestGoBuilder_Name_Good(t *testing.T) {
}
func TestGoBuilder_Detect_Good(t *testing.T) {
fs := io.Local
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)
detected, err := builder.Detect(fs, dir)
assert.NoError(t, err)
assert.True(t, detected)
})
@ -61,7 +63,7 @@ func TestGoBuilder_Detect_Good(t *testing.T) {
require.NoError(t, err)
builder := NewGoBuilder()
detected, err := builder.Detect(dir)
detected, err := builder.Detect(fs, dir)
assert.NoError(t, err)
assert.True(t, detected)
})
@ -73,7 +75,7 @@ func TestGoBuilder_Detect_Good(t *testing.T) {
require.NoError(t, err)
builder := NewGoBuilder()
detected, err := builder.Detect(dir)
detected, err := builder.Detect(fs, dir)
assert.NoError(t, err)
assert.False(t, detected)
})
@ -82,7 +84,7 @@ func TestGoBuilder_Detect_Good(t *testing.T) {
dir := t.TempDir()
builder := NewGoBuilder()
detected, err := builder.Detect(dir)
detected, err := builder.Detect(fs, dir)
assert.NoError(t, err)
assert.False(t, detected)
})
@ -99,6 +101,7 @@ func TestGoBuilder_Build_Good(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "testbinary",
@ -133,6 +136,7 @@ func TestGoBuilder_Build_Good(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "multitest",
@ -160,6 +164,7 @@ func TestGoBuilder_Build_Good(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "wintest",
@ -183,6 +188,7 @@ func TestGoBuilder_Build_Good(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "", // Empty name
@ -209,6 +215,7 @@ func TestGoBuilder_Build_Good(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "ldflagstest",
@ -230,6 +237,7 @@ func TestGoBuilder_Build_Good(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "nestedtest",
@ -261,6 +269,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: t.TempDir(),
Name: "test",
@ -279,6 +288,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: "/nonexistent/path",
OutputDir: t.TempDir(),
Name: "test",
@ -309,6 +319,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: dir,
OutputDir: t.TempDir(),
Name: "test",
@ -335,6 +346,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "partialtest",
@ -360,6 +372,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) {
builder := NewGoBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: t.TempDir(),
Name: "canceltest",

View file

@ -10,6 +10,7 @@ import (
"strings"
"github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/io"
)
// LinuxKitBuilder builds LinuxKit images.
@ -26,15 +27,23 @@ func (b *LinuxKitBuilder) Name() string {
}
// Detect checks if a linuxkit.yml or .yml config exists in the directory.
func (b *LinuxKitBuilder) Detect(dir string) (bool, error) {
func (b *LinuxKitBuilder) Detect(fs io.Medium, dir string) (bool, error) {
// Check for linuxkit.yml
if _, err := os.Stat(filepath.Join(dir, "linuxkit.yml")); err == nil {
if fs.IsFile(filepath.Join(dir, "linuxkit.yml")) {
return true, nil
}
// Check for .core/linuxkit/*.yml
if matches, _ := filepath.Glob(filepath.Join(dir, ".core", "linuxkit", "*.yml")); len(matches) > 0 {
// Check for .core/linuxkit/
lkDir := filepath.Join(dir, ".core", "linuxkit")
if fs.IsDir(lkDir) {
entries, err := fs.List(lkDir)
if err == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".yml") {
return true, nil
}
}
}
}
return false, nil
}
@ -49,13 +58,21 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets
configPath := cfg.LinuxKitConfig
if configPath == "" {
// Auto-detect
if _, err := os.Stat(filepath.Join(cfg.ProjectDir, "linuxkit.yml")); err == nil {
if cfg.FS.IsFile(filepath.Join(cfg.ProjectDir, "linuxkit.yml")) {
configPath = filepath.Join(cfg.ProjectDir, "linuxkit.yml")
} else {
// Look in .core/linuxkit/
matches, _ := filepath.Glob(filepath.Join(cfg.ProjectDir, ".core", "linuxkit", "*.yml"))
if len(matches) > 0 {
configPath = matches[0]
lkDir := filepath.Join(cfg.ProjectDir, ".core", "linuxkit")
if cfg.FS.IsDir(lkDir) {
entries, err := cfg.FS.List(lkDir)
if err == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".yml") {
configPath = filepath.Join(lkDir, entry.Name())
break
}
}
}
}
}
}
@ -65,7 +82,7 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets
}
// Validate config file exists
if _, err := os.Stat(configPath); err != nil {
if !cfg.FS.IsFile(configPath) {
return nil, fmt.Errorf("linuxkit.Build: config file not found: %s", configPath)
}
@ -80,7 +97,7 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets
if outputDir == "" {
outputDir = filepath.Join(cfg.ProjectDir, "dist")
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
if err := cfg.FS.EnsureDir(outputDir); err != nil {
return nil, fmt.Errorf("linuxkit.Build: failed to create output directory: %w", err)
}
@ -125,9 +142,9 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets
artifactPath := b.getArtifactPath(outputDir, outputName, format)
// Verify the artifact was created
if _, err := os.Stat(artifactPath); err != nil {
if !cfg.FS.Exists(artifactPath) {
// Try alternate naming conventions
artifactPath = b.findArtifact(outputDir, outputName, format)
artifactPath = b.findArtifact(cfg.FS, outputDir, outputName, format)
if artifactPath == "" {
return nil, fmt.Errorf("linuxkit.Build: artifact not found after build: expected %s", b.getArtifactPath(outputDir, outputName, format))
}
@ -175,7 +192,7 @@ func (b *LinuxKitBuilder) getArtifactPath(outputDir, outputName, format string)
}
// findArtifact searches for the built artifact with various naming conventions.
func (b *LinuxKitBuilder) findArtifact(outputDir, outputName, format string) string {
func (b *LinuxKitBuilder) findArtifact(fs io.Medium, outputDir, outputName, format string) string {
// LinuxKit can create files with different suffixes
extensions := []string{
b.getFormatExtension(format),
@ -185,20 +202,25 @@ func (b *LinuxKitBuilder) findArtifact(outputDir, outputName, format string) str
for _, ext := range extensions {
path := filepath.Join(outputDir, outputName+ext)
if _, err := os.Stat(path); err == nil {
if fs.Exists(path) {
return path
}
}
// Try to find any file matching the output name
matches, _ := filepath.Glob(filepath.Join(outputDir, outputName+"*"))
for _, match := range matches {
entries, err := fs.List(outputDir)
if err == nil {
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), outputName) {
match := filepath.Join(outputDir, entry.Name())
// Return first match that looks like an image
ext := filepath.Ext(match)
if ext == ".iso" || ext == ".qcow2" || ext == ".raw" || ext == ".vmdk" || ext == ".vhd" {
return match
}
}
}
}
return ""
}

View file

@ -10,6 +10,7 @@ import (
"strings"
"github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/io"
)
// TaskfileBuilder builds projects using Taskfile (https://taskfile.dev/).
@ -27,7 +28,7 @@ func (b *TaskfileBuilder) Name() string {
}
// Detect checks if a Taskfile exists in the directory.
func (b *TaskfileBuilder) Detect(dir string) (bool, error) {
func (b *TaskfileBuilder) Detect(fs io.Medium, dir string) (bool, error) {
// Check for Taskfile.yml, Taskfile.yaml, or Taskfile
taskfiles := []string{
"Taskfile.yml",
@ -38,7 +39,7 @@ func (b *TaskfileBuilder) Detect(dir string) (bool, error) {
}
for _, tf := range taskfiles {
if _, err := os.Stat(filepath.Join(dir, tf)); err == nil {
if fs.IsFile(filepath.Join(dir, tf)) {
return true, nil
}
}
@ -57,7 +58,7 @@ func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets
if outputDir == "" {
outputDir = filepath.Join(cfg.ProjectDir, "dist")
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
if err := cfg.FS.EnsureDir(outputDir); err != nil {
return nil, fmt.Errorf("taskfile.Build: failed to create output directory: %w", err)
}
@ -70,7 +71,7 @@ func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets
}
// Try to find artifacts in output directory
found := b.findArtifacts(outputDir)
found := b.findArtifacts(cfg.FS, outputDir)
artifacts = append(artifacts, found...)
} else {
// Run build task for each target
@ -80,7 +81,7 @@ func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets
}
// Try to find artifacts for this target
found := b.findArtifactsForTarget(outputDir, target)
found := b.findArtifactsForTarget(cfg.FS, outputDir, target)
artifacts = append(artifacts, found...)
}
}
@ -147,10 +148,10 @@ func (b *TaskfileBuilder) runTask(ctx context.Context, cfg *build.Config, goos,
}
// findArtifacts searches for built artifacts in the output directory.
func (b *TaskfileBuilder) findArtifacts(outputDir string) []build.Artifact {
func (b *TaskfileBuilder) findArtifacts(fs io.Medium, outputDir string) []build.Artifact {
var artifacts []build.Artifact
entries, err := os.ReadDir(outputDir)
entries, err := fs.List(outputDir)
if err != nil {
return artifacts
}
@ -177,13 +178,13 @@ func (b *TaskfileBuilder) findArtifacts(outputDir string) []build.Artifact {
}
// findArtifactsForTarget searches for built artifacts for a specific target.
func (b *TaskfileBuilder) findArtifactsForTarget(outputDir string, target build.Target) []build.Artifact {
func (b *TaskfileBuilder) findArtifactsForTarget(fs io.Medium, outputDir string, target build.Target) []build.Artifact {
var artifacts []build.Artifact
// 1. Look for platform-specific subdirectory: output/os_arch/
platformSubdir := filepath.Join(outputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch))
if info, err := os.Stat(platformSubdir); err == nil && info.IsDir() {
entries, _ := os.ReadDir(platformSubdir)
if fs.IsDir(platformSubdir) {
entries, _ := fs.List(platformSubdir)
for _, entry := range entries {
if entry.IsDir() {
// Handle .app bundles on macOS
@ -219,19 +220,23 @@ func (b *TaskfileBuilder) findArtifactsForTarget(outputDir string, target build.
}
for _, pattern := range patterns {
matches, _ := filepath.Glob(filepath.Join(outputDir, pattern))
for _, match := range matches {
info, err := os.Stat(match)
if err != nil || info.IsDir() {
entries, _ := fs.List(outputDir)
for _, entry := range entries {
match := entry.Name()
// Simple glob matching
if b.matchPattern(match, pattern) {
fullPath := filepath.Join(outputDir, match)
if fs.IsDir(fullPath) {
continue
}
artifacts = append(artifacts, build.Artifact{
Path: match,
Path: fullPath,
OS: target.OS,
Arch: target.Arch,
})
}
}
if len(artifacts) > 0 {
break // Found matches, stop looking
@ -241,6 +246,12 @@ func (b *TaskfileBuilder) findArtifactsForTarget(outputDir string, target build.
return artifacts
}
// matchPattern implements glob matching for Taskfile artifacts.
func (b *TaskfileBuilder) matchPattern(name, pattern string) bool {
matched, _ := filepath.Match(pattern, name)
return matched
}
// validateTaskCli checks if the task CLI is available.
func (b *TaskfileBuilder) validateTaskCli() error {
// Check PATH first

View file

@ -4,12 +4,12 @@ package builders
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/io"
)
// WailsBuilder implements the Builder interface for Wails v3 projects.
@ -27,8 +27,8 @@ func (b *WailsBuilder) Name() string {
// Detect checks if this builder can handle the project in the given directory.
// Uses IsWailsProject from the build package which checks for wails.json.
func (b *WailsBuilder) Detect(dir string) (bool, error) {
return build.IsWailsProject(dir), nil
func (b *WailsBuilder) Detect(fs io.Medium, dir string) (bool, error) {
return build.IsWailsProject(fs, dir), nil
}
// Build compiles the Wails project for the specified targets.
@ -45,12 +45,12 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b
}
// Detect Wails version
isV3 := b.isWailsV3(cfg.ProjectDir)
isV3 := b.isWailsV3(cfg.FS, cfg.ProjectDir)
if isV3 {
// Wails v3 strategy: Delegate to Taskfile
taskBuilder := NewTaskfileBuilder()
if detected, _ := taskBuilder.Detect(cfg.ProjectDir); detected {
if detected, _ := taskBuilder.Detect(cfg.FS, cfg.ProjectDir); detected {
return taskBuilder.Build(ctx, cfg, targets)
}
return nil, fmt.Errorf("wails v3 projects require a Taskfile for building")
@ -58,7 +58,7 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b
// Wails v2 strategy: Use 'wails build'
// Ensure output directory exists
if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil {
if err := cfg.FS.EnsureDir(cfg.OutputDir); err != nil {
return nil, fmt.Errorf("builders.WailsBuilder.Build: failed to create output directory: %w", err)
}
@ -78,13 +78,13 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b
}
// isWailsV3 checks if the project uses Wails v3 by inspecting go.mod.
func (b *WailsBuilder) isWailsV3(dir string) bool {
func (b *WailsBuilder) isWailsV3(fs io.Medium, dir string) bool {
goModPath := filepath.Join(dir, "go.mod")
data, err := os.ReadFile(goModPath)
content, err := fs.Read(goModPath)
if err != nil {
return false
}
return strings.Contains(string(data), "github.com/wailsapp/wails/v3")
return strings.Contains(content, "github.com/wailsapp/wails/v3")
}
// buildV2Target compiles for a single target platform using wails (v2).
@ -123,7 +123,7 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar
wailsOutputDir := filepath.Join(cfg.ProjectDir, "build", "bin")
// Find the artifact in Wails output dir
sourcePath, err := b.findArtifact(wailsOutputDir, binaryName, target)
sourcePath, err := b.findArtifact(cfg.FS, wailsOutputDir, binaryName, target)
if err != nil {
return build.Artifact{}, fmt.Errorf("failed to find Wails v2 build artifact: %w", err)
}
@ -131,18 +131,18 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar
// Move/Copy to our output dir
// Create platform specific dir in our output
platformDir := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch))
if err := os.MkdirAll(platformDir, 0755); err != nil {
if err := cfg.FS.EnsureDir(platformDir); err != nil {
return build.Artifact{}, fmt.Errorf("failed to create output dir: %w", err)
}
destPath := filepath.Join(platformDir, filepath.Base(sourcePath))
// Simple copy
input, err := os.ReadFile(sourcePath)
// Simple copy using the medium
content, err := cfg.FS.Read(sourcePath)
if err != nil {
return build.Artifact{}, err
}
if err := os.WriteFile(destPath, input, 0755); err != nil {
if err := cfg.FS.Write(destPath, content); err != nil {
return build.Artifact{}, err
}
@ -154,7 +154,7 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar
}
// findArtifact locates the built artifact based on the target platform.
func (b *WailsBuilder) findArtifact(platformDir, binaryName string, target build.Target) (string, error) {
func (b *WailsBuilder) findArtifact(fs io.Medium, platformDir, binaryName string, target build.Target) (string, error) {
var candidates []string
switch target.OS {
@ -181,13 +181,13 @@ func (b *WailsBuilder) findArtifact(platformDir, binaryName string, target build
// Try each candidate
for _, candidate := range candidates {
if fileOrDirExists(candidate) {
if fs.Exists(candidate) {
return candidate, nil
}
}
// If no specific candidate found, try to find any executable or package in the directory
entries, err := os.ReadDir(platformDir)
entries, err := fs.List(platformDir)
if err != nil {
return "", fmt.Errorf("failed to read platform directory: %w", err)
}
@ -221,7 +221,7 @@ func (b *WailsBuilder) findArtifact(platformDir, binaryName string, target build
// detectPackageManager detects the frontend package manager based on lock files.
// Returns "bun", "pnpm", "yarn", or "npm" (default).
func detectPackageManager(dir string) string {
func detectPackageManager(fs io.Medium, dir string) string {
// Check in priority order: bun, pnpm, yarn, npm
lockFiles := []struct {
file string
@ -234,7 +234,7 @@ func detectPackageManager(dir string) string {
}
for _, lf := range lockFiles {
if fileExists(filepath.Join(dir, lf.file)) {
if fs.IsFile(filepath.Join(dir, lf.file)) {
return lf.manager
}
}
@ -243,20 +243,5 @@ func detectPackageManager(dir string) string {
return "npm"
}
// 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()
}
// fileOrDirExists checks if a file or directory exists.
func fileOrDirExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Ensure WailsBuilder implements the Builder interface.
var _ build.Builder = (*WailsBuilder)(nil)

View file

@ -9,6 +9,7 @@ import (
"testing"
"github.com/host-uk/core/pkg/build"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -91,6 +92,7 @@ func TestWailsBuilder_Build_Taskfile_Good(t *testing.T) {
}
t.Run("delegates to Taskfile if present", func(t *testing.T) {
fs := io.Local
projectDir := setupWailsTestProject(t)
outputDir := t.TempDir()
@ -107,6 +109,7 @@ tasks:
builder := NewWailsBuilder()
cfg := &build.Config{
FS: fs,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "testapp",
@ -136,11 +139,13 @@ func TestWailsBuilder_Build_V2_Good(t *testing.T) {
}
t.Run("builds v2 project", func(t *testing.T) {
fs := io.Local
projectDir := setupWailsV2TestProject(t)
outputDir := t.TempDir()
builder := NewWailsBuilder()
cfg := &build.Config{
FS: fs,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "testapp",
@ -158,13 +163,14 @@ func TestWailsBuilder_Build_V2_Good(t *testing.T) {
}
func TestWailsBuilder_Detect_Good(t *testing.T) {
fs := io.Local
t.Run("detects Wails project with wails.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte("{}"), 0644)
require.NoError(t, err)
builder := NewWailsBuilder()
detected, err := builder.Detect(dir)
detected, err := builder.Detect(fs, dir)
assert.NoError(t, err)
assert.True(t, detected)
})
@ -175,7 +181,7 @@ func TestWailsBuilder_Detect_Good(t *testing.T) {
require.NoError(t, err)
builder := NewWailsBuilder()
detected, err := builder.Detect(dir)
detected, err := builder.Detect(fs, dir)
assert.NoError(t, err)
assert.False(t, detected)
})
@ -186,7 +192,7 @@ func TestWailsBuilder_Detect_Good(t *testing.T) {
require.NoError(t, err)
builder := NewWailsBuilder()
detected, err := builder.Detect(dir)
detected, err := builder.Detect(fs, dir)
assert.NoError(t, err)
assert.False(t, detected)
})
@ -195,19 +201,20 @@ func TestWailsBuilder_Detect_Good(t *testing.T) {
dir := t.TempDir()
builder := NewWailsBuilder()
detected, err := builder.Detect(dir)
detected, err := builder.Detect(fs, dir)
assert.NoError(t, err)
assert.False(t, detected)
})
}
func TestDetectPackageManager_Good(t *testing.T) {
fs := io.Local
t.Run("detects bun from bun.lockb", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644)
require.NoError(t, err)
result := detectPackageManager(dir)
result := detectPackageManager(fs, dir)
assert.Equal(t, "bun", result)
})
@ -216,7 +223,7 @@ func TestDetectPackageManager_Good(t *testing.T) {
err := os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte(""), 0644)
require.NoError(t, err)
result := detectPackageManager(dir)
result := detectPackageManager(fs, dir)
assert.Equal(t, "pnpm", result)
})
@ -225,7 +232,7 @@ func TestDetectPackageManager_Good(t *testing.T) {
err := os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)
require.NoError(t, err)
result := detectPackageManager(dir)
result := detectPackageManager(fs, dir)
assert.Equal(t, "yarn", result)
})
@ -234,14 +241,14 @@ func TestDetectPackageManager_Good(t *testing.T) {
err := os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644)
require.NoError(t, err)
result := detectPackageManager(dir)
result := detectPackageManager(fs, dir)
assert.Equal(t, "npm", result)
})
t.Run("defaults to npm when no lock file", func(t *testing.T) {
dir := t.TempDir()
result := detectPackageManager(dir)
result := detectPackageManager(fs, dir)
assert.Equal(t, "npm", result)
})
@ -252,7 +259,7 @@ func TestDetectPackageManager_Good(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644))
result := detectPackageManager(dir)
result := detectPackageManager(fs, dir)
assert.Equal(t, "bun", result)
})
@ -263,7 +270,7 @@ func TestDetectPackageManager_Good(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644))
result := detectPackageManager(dir)
result := detectPackageManager(fs, dir)
assert.Equal(t, "pnpm", result)
})
@ -273,7 +280,7 @@ func TestDetectPackageManager_Good(t *testing.T) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644))
result := detectPackageManager(dir)
result := detectPackageManager(fs, dir)
assert.Equal(t, "yarn", result)
})
}
@ -293,6 +300,7 @@ func TestWailsBuilder_Build_Bad(t *testing.T) {
builder := NewWailsBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: t.TempDir(),
Name: "test",
@ -321,6 +329,7 @@ func TestWailsBuilder_Build_Good(t *testing.T) {
builder := NewWailsBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "testapp",
@ -359,6 +368,7 @@ func TestWailsBuilder_Ugly(t *testing.T) {
builder := NewWailsBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: dir,
OutputDir: t.TempDir(),
Name: "test",
@ -386,6 +396,7 @@ func TestWailsBuilder_Ugly(t *testing.T) {
builder := NewWailsBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: t.TempDir(),
Name: "canceltest",

View file

@ -6,20 +6,21 @@ import (
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
io_interface "github.com/host-uk/core/pkg/io"
"sort"
"strings"
)
// Checksum computes SHA256 for an artifact and returns the artifact with the Checksum field filled.
func Checksum(artifact Artifact) (Artifact, error) {
func Checksum(fs io_interface.Medium, 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)
file, err := fs.Open(artifact.Path)
if err != nil {
return Artifact{}, fmt.Errorf("build.Checksum: failed to open file: %w", err)
}
@ -43,14 +44,14 @@ func Checksum(artifact Artifact) (Artifact, error) {
// ChecksumAll computes checksums for all artifacts.
// Returns a slice of artifacts with their Checksum fields filled.
func ChecksumAll(artifacts []Artifact) ([]Artifact, error) {
func ChecksumAll(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) {
if len(artifacts) == 0 {
return nil, nil
}
var checksummed []Artifact
for _, artifact := range artifacts {
cs, err := Checksum(artifact)
cs, err := Checksum(fs, artifact)
if err != nil {
return checksummed, fmt.Errorf("build.ChecksumAll: failed to checksum %s: %w", artifact.Path, err)
}
@ -67,7 +68,7 @@ func ChecksumAll(artifacts []Artifact) ([]Artifact, error) {
//
// 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 {
func WriteChecksumFile(fs io_interface.Medium, artifacts []Artifact, path string) error {
if len(artifacts) == 0 {
return nil
}
@ -87,14 +88,8 @@ func WriteChecksumFile(artifacts []Artifact, path string) error {
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 {
// Write the file using the medium (which handles directory creation in Write)
if err := fs.Write(path, content); err != nil {
return fmt.Errorf("build.WriteChecksumFile: failed to write file: %w", err)
}

View file

@ -6,6 +6,7 @@ import (
"strings"
"testing"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -23,6 +24,7 @@ func setupChecksumTestFile(t *testing.T, content string) string {
}
func TestChecksum_Good(t *testing.T) {
fs := io.Local
t.Run("computes SHA256 checksum", func(t *testing.T) {
// Known SHA256 of "Hello, World!\n"
path := setupChecksumTestFile(t, "Hello, World!\n")
@ -34,7 +36,7 @@ func TestChecksum_Good(t *testing.T) {
Arch: "amd64",
}
result, err := Checksum(artifact)
result, err := Checksum(fs, artifact)
require.NoError(t, err)
assert.Equal(t, expectedChecksum, result.Checksum)
})
@ -48,7 +50,7 @@ func TestChecksum_Good(t *testing.T) {
Arch: "arm64",
}
result, err := Checksum(artifact)
result, err := Checksum(fs, artifact)
require.NoError(t, err)
assert.Equal(t, path, result.Path)
@ -62,7 +64,7 @@ func TestChecksum_Good(t *testing.T) {
artifact := Artifact{Path: path, OS: "linux", Arch: "amd64"}
result, err := Checksum(artifact)
result, err := Checksum(fs, artifact)
require.NoError(t, err)
// SHA256 produces 32 bytes = 64 hex characters
@ -73,10 +75,10 @@ func TestChecksum_Good(t *testing.T) {
path1 := setupChecksumTestFile(t, "content one")
path2 := setupChecksumTestFile(t, "content two")
result1, err := Checksum(Artifact{Path: path1, OS: "linux", Arch: "amd64"})
result1, err := Checksum(fs, Artifact{Path: path1, OS: "linux", Arch: "amd64"})
require.NoError(t, err)
result2, err := Checksum(Artifact{Path: path2, OS: "linux", Arch: "amd64"})
result2, err := Checksum(fs, Artifact{Path: path2, OS: "linux", Arch: "amd64"})
require.NoError(t, err)
assert.NotEqual(t, result1.Checksum, result2.Checksum)
@ -87,10 +89,10 @@ func TestChecksum_Good(t *testing.T) {
path1 := setupChecksumTestFile(t, content)
path2 := setupChecksumTestFile(t, content)
result1, err := Checksum(Artifact{Path: path1, OS: "linux", Arch: "amd64"})
result1, err := Checksum(fs, Artifact{Path: path1, OS: "linux", Arch: "amd64"})
require.NoError(t, err)
result2, err := Checksum(Artifact{Path: path2, OS: "linux", Arch: "amd64"})
result2, err := Checksum(fs, Artifact{Path: path2, OS: "linux", Arch: "amd64"})
require.NoError(t, err)
assert.Equal(t, result1.Checksum, result2.Checksum)
@ -98,6 +100,7 @@ func TestChecksum_Good(t *testing.T) {
}
func TestChecksum_Bad(t *testing.T) {
fs := io.Local
t.Run("returns error for empty path", func(t *testing.T) {
artifact := Artifact{
Path: "",
@ -105,7 +108,7 @@ func TestChecksum_Bad(t *testing.T) {
Arch: "amd64",
}
result, err := Checksum(artifact)
result, err := Checksum(fs, artifact)
assert.Error(t, err)
assert.Contains(t, err.Error(), "artifact path is empty")
assert.Empty(t, result.Checksum)
@ -118,7 +121,7 @@ func TestChecksum_Bad(t *testing.T) {
Arch: "amd64",
}
result, err := Checksum(artifact)
result, err := Checksum(fs, artifact)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to open file")
assert.Empty(t, result.Checksum)
@ -126,6 +129,7 @@ func TestChecksum_Bad(t *testing.T) {
}
func TestChecksumAll_Good(t *testing.T) {
fs := io.Local
t.Run("checksums multiple artifacts", func(t *testing.T) {
paths := []string{
setupChecksumTestFile(t, "content one"),
@ -139,7 +143,7 @@ func TestChecksumAll_Good(t *testing.T) {
{Path: paths[2], OS: "windows", Arch: "amd64"},
}
results, err := ChecksumAll(artifacts)
results, err := ChecksumAll(fs, artifacts)
require.NoError(t, err)
require.Len(t, results, 3)
@ -152,19 +156,20 @@ func TestChecksumAll_Good(t *testing.T) {
})
t.Run("returns nil for empty slice", func(t *testing.T) {
results, err := ChecksumAll([]Artifact{})
results, err := ChecksumAll(fs, []Artifact{})
assert.NoError(t, err)
assert.Nil(t, results)
})
t.Run("returns nil for nil slice", func(t *testing.T) {
results, err := ChecksumAll(nil)
results, err := ChecksumAll(fs, nil)
assert.NoError(t, err)
assert.Nil(t, results)
})
}
func TestChecksumAll_Bad(t *testing.T) {
fs := io.Local
t.Run("returns partial results on error", func(t *testing.T) {
path := setupChecksumTestFile(t, "valid content")
@ -173,7 +178,7 @@ func TestChecksumAll_Bad(t *testing.T) {
{Path: "/nonexistent/file", OS: "linux", Arch: "arm64"}, // This will fail
}
results, err := ChecksumAll(artifacts)
results, err := ChecksumAll(fs, artifacts)
assert.Error(t, err)
// Should have the first successful result
assert.Len(t, results, 1)
@ -182,6 +187,7 @@ func TestChecksumAll_Bad(t *testing.T) {
}
func TestWriteChecksumFile_Good(t *testing.T) {
fs := io.Local
t.Run("writes checksum file with correct format", func(t *testing.T) {
dir := t.TempDir()
checksumPath := filepath.Join(dir, "CHECKSUMS.txt")
@ -191,7 +197,7 @@ func TestWriteChecksumFile_Good(t *testing.T) {
{Path: "/output/app_darwin_arm64.tar.gz", Checksum: "789xyz000111", OS: "darwin", Arch: "arm64"},
}
err := WriteChecksumFile(artifacts, checksumPath)
err := WriteChecksumFile(fs, artifacts, checksumPath)
require.NoError(t, err)
// Read and verify content
@ -214,7 +220,7 @@ func TestWriteChecksumFile_Good(t *testing.T) {
{Path: "/output/app.tar.gz", Checksum: "abc123", OS: "linux", Arch: "amd64"},
}
err := WriteChecksumFile(artifacts, checksumPath)
err := WriteChecksumFile(fs, artifacts, checksumPath)
require.NoError(t, err)
assert.FileExists(t, checksumPath)
})
@ -223,7 +229,7 @@ func TestWriteChecksumFile_Good(t *testing.T) {
dir := t.TempDir()
checksumPath := filepath.Join(dir, "CHECKSUMS.txt")
err := WriteChecksumFile([]Artifact{}, checksumPath)
err := WriteChecksumFile(fs, []Artifact{}, checksumPath)
require.NoError(t, err)
// File should not exist
@ -235,7 +241,7 @@ func TestWriteChecksumFile_Good(t *testing.T) {
dir := t.TempDir()
checksumPath := filepath.Join(dir, "CHECKSUMS.txt")
err := WriteChecksumFile(nil, checksumPath)
err := WriteChecksumFile(fs, nil, checksumPath)
require.NoError(t, err)
})
@ -247,7 +253,7 @@ func TestWriteChecksumFile_Good(t *testing.T) {
{Path: "/some/deep/nested/path/myapp_linux_amd64.tar.gz", Checksum: "checksum123", OS: "linux", Arch: "amd64"},
}
err := WriteChecksumFile(artifacts, checksumPath)
err := WriteChecksumFile(fs, artifacts, checksumPath)
require.NoError(t, err)
content, err := os.ReadFile(checksumPath)
@ -260,6 +266,7 @@ func TestWriteChecksumFile_Good(t *testing.T) {
}
func TestWriteChecksumFile_Bad(t *testing.T) {
fs := io.Local
t.Run("returns error for artifact without checksum", func(t *testing.T) {
dir := t.TempDir()
checksumPath := filepath.Join(dir, "CHECKSUMS.txt")
@ -268,7 +275,7 @@ func TestWriteChecksumFile_Bad(t *testing.T) {
{Path: "/output/app.tar.gz", Checksum: "", OS: "linux", Arch: "amd64"}, // No checksum
}
err := WriteChecksumFile(artifacts, checksumPath)
err := WriteChecksumFile(fs, artifacts, checksumPath)
assert.Error(t, err)
assert.Contains(t, err.Error(), "has no checksum")
})

View file

@ -8,6 +8,7 @@ import (
"path/filepath"
"github.com/host-uk/core/pkg/build/signing"
"github.com/host-uk/core/pkg/io"
"gopkg.in/yaml.v3"
)
@ -68,10 +69,10 @@ type TargetConfig struct {
// 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) {
func LoadConfig(fs io.Medium, dir string) (*BuildConfig, error) {
configPath := filepath.Join(dir, ConfigDir, ConfigFileName)
data, err := os.ReadFile(configPath)
content, err := fs.Read(configPath)
if err != nil {
if os.IsNotExist(err) {
return DefaultConfig(), nil
@ -80,6 +81,7 @@ func LoadConfig(dir string) (*BuildConfig, error) {
}
var cfg BuildConfig
data := []byte(content)
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("build.LoadConfig: failed to parse config file: %w", err)
}
@ -153,8 +155,8 @@ func ConfigPath(dir string) string {
}
// ConfigExists checks if a build config file exists in the given directory.
func ConfigExists(dir string) bool {
return fileExists(ConfigPath(dir))
func ConfigExists(fs io.Medium, dir string) bool {
return fileExists(fs, ConfigPath(dir))
}
// ToTargets converts TargetConfig slice to Target slice for use with builders.

View file

@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -28,6 +29,7 @@ func setupConfigTestDir(t *testing.T, configContent string) string {
}
func TestLoadConfig_Good(t *testing.T) {
fs := io.Local
t.Run("loads valid config", func(t *testing.T) {
content := `
version: 1
@ -54,7 +56,7 @@ targets:
`
dir := setupConfigTestDir(t, content)
cfg, err := LoadConfig(dir)
cfg, err := LoadConfig(fs, dir)
require.NoError(t, err)
require.NotNil(t, cfg)
@ -77,7 +79,7 @@ targets:
t.Run("returns defaults when config file missing", func(t *testing.T) {
dir := t.TempDir()
cfg, err := LoadConfig(dir)
cfg, err := LoadConfig(fs, dir)
require.NoError(t, err)
require.NotNil(t, cfg)
@ -98,7 +100,7 @@ project:
`
dir := setupConfigTestDir(t, content)
cfg, err := LoadConfig(dir)
cfg, err := LoadConfig(fs, dir)
require.NoError(t, err)
require.NotNil(t, cfg)
@ -128,7 +130,7 @@ targets:
`
dir := setupConfigTestDir(t, content)
cfg, err := LoadConfig(dir)
cfg, err := LoadConfig(fs, dir)
require.NoError(t, err)
require.NotNil(t, cfg)
@ -141,6 +143,7 @@ targets:
}
func TestLoadConfig_Bad(t *testing.T) {
fs := io.Local
t.Run("returns error for invalid YAML", func(t *testing.T) {
content := `
version: 1
@ -149,7 +152,7 @@ project:
`
dir := setupConfigTestDir(t, content)
cfg, err := LoadConfig(dir)
cfg, err := LoadConfig(fs, dir)
assert.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "failed to parse config file")
@ -166,7 +169,7 @@ project:
err = os.Mkdir(configPath, 0755)
require.NoError(t, err)
cfg, err := LoadConfig(dir)
cfg, err := LoadConfig(fs, dir)
assert.Error(t, err)
assert.Nil(t, cfg)
assert.Contains(t, err.Error(), "failed to read config file")
@ -217,19 +220,20 @@ func TestConfigPath_Good(t *testing.T) {
}
func TestConfigExists_Good(t *testing.T) {
fs := io.Local
t.Run("returns true when config exists", func(t *testing.T) {
dir := setupConfigTestDir(t, "version: 1")
assert.True(t, ConfigExists(dir))
assert.True(t, ConfigExists(fs, dir))
})
t.Run("returns false when config missing", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, ConfigExists(dir))
assert.False(t, ConfigExists(fs, dir))
})
t.Run("returns false when .core dir missing", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, ConfigExists(dir))
assert.False(t, ConfigExists(fs, dir))
})
}
@ -249,7 +253,7 @@ sign:
`
_ = os.WriteFile(filepath.Join(coreDir, "build.yaml"), []byte(configContent), 0644)
cfg, err := LoadConfig(tmpDir)
cfg, err := LoadConfig(io.Local, tmpDir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -298,8 +302,12 @@ func TestBuildConfig_ToTargets_Good(t *testing.T) {
// TestLoadConfig_Testdata tests loading from the testdata fixture.
func TestLoadConfig_Testdata(t *testing.T) {
fs := io.Local
abs, err := filepath.Abs("testdata/config-project")
require.NoError(t, err)
t.Run("loads config-project fixture", func(t *testing.T) {
cfg, err := LoadConfig("testdata/config-project")
cfg, err := LoadConfig(fs, abs)
require.NoError(t, err)
require.NotNil(t, cfg)

View file

@ -1,9 +1,10 @@
package build
import (
"os"
"path/filepath"
"slices"
"github.com/host-uk/core/pkg/io"
)
// Marker files for project type detection.
@ -32,12 +33,12 @@ var markers = []projectMarker{
// 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) {
func Discover(fs io.Medium, dir string) ([]ProjectType, error) {
var detected []ProjectType
for _, m := range markers {
path := filepath.Join(dir, m.file)
if fileExists(path) {
if fileExists(fs, path) {
// Avoid duplicates (shouldn't happen with current markers, but defensive)
if !slices.Contains(detected, m.projectType) {
detected = append(detected, m.projectType)
@ -50,8 +51,8 @@ func Discover(dir string) ([]ProjectType, error) {
// 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)
func PrimaryType(fs io.Medium, dir string) (ProjectType, error) {
types, err := Discover(fs, dir)
if err != nil {
return "", err
}
@ -62,31 +63,27 @@ func PrimaryType(dir string) (ProjectType, error) {
}
// 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))
func IsGoProject(fs io.Medium, dir string) bool {
return fileExists(fs, filepath.Join(dir, markerGoMod)) ||
fileExists(fs, filepath.Join(dir, markerWails))
}
// IsWailsProject checks if the directory contains a Wails project.
func IsWailsProject(dir string) bool {
return fileExists(filepath.Join(dir, markerWails))
func IsWailsProject(fs io.Medium, dir string) bool {
return fileExists(fs, 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))
func IsNodeProject(fs io.Medium, dir string) bool {
return fileExists(fs, filepath.Join(dir, markerNodePackage))
}
// IsPHPProject checks if the directory contains a PHP project.
func IsPHPProject(dir string) bool {
return fileExists(filepath.Join(dir, markerComposer))
func IsPHPProject(fs io.Medium, dir string) bool {
return fileExists(fs, 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()
func fileExists(fs io.Medium, path string) bool {
return fs.IsFile(path)
}

View file

@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -22,52 +23,54 @@ func setupTestDir(t *testing.T, markers ...string) string {
}
func TestDiscover_Good(t *testing.T) {
fs := io.Local
t.Run("detects Go project", func(t *testing.T) {
dir := setupTestDir(t, "go.mod")
types, err := Discover(dir)
types, err := Discover(fs, 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)
types, err := Discover(fs, 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)
types, err := Discover(fs, 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)
types, err := Discover(fs, 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)
types, err := Discover(fs, 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)
types, err := Discover(fs, dir)
assert.NoError(t, err)
assert.Empty(t, types)
})
}
func TestDiscover_Bad(t *testing.T) {
fs := io.Local
t.Run("non-existent directory returns empty slice", func(t *testing.T) {
types, err := Discover("/non/existent/path")
types, err := Discover(fs, "/non/existent/path")
assert.NoError(t, err) // os.Stat fails silently in fileExists
assert.Empty(t, types)
})
@ -78,85 +81,90 @@ func TestDiscover_Bad(t *testing.T) {
err := os.Mkdir(filepath.Join(dir, "go.mod"), 0755)
require.NoError(t, err)
types, err := Discover(dir)
types, err := Discover(fs, dir)
assert.NoError(t, err)
assert.Empty(t, types)
})
}
func TestPrimaryType_Good(t *testing.T) {
fs := io.Local
t.Run("returns wails for wails project", func(t *testing.T) {
dir := setupTestDir(t, "wails.json", "go.mod")
primary, err := PrimaryType(dir)
primary, err := PrimaryType(fs, 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)
primary, err := PrimaryType(fs, 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)
primary, err := PrimaryType(fs, dir)
assert.NoError(t, err)
assert.Empty(t, primary)
})
}
func TestIsGoProject_Good(t *testing.T) {
fs := io.Local
t.Run("true with go.mod", func(t *testing.T) {
dir := setupTestDir(t, "go.mod")
assert.True(t, IsGoProject(dir))
assert.True(t, IsGoProject(fs, dir))
})
t.Run("true with wails.json", func(t *testing.T) {
dir := setupTestDir(t, "wails.json")
assert.True(t, IsGoProject(dir))
assert.True(t, IsGoProject(fs, dir))
})
t.Run("false without markers", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, IsGoProject(dir))
assert.False(t, IsGoProject(fs, dir))
})
}
func TestIsWailsProject_Good(t *testing.T) {
fs := io.Local
t.Run("true with wails.json", func(t *testing.T) {
dir := setupTestDir(t, "wails.json")
assert.True(t, IsWailsProject(dir))
assert.True(t, IsWailsProject(fs, dir))
})
t.Run("false with only go.mod", func(t *testing.T) {
dir := setupTestDir(t, "go.mod")
assert.False(t, IsWailsProject(dir))
assert.False(t, IsWailsProject(fs, dir))
})
}
func TestIsNodeProject_Good(t *testing.T) {
fs := io.Local
t.Run("true with package.json", func(t *testing.T) {
dir := setupTestDir(t, "package.json")
assert.True(t, IsNodeProject(dir))
assert.True(t, IsNodeProject(fs, dir))
})
t.Run("false without package.json", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, IsNodeProject(dir))
assert.False(t, IsNodeProject(fs, dir))
})
}
func TestIsPHPProject_Good(t *testing.T) {
fs := io.Local
t.Run("true with composer.json", func(t *testing.T) {
dir := setupTestDir(t, "composer.json")
assert.True(t, IsPHPProject(dir))
assert.True(t, IsPHPProject(fs, dir))
})
t.Run("false without composer.json", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, IsPHPProject(dir))
assert.False(t, IsPHPProject(fs, dir))
})
}
@ -166,28 +174,31 @@ func TestTarget_Good(t *testing.T) {
}
func TestFileExists_Good(t *testing.T) {
fs := io.Local
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))
assert.True(t, fileExists(fs, path))
})
t.Run("returns false for directory", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, fileExists(dir))
assert.False(t, fileExists(fs, dir))
})
t.Run("returns false for non-existent path", func(t *testing.T) {
assert.False(t, fileExists("/non/existent/file"))
assert.False(t, fileExists(fs, "/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"
fs := io.Local
testdataDir, err := filepath.Abs("testdata")
require.NoError(t, err)
tests := []struct {
name string
@ -205,7 +216,7 @@ func TestDiscover_Testdata(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := filepath.Join(testdataDir, tt.dir)
types, err := Discover(dir)
types, err := Discover(fs, dir)
assert.NoError(t, err)
if len(tt.expected) == 0 {
assert.Empty(t, types)

View file

@ -3,9 +3,10 @@ package signing
import (
"context"
"fmt"
"os"
"os/exec"
"runtime"
"github.com/host-uk/core/pkg/io"
)
// MacOSSigner signs binaries using macOS codesign.
@ -39,7 +40,7 @@ func (s *MacOSSigner) Available() bool {
}
// Sign codesigns a binary with hardened runtime.
func (s *MacOSSigner) Sign(ctx context.Context, binary string) error {
func (s *MacOSSigner) Sign(ctx context.Context, fs io.Medium, binary string) error {
if !s.Available() {
return fmt.Errorf("codesign.Sign: codesign not available")
}
@ -62,7 +63,7 @@ func (s *MacOSSigner) Sign(ctx context.Context, binary string) error {
// Notarize submits binary to Apple for notarization and staples the ticket.
// This blocks until Apple responds (typically 1-5 minutes).
func (s *MacOSSigner) Notarize(ctx context.Context, binary string) error {
func (s *MacOSSigner) Notarize(ctx context.Context, fs io.Medium, binary string) error {
if s.config.AppleID == "" || s.config.TeamID == "" || s.config.AppPassword == "" {
return fmt.Errorf("codesign.Notarize: missing Apple credentials (apple_id, team_id, app_password)")
}
@ -73,7 +74,7 @@ func (s *MacOSSigner) Notarize(ctx context.Context, binary string) error {
if output, err := zipCmd.CombinedOutput(); err != nil {
return fmt.Errorf("codesign.Notarize: failed to create zip: %w\nOutput: %s", err, string(output))
}
defer func() { _ = os.Remove(zipPath) }()
defer func() { _ = fs.Delete(zipPath) }()
// Submit to Apple and wait
submitCmd := exec.CommandContext(ctx, "xcrun", "notarytool", "submit",

View file

@ -5,6 +5,7 @@ import (
"runtime"
"testing"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert"
)
@ -34,17 +35,19 @@ func TestMacOSSigner_Sign_Bad(t *testing.T) {
if runtime.GOOS == "darwin" {
t.Skip("skipping on macOS")
}
fs := io.Local
s := NewMacOSSigner(MacOSConfig{Identity: "test"})
err := s.Sign(context.Background(), "test")
err := s.Sign(context.Background(), fs, "test")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not available")
})
}
func TestMacOSSigner_Notarize_Bad(t *testing.T) {
fs := io.Local
t.Run("fails with missing credentials", func(t *testing.T) {
s := NewMacOSSigner(MacOSConfig{})
err := s.Notarize(context.Background(), "test")
err := s.Notarize(context.Background(), fs, "test")
assert.Error(t, err)
assert.Contains(t, err.Error(), "missing Apple credentials")
})

View file

@ -4,6 +4,8 @@ import (
"context"
"fmt"
"os/exec"
"github.com/host-uk/core/pkg/io"
)
// GPGSigner signs files using GPG.
@ -35,7 +37,7 @@ func (s *GPGSigner) Available() bool {
// Sign creates a detached ASCII-armored signature.
// For file.txt, creates file.txt.asc
func (s *GPGSigner) Sign(ctx context.Context, file string) error {
func (s *GPGSigner) Sign(ctx context.Context, fs io.Medium, file string) error {
if !s.Available() {
return fmt.Errorf("gpg.Sign: gpg not available or key not configured")
}

View file

@ -4,6 +4,7 @@ import (
"context"
"testing"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert"
)
@ -23,9 +24,10 @@ func TestGPGSigner_Bad_NoKey(t *testing.T) {
}
func TestGPGSigner_Sign_Bad(t *testing.T) {
fs := io.Local
t.Run("fails when no key", func(t *testing.T) {
s := NewGPGSigner("")
err := s.Sign(context.Background(), "test.txt")
err := s.Sign(context.Background(), fs, "test.txt")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not available or key not configured")
})

View file

@ -4,6 +4,8 @@ import (
"context"
"fmt"
"runtime"
"github.com/host-uk/core/pkg/io"
)
// Artifact represents a build output that can be signed.
@ -16,7 +18,7 @@ type Artifact struct {
// SignBinaries signs macOS binaries in the artifacts list.
// Only signs darwin binaries when running on macOS with a configured identity.
func SignBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) error {
func SignBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts []Artifact) error {
if !cfg.Enabled {
return nil
}
@ -37,7 +39,7 @@ func SignBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) err
}
fmt.Printf(" Signing %s...\n", artifact.Path)
if err := signer.Sign(ctx, artifact.Path); err != nil {
if err := signer.Sign(ctx, fs, artifact.Path); err != nil {
return fmt.Errorf("failed to sign %s: %w", artifact.Path, err)
}
}
@ -46,7 +48,7 @@ func SignBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) err
}
// NotarizeBinaries notarizes macOS binaries if enabled.
func NotarizeBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact) error {
func NotarizeBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts []Artifact) error {
if !cfg.Enabled || !cfg.MacOS.Notarize {
return nil
}
@ -66,7 +68,7 @@ func NotarizeBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact)
}
fmt.Printf(" Notarizing %s (this may take a few minutes)...\n", artifact.Path)
if err := signer.Notarize(ctx, artifact.Path); err != nil {
if err := signer.Notarize(ctx, fs, artifact.Path); err != nil {
return fmt.Errorf("failed to notarize %s: %w", artifact.Path, err)
}
}
@ -75,7 +77,7 @@ func NotarizeBinaries(ctx context.Context, cfg SignConfig, artifacts []Artifact)
}
// SignChecksums signs the checksums file with GPG.
func SignChecksums(ctx context.Context, cfg SignConfig, checksumFile string) error {
func SignChecksums(ctx context.Context, fs io.Medium, cfg SignConfig, checksumFile string) error {
if !cfg.Enabled {
return nil
}
@ -86,7 +88,7 @@ func SignChecksums(ctx context.Context, cfg SignConfig, checksumFile string) err
}
fmt.Printf(" Signing %s with GPG...\n", checksumFile)
if err := signer.Sign(ctx, checksumFile); err != nil {
if err := signer.Sign(ctx, fs, checksumFile); err != nil {
return fmt.Errorf("failed to sign checksums: %w", err)
}

View file

@ -5,6 +5,8 @@ import (
"context"
"os"
"strings"
"github.com/host-uk/core/pkg/io"
)
// Signer defines the interface for code signing implementations.
@ -14,7 +16,7 @@ type Signer interface {
// Available checks if this signer can be used.
Available() bool
// Sign signs the artifact at the given path.
Sign(ctx context.Context, path string) error
Sign(ctx context.Context, fs io.Medium, path string) error
}
// SignConfig holds signing configuration from .core/build.yaml.

View file

@ -5,11 +5,13 @@ import (
"runtime"
"testing"
"github.com/host-uk/core/pkg/io"
"github.com/stretchr/testify/assert"
)
func TestSignBinaries_Good_SkipsNonDarwin(t *testing.T) {
ctx := context.Background()
fs := io.Local
cfg := SignConfig{
Enabled: true,
MacOS: MacOSConfig{
@ -23,7 +25,7 @@ func TestSignBinaries_Good_SkipsNonDarwin(t *testing.T) {
}
// Should not error even though binary doesn't exist (skips non-darwin)
err := SignBinaries(ctx, cfg, artifacts)
err := SignBinaries(ctx, fs, cfg, artifacts)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@ -31,6 +33,7 @@ func TestSignBinaries_Good_SkipsNonDarwin(t *testing.T) {
func TestSignBinaries_Good_DisabledConfig(t *testing.T) {
ctx := context.Background()
fs := io.Local
cfg := SignConfig{
Enabled: false,
}
@ -39,7 +42,7 @@ func TestSignBinaries_Good_DisabledConfig(t *testing.T) {
{Path: "/tmp/test-binary", OS: "darwin", Arch: "arm64"},
}
err := SignBinaries(ctx, cfg, artifacts)
err := SignBinaries(ctx, fs, cfg, artifacts)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@ -51,6 +54,7 @@ func TestSignBinaries_Good_SkipsOnNonMacOS(t *testing.T) {
}
ctx := context.Background()
fs := io.Local
cfg := SignConfig{
Enabled: true,
MacOS: MacOSConfig{
@ -62,7 +66,7 @@ func TestSignBinaries_Good_SkipsOnNonMacOS(t *testing.T) {
{Path: "/tmp/test-binary", OS: "darwin", Arch: "arm64"},
}
err := SignBinaries(ctx, cfg, artifacts)
err := SignBinaries(ctx, fs, cfg, artifacts)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@ -70,6 +74,7 @@ func TestSignBinaries_Good_SkipsOnNonMacOS(t *testing.T) {
func TestNotarizeBinaries_Good_DisabledConfig(t *testing.T) {
ctx := context.Background()
fs := io.Local
cfg := SignConfig{
Enabled: false,
}
@ -78,7 +83,7 @@ func TestNotarizeBinaries_Good_DisabledConfig(t *testing.T) {
{Path: "/tmp/test-binary", OS: "darwin", Arch: "arm64"},
}
err := NotarizeBinaries(ctx, cfg, artifacts)
err := NotarizeBinaries(ctx, fs, cfg, artifacts)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@ -86,6 +91,7 @@ func TestNotarizeBinaries_Good_DisabledConfig(t *testing.T) {
func TestNotarizeBinaries_Good_NotarizeDisabled(t *testing.T) {
ctx := context.Background()
fs := io.Local
cfg := SignConfig{
Enabled: true,
MacOS: MacOSConfig{
@ -97,7 +103,7 @@ func TestNotarizeBinaries_Good_NotarizeDisabled(t *testing.T) {
{Path: "/tmp/test-binary", OS: "darwin", Arch: "arm64"},
}
err := NotarizeBinaries(ctx, cfg, artifacts)
err := NotarizeBinaries(ctx, fs, cfg, artifacts)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@ -105,6 +111,7 @@ func TestNotarizeBinaries_Good_NotarizeDisabled(t *testing.T) {
func TestSignChecksums_Good_SkipsNoKey(t *testing.T) {
ctx := context.Background()
fs := io.Local
cfg := SignConfig{
Enabled: true,
GPG: GPGConfig{
@ -113,7 +120,7 @@ func TestSignChecksums_Good_SkipsNoKey(t *testing.T) {
}
// Should silently skip when no key
err := SignChecksums(ctx, cfg, "/tmp/CHECKSUMS.txt")
err := SignChecksums(ctx, fs, cfg, "/tmp/CHECKSUMS.txt")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@ -121,11 +128,12 @@ func TestSignChecksums_Good_SkipsNoKey(t *testing.T) {
func TestSignChecksums_Good_Disabled(t *testing.T) {
ctx := context.Background()
fs := io.Local
cfg := SignConfig{
Enabled: false,
}
err := SignChecksums(ctx, cfg, "/tmp/CHECKSUMS.txt")
err := SignChecksums(ctx, fs, cfg, "/tmp/CHECKSUMS.txt")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
@ -146,8 +154,9 @@ func TestSignConfig_ExpandEnv(t *testing.T) {
}
func TestWindowsSigner_Good(t *testing.T) {
fs := io.Local
s := NewWindowsSigner(WindowsConfig{})
assert.Equal(t, "signtool", s.Name())
assert.False(t, s.Available())
assert.NoError(t, s.Sign(context.Background(), "test.exe"))
assert.NoError(t, s.Sign(context.Background(), fs, "test.exe"))
}

View file

@ -2,6 +2,8 @@ package signing
import (
"context"
"github.com/host-uk/core/pkg/io"
)
// WindowsSigner signs binaries using Windows signtool (placeholder).
@ -28,7 +30,7 @@ func (s *WindowsSigner) Available() bool {
}
// Sign is a placeholder that does nothing.
func (s *WindowsSigner) Sign(ctx context.Context, binary string) error {
func (s *WindowsSigner) Sign(ctx context.Context, fs io.Medium, binary string) error {
// TODO: Implement Windows signing
return nil
}

View file

@ -22,9 +22,6 @@ type Medium interface {
// Write saves the given content to a file, overwriting it if it exists.
Write(path, content string) error
// Open opens a file for reading.
Open(path string) (goio.ReadCloser, error)
// EnsureDir makes sure a directory exists, creating it if necessary.
EnsureDir(path string) error
@ -52,6 +49,12 @@ type Medium interface {
// Stat returns file information for the given path.
Stat(path string) (fs.FileInfo, error)
// Open opens the named file for reading.
Open(path string) (fs.File, error)
// Create creates or truncates the named file.
Create(path string) (goio.WriteCloser, error)
// Exists checks if a path exists (file or directory).
Exists(path string) bool
@ -173,15 +176,6 @@ func (m *MockMedium) Write(path, content string) error {
return nil
}
// Open opens a file for reading in the mock filesystem.
func (m *MockMedium) Open(path string) (goio.ReadCloser, error) {
content, ok := m.Files[path]
if !ok {
return nil, coreerr.E("io.MockMedium.Open", "file not found: "+path, os.ErrNotExist)
}
return goio.NopCloser(strings.NewReader(content)), nil
}
// EnsureDir records that a directory exists in the mock filesystem.
func (m *MockMedium) EnsureDir(path string) error {
m.Dirs[path] = true
@ -320,6 +314,70 @@ func (m *MockMedium) Rename(oldPath, newPath string) error {
return coreerr.E("io.MockMedium.Rename", "path not found: "+oldPath, os.ErrNotExist)
}
// Open opens a file from the mock filesystem.
func (m *MockMedium) Open(path string) (fs.File, error) {
content, ok := m.Files[path]
if !ok {
return nil, coreerr.E("io.MockMedium.Open", "file not found: "+path, os.ErrNotExist)
}
return &MockFile{
name: filepath.Base(path),
content: []byte(content),
}, nil
}
// Create creates a file in the mock filesystem.
func (m *MockMedium) Create(path string) (goio.WriteCloser, error) {
return &MockWriteCloser{
medium: m,
path: path,
}, nil
}
// MockFile implements fs.File for MockMedium.
type MockFile struct {
name string
content []byte
offset int64
}
func (f *MockFile) Stat() (fs.FileInfo, error) {
return FileInfo{
name: f.name,
size: int64(len(f.content)),
}, nil
}
func (f *MockFile) Read(b []byte) (int, error) {
if f.offset >= int64(len(f.content)) {
return 0, goio.EOF
}
n := copy(b, f.content[f.offset:])
f.offset += int64(n)
return n, nil
}
func (f *MockFile) Close() error {
return nil
}
// MockWriteCloser implements WriteCloser for MockMedium.
type MockWriteCloser struct {
medium *MockMedium
path string
data []byte
}
func (w *MockWriteCloser) Write(p []byte) (int, error) {
w.data = append(w.data, p...)
return len(p), nil
}
func (w *MockWriteCloser) Close() error {
w.medium.Files[w.path] = string(w.data)
return nil
}
// List returns directory entries for the mock filesystem.
func (m *MockMedium) List(path string) ([]fs.DirEntry, error) {
if _, ok := m.Dirs[path]; !ok {

View file

@ -30,6 +30,15 @@ func (m *Medium) path(p string) string {
if p == "" {
return m.root
}
// If the path is relative and the medium is rooted at "/",
// treat it as relative to the current working directory.
// This makes io.Local behave more like the standard 'os' package.
if m.root == "/" && !filepath.IsAbs(p) {
cwd, _ := os.Getwd()
return filepath.Join(cwd, p)
}
// Use filepath.Clean with a leading slash to resolve all .. and . internally
// before joining with the root. This is a standard way to sandbox paths.
clean := filepath.Clean("/" + p)
@ -39,6 +48,7 @@ func (m *Medium) path(p string) string {
return clean
}
// Join cleaned relative path with root
return filepath.Join(m.root, clean)
}
@ -107,11 +117,6 @@ func (m *Medium) Write(p, content string) error {
return os.WriteFile(full, []byte(content), 0644)
}
// Open opens a file for reading.
func (m *Medium) Open(p string) (goio.ReadCloser, error) {
return os.Open(m.path(p))
}
// EnsureDir creates directory if it doesn't exist.
func (m *Medium) EnsureDir(p string) error {
full, err := m.validatePath(p)
@ -175,6 +180,27 @@ func (m *Medium) Stat(p string) (fs.FileInfo, error) {
return os.Stat(full)
}
// Open opens the named file for reading.
func (m *Medium) Open(p string) (fs.File, error) {
full, err := m.validatePath(p)
if err != nil {
return nil, err
}
return os.Open(full)
}
// Create creates or truncates the named file.
func (m *Medium) Create(p string) (goio.WriteCloser, error) {
full, err := m.validatePath(p)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return nil, err
}
return os.Create(full)
}
// Delete removes a file or empty directory.
func (m *Medium) Delete(p string) error {
full, err := m.validatePath(p)

View file

@ -40,8 +40,9 @@ func TestPath_RootFilesystem(t *testing.T) {
assert.Equal(t, "/etc/passwd", m.path("/etc/passwd"))
assert.Equal(t, "/home/user/file.txt", m.path("/home/user/file.txt"))
// Relative paths still work
assert.Equal(t, "/file.txt", m.path("file.txt"))
// Relative paths are relative to CWD when root is "/"
cwd, _ := os.Getwd()
assert.Equal(t, filepath.Join(cwd, "file.txt"), m.path("file.txt"))
}
func TestReadWrite(t *testing.T) {

View file

@ -7,7 +7,6 @@ import (
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/host-uk/core/pkg/build"
@ -216,9 +215,9 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) {
}
// buildArtifacts builds all artifacts for the release.
func buildArtifacts(ctx context.Context, m io.Medium, cfg *Config, projectDir, version string) ([]build.Artifact, error) {
func buildArtifacts(ctx context.Context, fs io.Medium, cfg *Config, projectDir, version string) ([]build.Artifact, error) {
// Load build configuration
buildCfg, err := build.LoadConfig(projectDir)
buildCfg, err := build.LoadConfig(fs, projectDir)
if err != nil {
return nil, fmt.Errorf("failed to load build config: %w", err)
}
@ -257,7 +256,7 @@ func buildArtifacts(ctx context.Context, m io.Medium, cfg *Config, projectDir, v
outputDir := filepath.Join(projectDir, "dist")
// Get builder (detect project type)
projectType, err := build.PrimaryType(projectDir)
projectType, err := build.PrimaryType(fs, projectDir)
if err != nil {
return nil, fmt.Errorf("failed to detect project type: %w", err)
}
@ -269,6 +268,7 @@ func buildArtifacts(ctx context.Context, m io.Medium, cfg *Config, projectDir, v
// Build configuration
buildConfig := &build.Config{
FS: fs,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: binaryName,
@ -283,29 +283,20 @@ func buildArtifacts(ctx context.Context, m io.Medium, cfg *Config, projectDir, v
}
// Archive artifacts
archivedArtifacts, err := build.ArchiveAll(artifacts)
archivedArtifacts, err := build.ArchiveAll(fs, artifacts)
if err != nil {
return nil, fmt.Errorf("archive failed: %w", err)
}
// Compute checksums
checksummedArtifacts, err := build.ChecksumAll(archivedArtifacts)
checksummedArtifacts, err := build.ChecksumAll(fs, archivedArtifacts)
if err != nil {
return nil, fmt.Errorf("checksum failed: %w", err)
}
// Write CHECKSUMS.txt
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
var lines []string
for _, artifact := range checksummedArtifacts {
if artifact.Checksum != "" {
lines = append(lines, fmt.Sprintf("%s %s", artifact.Checksum, filepath.Base(artifact.Path)))
}
}
sort.Strings(lines)
content := strings.Join(lines, "\n") + "\n"
if err := m.Write(checksumPath, content); err != nil {
if err := build.WriteChecksumFile(fs, checksummedArtifacts, checksumPath); err != nil {
return nil, fmt.Errorf("failed to write checksums file: %w", err)
}