go-build/cmd/build/cmd_project.go

536 lines
17 KiB
Go

// cmd_project.go implements the main project build logic.
//
// This handles auto-detection of project types (Go, Wails, Node, PHP, Docs, Docker, LinuxKit, Taskfile)
// and orchestrates the build process including signing, archiving, and checksums.
package buildcmd
import (
"context"
"runtime"
"dappco.re/go/core"
"dappco.re/go/core/build/internal/ax"
"dappco.re/go/core/build/internal/projectdetect"
"dappco.re/go/core/build/pkg/build"
"dappco.re/go/core/build/pkg/build/builders"
"dappco.re/go/core/build/pkg/build/signing"
"dappco.re/go/core/build/pkg/release"
"dappco.re/go/core/i18n"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/cli/pkg/cli"
)
// ProjectBuildRequest groups the inputs for the main `core build` command.
//
// req := ProjectBuildRequest{
// Context: cmd.Context(),
// BuildType: "go",
// TargetsFlag: "linux/amd64,linux/arm64",
// }
type ProjectBuildRequest struct {
Context context.Context
BuildType string
CIMode bool
TargetsFlag string
OutputDir string
ArchiveOutput bool
ChecksumOutput bool
ArchiveFormat string
ConfigPath string
Format string
Push bool
ImageName string
NoSign bool
Notarize bool
Verbose bool
}
// runProjectBuild handles the main `core build` command with auto-detection.
func runProjectBuild(req ProjectBuildRequest) error {
ctx := req.Context
if ctx == nil {
ctx = context.Background()
}
// Use local filesystem as the default medium.
filesystem := io.Local
// Get current working directory as project root
projectDir, err := ax.Getwd()
if err != nil {
return coreerr.E("build.Run", "failed to get working directory", err)
}
// PWA builds use the dedicated local web-app pipeline rather than the
// project-type builder registry.
if req.BuildType == "pwa" {
return runLocalPwaBuild(ctx, projectDir)
}
// Load configuration from .core/build.yaml (or defaults)
var buildConfig *build.BuildConfig
configPath := req.ConfigPath
if configPath != "" {
if !ax.IsAbs(configPath) {
configPath = ax.Join(projectDir, configPath)
}
if !filesystem.Exists(configPath) {
return coreerr.E("build.Run", "build config not found: "+configPath, nil)
}
buildConfig, err = build.LoadConfigAtPath(filesystem, configPath)
} else {
buildConfig, err = build.LoadConfig(filesystem, projectDir)
}
if err != nil {
return coreerr.E("build.Run", "failed to load config", err)
}
if buildConfig.Build.Type == "pwa" {
return runLocalPwaBuild(ctx, projectDir)
}
if err := build.SetupBuildCache(filesystem, projectDir, buildConfig); err != nil {
return coreerr.E("build.Run", "failed to set up build cache", err)
}
// Detect project type if not specified
var projectType build.ProjectType
if req.BuildType != "" {
projectType = build.ProjectType(req.BuildType)
} else if buildConfig.Build.Type != "" {
// Use type from .core/build.yaml
projectType = build.ProjectType(buildConfig.Build.Type)
} else {
projectType, err = projectdetect.DetectProjectType(filesystem, projectDir)
if err != nil {
return coreerr.E("build.Run", "failed to detect project type", err)
}
if projectType == "" {
return coreerr.E("build.Run", "no buildable project type found in "+projectDir, nil)
}
}
// Determine targets
var buildTargets []build.Target
if req.TargetsFlag != "" {
// Parse from command line
buildTargets, err = parseTargets(req.TargetsFlag)
if err != nil {
return err
}
} else if len(buildConfig.Targets) > 0 {
// Use config targets
buildTargets = buildConfig.ToTargets()
} else {
// Fall back to current OS/arch
buildTargets = []build.Target{
{OS: runtime.GOOS, Arch: runtime.GOARCH},
}
}
// Determine output directory
outputDir := req.OutputDir
if outputDir == "" {
outputDir = "dist"
}
if !ax.IsAbs(outputDir) {
outputDir = ax.Join(projectDir, outputDir)
}
outputDir = ax.Clean(outputDir)
// Determine binary name
binaryName := buildConfig.Project.Binary
if binaryName == "" {
binaryName = buildConfig.Project.Name
}
if binaryName == "" {
binaryName = ax.Base(projectDir)
}
// Print build info (verbose mode only)
if req.Verbose && !req.CIMode {
cli.Print("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.build")), i18n.T("cmd.build.building_project"))
cli.Print(" %s %s\n", i18n.T("cmd.build.label.type"), buildTargetStyle.Render(string(projectType)))
cli.Print(" %s %s\n", i18n.T("cmd.build.label.output"), buildTargetStyle.Render(outputDir))
cli.Print(" %s %s\n", i18n.T("cmd.build.label.binary"), buildTargetStyle.Render(binaryName))
cli.Print(" %s %s\n", i18n.T("cmd.build.label.targets"), buildTargetStyle.Render(formatTargets(buildTargets)))
cli.Blank()
}
// Get the appropriate builder
builder, err := getBuilder(projectType)
if err != nil {
return err
}
// Create build config for the builder
version, err := resolveBuildVersion(ctx, projectDir)
if err != nil {
return coreerr.E("build.Run", "failed to determine build version", err)
}
cfg := buildRuntimeConfig(filesystem, projectDir, outputDir, binaryName, buildConfig, req.Push, req.ImageName, version)
discovery, err := build.DiscoverFull(filesystem, projectDir)
if err != nil {
return coreerr.E("build.Run", "failed to inspect project for build options", err)
}
build.ApplyOptions(cfg, build.ComputeOptions(buildConfig, discovery))
// Parse formats for LinuxKit
if req.Format != "" {
cfg.Formats = core.Split(req.Format, ",")
}
// Execute build
artifacts, err := builder.Build(ctx, cfg, buildTargets)
if err != nil {
if !req.CIMode {
cli.Print("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err)
}
return err
}
if req.Verbose && !req.CIMode {
cli.Print("%s %s\n", buildSuccessStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.build.built_artifacts", map[string]any{"Count": len(artifacts)}))
cli.Blank()
for _, artifact := range artifacts {
relPath, err := ax.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
cli.Print(" %s %s %s\n",
buildSuccessStyle.Render("*"),
buildTargetStyle.Render(relPath),
buildDimStyle.Render(core.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
)
}
}
// Sign binaries if enabled.
signCfg := buildConfig.Sign
if req.Notarize {
signCfg.MacOS.Notarize = true
}
if req.NoSign {
signCfg.Enabled = false
}
if signCfg.Enabled && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
if req.Verbose && !req.CIMode {
cli.Blank()
cli.Print("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.sign")), i18n.T("cmd.build.signing_binaries"))
}
// Convert build.Artifact to signing.Artifact
signingArtifacts := make([]signing.Artifact, len(artifacts))
for i, a := range artifacts {
signingArtifacts[i] = signing.Artifact{Path: a.Path, OS: a.OS, Arch: a.Arch}
}
if err := signing.SignBinaries(ctx, filesystem, signCfg, signingArtifacts); err != nil {
if !req.CIMode {
cli.Print("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.signing_failed"), err)
}
return err
}
if runtime.GOOS == "darwin" && signCfg.MacOS.Notarize {
if err := signing.NotarizeBinaries(ctx, filesystem, signCfg, signingArtifacts); err != nil {
if !req.CIMode {
cli.Print("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.notarization_failed"), err)
}
return err
}
}
}
// Archive artifacts if enabled
var archivedArtifacts []build.Artifact
if req.ArchiveOutput && len(artifacts) > 0 {
if req.Verbose && !req.CIMode {
cli.Blank()
cli.Print("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.archive")), i18n.T("cmd.build.creating_archives"))
}
archiveFormatValue, err := resolveArchiveFormat(buildConfig.Build.ArchiveFormat, req.ArchiveFormat)
if err != nil {
return err
}
archivedArtifacts, err = build.ArchiveAllWithFormat(filesystem, artifacts, archiveFormatValue)
if err != nil {
if !req.CIMode {
cli.Print("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.archive_failed"), err)
}
return err
}
if req.Verbose && !req.CIMode {
for _, artifact := range archivedArtifacts {
relPath, err := ax.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
cli.Print(" %s %s %s\n",
buildSuccessStyle.Render("*"),
buildTargetStyle.Render(relPath),
buildDimStyle.Render(core.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)),
)
}
}
}
// Compute checksums if enabled
var checksummedArtifacts []build.Artifact
if req.ChecksumOutput && len(archivedArtifacts) > 0 {
checksummedArtifacts, err = computeAndWriteChecksums(ctx, filesystem, projectDir, outputDir, archivedArtifacts, signCfg, req.CIMode, req.Verbose)
if err != nil {
return err
}
} else if req.ChecksumOutput && len(artifacts) > 0 && !req.ArchiveOutput {
// Checksum raw binaries if archiving is disabled
checksummedArtifacts, err = computeAndWriteChecksums(ctx, filesystem, projectDir, outputDir, artifacts, signCfg, req.CIMode, req.Verbose)
if err != nil {
return err
}
}
// Output results
if req.CIMode {
// Determine which artifacts to output (prefer checksummed > archived > raw).
outputArtifacts := selectOutputArtifacts(artifacts, archivedArtifacts, checksummedArtifacts)
if err := writeArtifactMetadata(filesystem, binaryName, outputArtifacts); err != nil {
return err
}
// JSON output for CI
output, err := ax.JSONMarshal(outputArtifacts)
if err != nil {
return coreerr.E("build.Run", "failed to marshal artifacts", err)
}
cli.Print("%s\n", output)
} else if !req.Verbose {
// Minimal output: just success with artifact count
cli.Print("%s %s %s\n",
buildSuccessStyle.Render(i18n.T("common.label.success")),
i18n.T("cmd.build.built_artifacts", map[string]any{"Count": len(artifacts)}),
buildDimStyle.Render(core.Sprintf("(%s)", outputDir)),
)
}
return nil
}
// selectOutputArtifacts chooses the final artifact list for CI output.
//
// output := selectOutputArtifacts(rawArtifacts, archivedArtifacts, checksummedArtifacts)
func selectOutputArtifacts(rawArtifacts, archivedArtifacts, checksummedArtifacts []build.Artifact) []build.Artifact {
if len(checksummedArtifacts) > 0 {
return checksummedArtifacts
}
if len(archivedArtifacts) > 0 {
return archivedArtifacts
}
return rawArtifacts
}
// writeArtifactMetadata writes artifact_meta.json files next to built artifacts when CI metadata is available.
func writeArtifactMetadata(filesystem io.Medium, buildName string, artifacts []build.Artifact) error {
ci := build.DetectCI()
if ci == nil {
ci = build.DetectGitHubMetadata()
}
if ci == nil {
return nil
}
for _, artifact := range artifacts {
metaPath := ax.Join(ax.Dir(artifact.Path), "artifact_meta.json")
if err := build.WriteArtifactMeta(filesystem, metaPath, buildName, build.Target{OS: artifact.OS, Arch: artifact.Arch}, ci); err != nil {
return err
}
}
return nil
}
// buildRuntimeConfig maps persisted build configuration onto the runtime builder config.
func buildRuntimeConfig(filesystem io.Medium, projectDir, outputDir, binaryName string, buildConfig *build.BuildConfig, push bool, imageName string, version string) *build.Config {
buildDefaults := buildConfig.Build
cfg := &build.Config{
FS: filesystem,
Project: buildConfig.Project,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: binaryName,
Version: version,
LDFlags: append([]string{}, buildDefaults.LDFlags...),
Flags: append([]string{}, buildDefaults.Flags...),
BuildTags: append([]string{}, buildDefaults.BuildTags...),
Env: append([]string{}, buildDefaults.Env...),
Cache: buildDefaults.Cache,
CGO: buildDefaults.CGO,
Obfuscate: buildDefaults.Obfuscate,
NSIS: buildDefaults.NSIS,
WebView2: buildDefaults.WebView2,
Dockerfile: buildDefaults.Dockerfile,
Registry: buildDefaults.Registry,
Image: buildDefaults.Image,
Tags: append([]string{}, buildDefaults.Tags...),
BuildArgs: build.CloneStringMap(buildDefaults.BuildArgs),
Push: buildDefaults.Push || push,
Load: buildDefaults.Load,
LinuxKitConfig: buildDefaults.LinuxKitConfig,
Formats: append([]string{}, buildDefaults.Formats...),
}
if imageName != "" {
cfg.Image = imageName
}
return cfg
}
// resolveArchiveFormat selects the archive format from CLI overrides or config defaults.
func resolveArchiveFormat(configFormat, cliFormat string) (build.ArchiveFormat, error) {
if cliFormat != "" {
return build.ParseArchiveFormat(cliFormat)
}
return build.ParseArchiveFormat(configFormat)
}
// resolveBuildVersion determines the version string embedded into build artifacts.
//
// version, err := resolveBuildVersion(ctx, ".")
func resolveBuildVersion(ctx context.Context, projectDir string) (string, error) {
return release.DetermineVersionWithContext(ctx, projectDir)
}
// computeAndWriteChecksums computes checksums for artifacts and writes CHECKSUMS.txt.
func computeAndWriteChecksums(ctx context.Context, filesystem io.Medium, projectDir, outputDir string, artifacts []build.Artifact, signCfg signing.SignConfig, ciMode bool, verbose bool) ([]build.Artifact, error) {
if verbose && !ciMode {
cli.Blank()
cli.Print("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.checksum")), i18n.T("cmd.build.computing_checksums"))
}
checksummedArtifacts, err := build.ChecksumAll(filesystem, artifacts)
if err != nil {
if !ciMode {
cli.Print("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.checksum_failed"), err)
}
return nil, err
}
// Write CHECKSUMS.txt
checksumPath := ax.Join(outputDir, "CHECKSUMS.txt")
if err := build.WriteChecksumFile(filesystem, checksummedArtifacts, checksumPath); err != nil {
if !ciMode {
cli.Print("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("common.error.failed", map[string]any{"Action": "write CHECKSUMS.txt"}), err)
}
return nil, err
}
// Sign checksums with GPG
if signCfg.Enabled {
if err := signing.SignChecksums(ctx, filesystem, signCfg, checksumPath); err != nil {
if !ciMode {
cli.Print("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.gpg_signing_failed"), err)
}
return nil, err
}
}
if verbose && !ciMode {
for _, artifact := range checksummedArtifacts {
relPath, err := ax.Rel(projectDir, artifact.Path)
if err != nil {
relPath = artifact.Path
}
cli.Print(" %s %s\n",
buildSuccessStyle.Render("*"),
buildTargetStyle.Render(relPath),
)
cli.Print(" %s\n", buildDimStyle.Render(artifact.Checksum))
}
relChecksumPath, err := ax.Rel(projectDir, checksumPath)
if err != nil {
relChecksumPath = checksumPath
}
cli.Print(" %s %s\n",
buildSuccessStyle.Render("*"),
buildTargetStyle.Render(relChecksumPath),
)
}
return checksummedArtifacts, nil
}
// parseTargets parses a comma-separated list of OS/arch pairs.
func parseTargets(targetsFlag string) ([]build.Target, error) {
parts := core.Split(targetsFlag, ",")
var targets []build.Target
for _, part := range parts {
part = core.Trim(part)
if part == "" {
continue
}
osArch := core.Split(part, "/")
if len(osArch) != 2 {
return nil, coreerr.E("build.parseTargets", "invalid target format (expected os/arch): "+part, nil)
}
targets = append(targets, build.Target{
OS: core.Trim(osArch[0]),
Arch: core.Trim(osArch[1]),
})
}
if len(targets) == 0 {
return nil, coreerr.E("build.parseTargets", "no valid targets specified", nil)
}
return targets, nil
}
// formatTargets returns a human-readable string of targets.
func formatTargets(targets []build.Target) string {
var parts []string
for _, t := range targets {
parts = append(parts, t.String())
}
return core.Join(", ", parts...)
}
// getBuilder returns the appropriate builder for the project type.
func getBuilder(projectType build.ProjectType) (build.Builder, error) {
switch projectType {
case build.ProjectTypeWails:
return builders.NewWailsBuilder(), nil
case build.ProjectTypeGo:
return builders.NewGoBuilder(), nil
case build.ProjectTypeDocker:
return builders.NewDockerBuilder(), nil
case build.ProjectTypeLinuxKit:
return builders.NewLinuxKitBuilder(), nil
case build.ProjectTypeTaskfile:
return builders.NewTaskfileBuilder(), nil
case build.ProjectTypeCPP:
return builders.NewCPPBuilder(), nil
case build.ProjectTypeNode:
return builders.NewNodeBuilder(), nil
case build.ProjectTypePHP:
return builders.NewPHPBuilder(), nil
case build.ProjectTypePython:
return builders.NewPythonBuilder(), nil
case build.ProjectTypeRust:
return builders.NewRustBuilder(), nil
case build.ProjectTypeDocs:
return builders.NewDocsBuilder(), nil
default:
return nil, coreerr.E("build.getBuilder", "unsupported project type: "+string(projectType), nil)
}
}