From 0237edeb0d34b033241e5d15b3a055d907c4ee34 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 28 Jan 2026 18:16:57 +0000 Subject: [PATCH] feat(build): add Wails v3 builder for desktop app cross-compilation Add WailsBuilder to pkg/build/builders: - Auto-detect frontend package manager (bun > pnpm > yarn > npm) - Install dependencies and build frontend automatically - Cross-compile with wails3 CLI for all target platforms - Platform-specific artifact detection (NSIS/DMG/binary) Co-Authored-By: Claude Opus 4.5 --- cmd/core/cmd/build.go | 4 +- pkg/build/builders/wails.go | 297 +++++++++++++++++++++++++++ pkg/build/builders/wails_test.go | 335 +++++++++++++++++++++++++++++++ 3 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 pkg/build/builders/wails.go create mode 100644 pkg/build/builders/wails_test.go diff --git a/cmd/core/cmd/build.go b/cmd/core/cmd/build.go index 863f5823..288e921f 100644 --- a/cmd/core/cmd/build.go +++ b/cmd/core/cmd/build.go @@ -403,7 +403,9 @@ func formatTargets(targets []build.Target) string { // getBuilder returns the appropriate builder for the project type. func getBuilder(projectType build.ProjectType) (build.Builder, error) { switch projectType { - case build.ProjectTypeGo, build.ProjectTypeWails: + case build.ProjectTypeWails: + return builders.NewWailsBuilder(), nil + case build.ProjectTypeGo: return builders.NewGoBuilder(), nil case build.ProjectTypeNode: return nil, fmt.Errorf("Node.js builder not yet implemented") diff --git a/pkg/build/builders/wails.go b/pkg/build/builders/wails.go new file mode 100644 index 00000000..f33c0ae0 --- /dev/null +++ b/pkg/build/builders/wails.go @@ -0,0 +1,297 @@ +// 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" +) + +// WailsBuilder implements the Builder interface for Wails v3 projects. +type WailsBuilder struct{} + +// NewWailsBuilder creates a new WailsBuilder instance. +func NewWailsBuilder() *WailsBuilder { + return &WailsBuilder{} +} + +// Name returns the builder's identifier. +func (b *WailsBuilder) Name() string { + return "wails" +} + +// 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 +} + +// Build compiles the Wails project for the specified targets. +// It installs frontend dependencies, builds the frontend, then runs wails3 build. +func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { + if cfg == nil { + return nil, fmt.Errorf("builders.WailsBuilder.Build: config is nil") + } + + if len(targets) == 0 { + return nil, fmt.Errorf("builders.WailsBuilder.Build: no targets specified") + } + + // Ensure output directory exists + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + return nil, fmt.Errorf("builders.WailsBuilder.Build: failed to create output directory: %w", err) + } + + // Find frontend directory (typically "frontend") + frontendDir := filepath.Join(cfg.ProjectDir, "frontend") + hasFrontend := dirExists(frontendDir) + + if hasFrontend { + // Detect package manager + pkgManager := detectPackageManager(frontendDir) + + // Install frontend dependencies if node_modules is missing + nodeModules := filepath.Join(frontendDir, "node_modules") + if !dirExists(nodeModules) { + if err := b.installFrontendDeps(ctx, frontendDir, pkgManager); err != nil { + return nil, fmt.Errorf("builders.WailsBuilder.Build: failed to install frontend dependencies: %w", err) + } + } + + // Build frontend + if err := b.buildFrontend(ctx, frontendDir, pkgManager); err != nil { + return nil, fmt.Errorf("builders.WailsBuilder.Build: failed to build frontend: %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.WailsBuilder.Build: failed to build %s: %w", target.String(), err) + } + artifacts = append(artifacts, artifact) + } + + return artifacts, nil +} + +// installFrontendDeps installs frontend dependencies using the detected package manager. +func (b *WailsBuilder) installFrontendDeps(ctx context.Context, frontendDir, pkgManager string) error { + var cmd *exec.Cmd + + switch pkgManager { + case "bun": + cmd = exec.CommandContext(ctx, "bun", "install") + case "pnpm": + cmd = exec.CommandContext(ctx, "pnpm", "install") + case "yarn": + cmd = exec.CommandContext(ctx, "yarn", "install") + default: + cmd = exec.CommandContext(ctx, "npm", "install") + } + + cmd.Dir = frontendDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s install failed: %w\nOutput: %s", pkgManager, err, string(output)) + } + + return nil +} + +// buildFrontend runs the frontend build command using the detected package manager. +func (b *WailsBuilder) buildFrontend(ctx context.Context, frontendDir, pkgManager string) error { + var cmd *exec.Cmd + + switch pkgManager { + case "bun": + cmd = exec.CommandContext(ctx, "bun", "run", "build") + case "pnpm": + cmd = exec.CommandContext(ctx, "pnpm", "run", "build") + case "yarn": + cmd = exec.CommandContext(ctx, "yarn", "run", "build") + default: + cmd = exec.CommandContext(ctx, "npm", "run", "build") + } + + cmd.Dir = frontendDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s run build failed: %w\nOutput: %s", pkgManager, err, string(output)) + } + + return nil +} + +// buildTarget compiles for a single target platform using wails3. +func (b *WailsBuilder) 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) + } + + // Create platform-specific output path: output/os_arch/ + 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) + } + + // Build the wails3 build arguments + args := []string{"build"} + + // Add output directory + args = append(args, "-o", platformDir) + + // Create the command + cmd := exec.CommandContext(ctx, "wails3", args...) + cmd.Dir = cfg.ProjectDir + + // Set up environment for cross-compilation + env := os.Environ() + env = append(env, fmt.Sprintf("GOOS=%s", target.OS)) + env = append(env, fmt.Sprintf("GOARCH=%s", target.Arch)) + cmd.Env = env + + // Capture output for error messages + output, err := cmd.CombinedOutput() + if err != nil { + return build.Artifact{}, fmt.Errorf("wails3 build failed: %w\nOutput: %s", err, string(output)) + } + + // Find the built artifact - depends on platform + artifactPath, err := b.findArtifact(platformDir, binaryName, target) + if err != nil { + return build.Artifact{}, fmt.Errorf("failed to find build artifact: %w", err) + } + + return build.Artifact{ + Path: artifactPath, + OS: target.OS, + Arch: target.Arch, + }, nil +} + +// findArtifact locates the built artifact based on the target platform. +func (b *WailsBuilder) findArtifact(platformDir, binaryName string, target build.Target) (string, error) { + var candidates []string + + switch target.OS { + case "windows": + // Look for NSIS installer first, then plain exe + candidates = []string{ + filepath.Join(platformDir, binaryName+"-installer.exe"), + filepath.Join(platformDir, binaryName+".exe"), + filepath.Join(platformDir, binaryName+"-amd64-installer.exe"), + } + case "darwin": + // Look for .dmg, then .app bundle, then plain binary + candidates = []string{ + filepath.Join(platformDir, binaryName+".dmg"), + filepath.Join(platformDir, binaryName+".app"), + filepath.Join(platformDir, binaryName), + } + default: + // Linux and others: look for plain binary + candidates = []string{ + filepath.Join(platformDir, binaryName), + } + } + + // Try each candidate + for _, candidate := range candidates { + if fileOrDirExists(candidate) { + return candidate, nil + } + } + + // If no specific candidate found, try to find any executable or package in the directory + entries, err := os.ReadDir(platformDir) + if err != nil { + return "", fmt.Errorf("failed to read platform directory: %w", err) + } + + for _, entry := range entries { + name := entry.Name() + // Skip common non-artifact files + if strings.HasSuffix(name, ".go") || strings.HasSuffix(name, ".json") { + continue + } + + path := filepath.Join(platformDir, name) + info, err := entry.Info() + if err != nil { + continue + } + + // On Unix, check if it's executable; on Windows, check for .exe + if target.OS == "windows" { + if strings.HasSuffix(name, ".exe") { + return path, nil + } + } else if info.Mode()&0111 != 0 || entry.IsDir() { + // Executable file or directory (.app bundle) + return path, nil + } + } + + return "", fmt.Errorf("no artifact found in %s", platformDir) +} + +// detectPackageManager detects the frontend package manager based on lock files. +// Returns "bun", "pnpm", "yarn", or "npm" (default). +func detectPackageManager(dir string) string { + // Check in priority order: bun, pnpm, yarn, npm + lockFiles := []struct { + file string + manager string + }{ + {"bun.lockb", "bun"}, + {"pnpm-lock.yaml", "pnpm"}, + {"yarn.lock", "yarn"}, + {"package-lock.json", "npm"}, + } + + for _, lf := range lockFiles { + if fileExists(filepath.Join(dir, lf.file)) { + return lf.manager + } + } + + // Default to npm if no lock file found + 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() +} + +// dirExists checks if a directory exists. +func dirExists(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) diff --git a/pkg/build/builders/wails_test.go b/pkg/build/builders/wails_test.go new file mode 100644 index 00000000..7b179267 --- /dev/null +++ b/pkg/build/builders/wails_test.go @@ -0,0 +1,335 @@ +package builders + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + + "github.com/host-uk/core/pkg/build" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupWailsTestProject creates a minimal Wails project structure for testing. +func setupWailsTestProject(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + // Create wails.json + wailsJSON := `{ + "name": "testapp", + "outputfilename": "testapp" +}` + err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte(wailsJSON), 0644) + require.NoError(t, err) + + // Create a minimal go.mod + goMod := `module testapp + +go 1.21 + +require github.com/wailsapp/wails/v3 v3.0.0 +` + 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 wails") +} +` + err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0644) + require.NoError(t, err) + + return dir +} + +// setupWailsTestProjectWithFrontend creates a Wails project with frontend directory. +func setupWailsTestProjectWithFrontend(t *testing.T, lockFile string) string { + t.Helper() + dir := setupWailsTestProject(t) + + // Create frontend directory + frontendDir := filepath.Join(dir, "frontend") + err := os.MkdirAll(frontendDir, 0755) + require.NoError(t, err) + + // Create package.json + packageJSON := `{ + "name": "frontend", + "scripts": { + "build": "echo building frontend" + } +}` + err = os.WriteFile(filepath.Join(frontendDir, "package.json"), []byte(packageJSON), 0644) + require.NoError(t, err) + + // Create lock file if specified + if lockFile != "" { + err = os.WriteFile(filepath.Join(frontendDir, lockFile), []byte(""), 0644) + require.NoError(t, err) + } + + return dir +} + +func TestWailsBuilder_Name_Good(t *testing.T) { + builder := NewWailsBuilder() + assert.Equal(t, "wails", builder.Name()) +} + +func TestWailsBuilder_Detect_Good(t *testing.T) { + 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) + assert.NoError(t, err) + assert.True(t, detected) + }) + + t.Run("returns false for Go-only project", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + require.NoError(t, err) + + builder := NewWailsBuilder() + detected, err := builder.Detect(dir) + assert.NoError(t, err) + assert.False(t, detected) + }) + + t.Run("returns false for Node.js project", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0644) + require.NoError(t, err) + + builder := NewWailsBuilder() + 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 := NewWailsBuilder() + detected, err := builder.Detect(dir) + assert.NoError(t, err) + assert.False(t, detected) + }) +} + +func TestDetectPackageManager_Good(t *testing.T) { + 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) + assert.Equal(t, "bun", result) + }) + + t.Run("detects pnpm from pnpm-lock.yaml", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte(""), 0644) + require.NoError(t, err) + + result := detectPackageManager(dir) + assert.Equal(t, "pnpm", result) + }) + + t.Run("detects yarn from yarn.lock", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644) + require.NoError(t, err) + + result := detectPackageManager(dir) + assert.Equal(t, "yarn", result) + }) + + t.Run("detects npm from package-lock.json", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644) + require.NoError(t, err) + + result := detectPackageManager(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) + assert.Equal(t, "npm", result) + }) + + t.Run("prefers bun over other lock files", func(t *testing.T) { + dir := t.TempDir() + // Create multiple lock files + require.NoError(t, os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644)) + 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) + assert.Equal(t, "bun", result) + }) + + t.Run("prefers pnpm over yarn and npm", func(t *testing.T) { + dir := t.TempDir() + // Create multiple lock files (no bun) + require.NoError(t, os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte(""), 0644)) + 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) + assert.Equal(t, "pnpm", result) + }) + + t.Run("prefers yarn over npm", func(t *testing.T) { + dir := t.TempDir() + // Create multiple lock files (no bun or pnpm) + 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) + assert.Equal(t, "yarn", result) + }) +} + +func TestWailsBuilder_Build_Bad(t *testing.T) { + t.Run("returns error for nil config", func(t *testing.T) { + builder := NewWailsBuilder() + + 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 := setupWailsTestProject(t) + + builder := NewWailsBuilder() + 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") + }) +} + +func TestWailsBuilder_Build_Good(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + // Skip if wails3 is not installed + if _, err := os.Stat("/usr/local/bin/wails3"); os.IsNotExist(err) { + // Also check in PATH + _, err := exec.LookPath("wails3") + if err != nil { + t.Skip("wails3 not installed, skipping integration test") + } + } + + t.Run("builds for current platform", func(t *testing.T) { + projectDir := setupWailsTestProject(t) + outputDir := t.TempDir() + + builder := NewWailsBuilder() + cfg := &build.Config{ + ProjectDir: projectDir, + OutputDir: outputDir, + Name: "testapp", + } + 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) + }) +} + +func TestWailsBuilder_Interface_Good(t *testing.T) { + // Verify WailsBuilder implements Builder interface + var _ build.Builder = (*WailsBuilder)(nil) + var _ build.Builder = NewWailsBuilder() +} + +func TestWailsBuilder_Ugly(t *testing.T) { + t.Run("handles nonexistent frontend directory gracefully", func(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + // Create a Wails project without a frontend directory + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte("{}"), 0644) + require.NoError(t, err) + + builder := NewWailsBuilder() + cfg := &build.Config{ + ProjectDir: dir, + OutputDir: t.TempDir(), + Name: "test", + } + targets := []build.Target{ + {OS: runtime.GOOS, Arch: runtime.GOARCH}, + } + + // This will fail because wails3 isn't set up, but it shouldn't panic + // due to missing frontend directory + _, err = builder.Build(context.Background(), cfg, targets) + // We expect an error (wails3 build will fail), but not a panic + // The error should be about wails3 build, not about frontend + if err != nil { + assert.NotContains(t, err.Error(), "frontend dependencies") + } + }) + + t.Run("handles context cancellation", func(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + projectDir := setupWailsTestProject(t) + + builder := NewWailsBuilder() + 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) + }) +}