// cmd_project.go implements the main project build logic. // // This handles auto-detection of project types (Go, Wails, Docker, LinuxKit, Taskfile) // and orchestrates the build process including signing, archiving, and checksums. package buildcmd import ( "context" "encoding/json" "fmt" "os" "path/filepath" "runtime" "strings" "forge.lthn.ai/core/go/pkg/build" "forge.lthn.ai/core/go/pkg/build/builders" "forge.lthn.ai/core/go/pkg/build/signing" "forge.lthn.ai/core/go/pkg/i18n" "forge.lthn.ai/core/go/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 { return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "get working directory"}), err) } // Load configuration from .core/build.yaml (or defaults) 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) } // Detect project type if not specified var projectType build.ProjectType if buildType != "" { projectType = build.ProjectType(buildType) } else { 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) } if projectType == "" { return fmt.Errorf("%s", i18n.T("cmd.build.error.no_project_type", map[string]interface{}{"Dir": projectDir})) } } // Determine targets var buildTargets []build.Target if targetsFlag != "" { // Parse from command line buildTargets, err = parseTargets(targetsFlag) if err != nil { return err } } else if len(buildCfg.Targets) > 0 { // Use config targets buildTargets = buildCfg.ToTargets() } else { // Fall back to current OS/arch buildTargets = []build.Target{ {OS: runtime.GOOS, Arch: runtime.GOARCH}, } } // Determine output directory if outputDir == "" { outputDir = "dist" } 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 if binaryName == "" { binaryName = buildCfg.Project.Name } if binaryName == "" { binaryName = filepath.Base(projectDir) } // Print build info (verbose mode only) if verbose && !ciMode { fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.build")), i18n.T("cmd.build.building_project")) fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.type"), buildTargetStyle.Render(string(projectType))) fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.output"), buildTargetStyle.Render(outputDir)) fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.binary"), buildTargetStyle.Render(binaryName)) fmt.Printf(" %s %s\n", i18n.T("cmd.build.label.targets"), buildTargetStyle.Render(formatTargets(buildTargets))) fmt.Println() } // Get the appropriate builder builder, err := getBuilder(projectType) if err != nil { return err } // Create build config for the builder cfg := &build.Config{ FS: fs, ProjectDir: projectDir, OutputDir: outputDir, Name: binaryName, Version: buildCfg.Project.Name, // Could be enhanced with git describe LDFlags: buildCfg.Build.LDFlags, // Docker/LinuxKit specific Dockerfile: configPath, // Reuse for Dockerfile path LinuxKitConfig: configPath, Push: push, Image: imageName, } // Parse formats for LinuxKit if format != "" { cfg.Formats = strings.Split(format, ",") } // Execute build artifacts, err := builder.Build(ctx, cfg, buildTargets) if err != nil { if !ciMode { fmt.Printf("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err) } return err } if verbose && !ciMode { fmt.Printf("%s %s\n", buildSuccessStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.build.built_artifacts", map[string]interface{}{"Count": len(artifacts)})) fmt.Println() for _, artifact := range artifacts { relPath, err := filepath.Rel(projectDir, artifact.Path) if err != nil { relPath = artifact.Path } fmt.Printf(" %s %s %s\n", buildSuccessStyle.Render("*"), buildTargetStyle.Render(relPath), buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), ) } } // Sign macOS binaries if enabled signCfg := buildCfg.Sign if notarize { signCfg.MacOS.Notarize = true } if noSign { signCfg.Enabled = false } if signCfg.Enabled && runtime.GOOS == "darwin" { if verbose && !ciMode { fmt.Println() fmt.Printf("%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, 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) } return err } if signCfg.MacOS.Notarize { 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) } return err } } } // Archive artifacts if enabled var archivedArtifacts []build.Artifact if doArchive && len(artifacts) > 0 { if verbose && !ciMode { fmt.Println() fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.label.archive")), i18n.T("cmd.build.creating_archives")) } 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) } return err } if verbose && !ciMode { for _, artifact := range archivedArtifacts { relPath, err := filepath.Rel(projectDir, artifact.Path) if err != nil { relPath = artifact.Path } fmt.Printf(" %s %s %s\n", buildSuccessStyle.Render("*"), buildTargetStyle.Render(relPath), buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), ) } } } // Compute checksums if enabled var checksummedArtifacts []build.Artifact if doChecksum && len(archivedArtifacts) > 0 { checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, archivedArtifacts, signCfg, ciMode, verbose) if err != nil { return err } } else if doChecksum && len(artifacts) > 0 && !doArchive { // Checksum raw binaries if archiving is disabled checksummedArtifacts, err = computeAndWriteChecksums(ctx, projectDir, outputDir, artifacts, signCfg, ciMode, verbose) if err != nil { return err } } // Output results if ciMode { // Determine which artifacts to output (prefer checksummed > archived > raw) var outputArtifacts []build.Artifact if len(checksummedArtifacts) > 0 { outputArtifacts = checksummedArtifacts } else if len(archivedArtifacts) > 0 { outputArtifacts = archivedArtifacts } else { outputArtifacts = artifacts } // JSON output for CI output, err := json.MarshalIndent(outputArtifacts, "", " ") if err != nil { return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "marshal artifacts"}), err) } fmt.Println(string(output)) } else if !verbose { // Minimal output: just success with artifact count fmt.Printf("%s %s %s\n", buildSuccessStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.build.built_artifacts", map[string]interface{}{"Count": len(artifacts)}), buildDimStyle.Render(fmt.Sprintf("(%s)", outputDir)), ) } return nil } // 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(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) } return nil, err } // Write CHECKSUMS.txt checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") 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) } return nil, err } // Sign checksums with GPG if signCfg.Enabled { 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) } return nil, err } } if verbose && !ciMode { for _, artifact := range checksummedArtifacts { relPath, err := filepath.Rel(projectDir, artifact.Path) if err != nil { relPath = artifact.Path } fmt.Printf(" %s %s\n", buildSuccessStyle.Render("*"), buildTargetStyle.Render(relPath), ) fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum)) } relChecksumPath, err := filepath.Rel(projectDir, checksumPath) if err != nil { relChecksumPath = checksumPath } fmt.Printf(" %s %s\n", buildSuccessStyle.Render("*"), buildTargetStyle.Render(relChecksumPath), ) } return checksummedArtifacts, nil } // parseTargets parses a comma-separated list of OS/arch pairs. func parseTargets(targetsFlag string) ([]build.Target, error) { parts := strings.Split(targetsFlag, ",") var targets []build.Target for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } osArch := strings.Split(part, "/") if len(osArch) != 2 { return nil, fmt.Errorf("%s", i18n.T("cmd.build.error.invalid_target", map[string]interface{}{"Target": part})) } targets = append(targets, build.Target{ OS: strings.TrimSpace(osArch[0]), Arch: strings.TrimSpace(osArch[1]), }) } if len(targets) == 0 { return nil, fmt.Errorf("%s", i18n.T("cmd.build.error.no_targets")) } return targets, nil } // formatTargets returns a human-readable string of targets. func formatTargets(targets []build.Target) string { var parts []string for _, t := range targets { parts = append(parts, t.String()) } return strings.Join(parts, ", ") } // getBuilder returns the appropriate builder for the project type. func getBuilder(projectType build.ProjectType) (build.Builder, error) { switch projectType { case build.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 nil, fmt.Errorf("%s", i18n.T("cmd.build.error.node_not_implemented")) case build.ProjectTypePHP: return nil, fmt.Errorf("%s", i18n.T("cmd.build.error.php_not_implemented")) default: return nil, fmt.Errorf("%s: %s", i18n.T("cmd.build.error.unsupported_type"), projectType) } }