536 lines
17 KiB
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)
|
|
}
|
|
}
|