Add new PHP quality commands: - psalm: Psalm static analysis with auto-fix support - audit: Security audit for composer and npm dependencies - security: Filesystem security checks (.env exposure, permissions) - qa: Full QA pipeline with quick/standard/full stages - rector: Automated code refactoring with dry-run - infection: Mutation testing Split cmd/php/php.go (2k+ lines) into logical files: - php.go: Styles and command registration - php_dev.go: dev, logs, stop, status, ssl - php_build.go: build, serve, shell - php_quality.go: test, fmt, analyse, psalm, audit, security, qa, rector, infection - php_packages.go: packages link/unlink/update/list - php_deploy.go: deploy commands QA pipeline improvements: - Suppress tool output noise in pipeline mode - Show actionable "To fix:" suggestions with commands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
895 lines
25 KiB
Go
895 lines
25 KiB
Go
// Package build provides project build commands with auto-detection.
|
|
package build
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
buildpkg "github.com/host-uk/core/pkg/build"
|
|
"github.com/host-uk/core/pkg/build/builders"
|
|
"github.com/host-uk/core/pkg/build/signing"
|
|
"github.com/host-uk/core/pkg/sdk"
|
|
"github.com/leaanthony/clir"
|
|
"github.com/leaanthony/debme"
|
|
"github.com/leaanthony/gosod"
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
// Build command styles
|
|
var (
|
|
buildHeaderStyle = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
|
|
|
buildTargetStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
|
|
|
|
buildSuccessStyle = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("#22c55e")) // green-500
|
|
|
|
buildErrorStyle = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("#ef4444")) // red-500
|
|
|
|
buildDimStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
|
)
|
|
|
|
//go:embed all:tmpl/gui
|
|
var guiTemplate embed.FS
|
|
|
|
// AddBuildCommand adds the new build command and its subcommands to the clir app.
|
|
func AddBuildCommand(app *clir.Cli) {
|
|
buildCmd := app.NewSubCommand("build", "Build projects with auto-detection and cross-compilation")
|
|
buildCmd.LongDescription("Builds the current project with automatic type detection.\n" +
|
|
"Supports Go, Wails, Docker, LinuxKit, and Taskfile projects.\n" +
|
|
"Configuration can be provided via .core/build.yaml or command-line flags.\n\n" +
|
|
"Examples:\n" +
|
|
" core build # Auto-detect and build\n" +
|
|
" core build --type docker # Build Docker image\n" +
|
|
" core build --type linuxkit # Build LinuxKit image\n" +
|
|
" core build --type linuxkit --config linuxkit.yml --format qcow2-bios")
|
|
|
|
// Flags for the main build command
|
|
var buildType string
|
|
var ciMode bool
|
|
var targets string
|
|
var outputDir string
|
|
var doArchive bool
|
|
var doChecksum bool
|
|
|
|
// Docker/LinuxKit specific flags
|
|
var configPath string
|
|
var format string
|
|
var push bool
|
|
var imageName string
|
|
|
|
// Signing flags
|
|
var noSign bool
|
|
var notarize bool
|
|
|
|
buildCmd.StringFlag("type", "Builder type (go, wails, docker, linuxkit, taskfile) - auto-detected if not specified", &buildType)
|
|
buildCmd.BoolFlag("ci", "CI mode - minimal output with JSON artifact list at the end", &ciMode)
|
|
buildCmd.StringFlag("targets", "Comma-separated OS/arch pairs (e.g., linux/amd64,darwin/arm64)", &targets)
|
|
buildCmd.StringFlag("output", "Output directory for artifacts (default: dist)", &outputDir)
|
|
buildCmd.BoolFlag("archive", "Create archives (tar.gz for linux/darwin, zip for windows) - default: true", &doArchive)
|
|
buildCmd.BoolFlag("checksum", "Generate SHA256 checksums and CHECKSUMS.txt - default: true", &doChecksum)
|
|
|
|
// Docker/LinuxKit specific
|
|
buildCmd.StringFlag("config", "Config file path (for linuxkit: YAML config, for docker: Dockerfile)", &configPath)
|
|
buildCmd.StringFlag("format", "Output format for linuxkit (iso-bios, qcow2-bios, raw, vmdk)", &format)
|
|
buildCmd.BoolFlag("push", "Push Docker image after build (default: false)", &push)
|
|
buildCmd.StringFlag("image", "Docker image name (e.g., host-uk/core-devops)", &imageName)
|
|
|
|
// Signing flags
|
|
buildCmd.BoolFlag("no-sign", "Skip all code signing", &noSign)
|
|
buildCmd.BoolFlag("notarize", "Enable macOS notarization (requires Apple credentials)", ¬arize)
|
|
|
|
// Set defaults for archive and checksum (true by default)
|
|
doArchive = true
|
|
doChecksum = true
|
|
|
|
// Default action for `core build` (no subcommand)
|
|
buildCmd.Action(func() error {
|
|
return runProjectBuild(buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize)
|
|
})
|
|
|
|
// --- `build from-path` command (legacy PWA/GUI build) ---
|
|
fromPathCmd := buildCmd.NewSubCommand("from-path", "Build from a local directory.")
|
|
var fromPath string
|
|
fromPathCmd.StringFlag("path", "The path to the static web application files.", &fromPath)
|
|
fromPathCmd.Action(func() error {
|
|
if fromPath == "" {
|
|
return fmt.Errorf("the --path flag is required")
|
|
}
|
|
return runBuild(fromPath)
|
|
})
|
|
|
|
// --- `build pwa` command (legacy PWA build) ---
|
|
pwaCmd := buildCmd.NewSubCommand("pwa", "Build from a live PWA URL.")
|
|
var pwaURL string
|
|
pwaCmd.StringFlag("url", "The URL of the PWA to build.", &pwaURL)
|
|
pwaCmd.Action(func() error {
|
|
if pwaURL == "" {
|
|
return fmt.Errorf("a URL argument is required")
|
|
}
|
|
return runPwaBuild(pwaURL)
|
|
})
|
|
|
|
// --- `build sdk` command ---
|
|
sdkBuildCmd := buildCmd.NewSubCommand("sdk", "Generate API SDKs from OpenAPI spec")
|
|
sdkBuildCmd.LongDescription("Generates typed API clients from OpenAPI specifications.\n" +
|
|
"Supports TypeScript, Python, Go, and PHP.\n\n" +
|
|
"Examples:\n" +
|
|
" core build sdk # Generate all configured SDKs\n" +
|
|
" core build sdk --lang typescript # Generate only TypeScript SDK\n" +
|
|
" core build sdk --spec api.yaml # Use specific OpenAPI spec")
|
|
|
|
var sdkSpec, sdkLang, sdkVersion string
|
|
var sdkDryRun bool
|
|
sdkBuildCmd.StringFlag("spec", "Path to OpenAPI spec file", &sdkSpec)
|
|
sdkBuildCmd.StringFlag("lang", "Generate only this language (typescript, python, go, php)", &sdkLang)
|
|
sdkBuildCmd.StringFlag("version", "Version to embed in generated SDKs", &sdkVersion)
|
|
sdkBuildCmd.BoolFlag("dry-run", "Show what would be generated without writing files", &sdkDryRun)
|
|
sdkBuildCmd.Action(func() error {
|
|
return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun)
|
|
})
|
|
}
|
|
|
|
// runProjectBuild handles the main `core build` command with auto-detection.
|
|
func runProjectBuild(buildType string, ciMode bool, targetsFlag string, outputDir string, doArchive bool, doChecksum bool, configPath string, format string, push bool, imageName string, noSign bool, notarize bool) error {
|
|
// Get current working directory as project root
|
|
projectDir, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get working directory: %w", err)
|
|
}
|
|
|
|
// Load configuration from .core/build.yaml (or defaults)
|
|
buildCfg, err := buildpkg.LoadConfig(projectDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
|
|
// Detect project type if not specified
|
|
var projectType buildpkg.ProjectType
|
|
if buildType != "" {
|
|
projectType = buildpkg.ProjectType(buildType)
|
|
} else {
|
|
projectType, err = buildpkg.PrimaryType(projectDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to detect project type: %w", err)
|
|
}
|
|
if projectType == "" {
|
|
return fmt.Errorf("no supported project type detected in %s\n"+
|
|
"Supported types: go (go.mod), wails (wails.json), node (package.json), php (composer.json)", projectDir)
|
|
}
|
|
}
|
|
|
|
// Determine targets
|
|
var buildTargets []buildpkg.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 = []buildpkg.Target{
|
|
{OS: runtime.GOOS, Arch: runtime.GOARCH},
|
|
}
|
|
}
|
|
|
|
// Determine output directory
|
|
if outputDir == "" {
|
|
outputDir = "dist"
|
|
}
|
|
|
|
// Determine binary name
|
|
binaryName := buildCfg.Project.Binary
|
|
if binaryName == "" {
|
|
binaryName = buildCfg.Project.Name
|
|
}
|
|
if binaryName == "" {
|
|
binaryName = filepath.Base(projectDir)
|
|
}
|
|
|
|
// Print build info (unless CI mode)
|
|
if !ciMode {
|
|
fmt.Printf("%s Building project\n", buildHeaderStyle.Render("Build:"))
|
|
fmt.Printf(" Type: %s\n", buildTargetStyle.Render(string(projectType)))
|
|
fmt.Printf(" Output: %s\n", buildTargetStyle.Render(outputDir))
|
|
fmt.Printf(" Binary: %s\n", buildTargetStyle.Render(binaryName))
|
|
fmt.Printf(" Targets: %s\n", 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 := &buildpkg.Config{
|
|
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
|
|
ctx := context.Background()
|
|
artifacts, err := builder.Build(ctx, cfg, buildTargets)
|
|
if err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s Build failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if !ciMode {
|
|
fmt.Printf("%s Built %d artifact(s)\n", buildSuccessStyle.Render("Success:"), 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 !ciMode {
|
|
fmt.Println()
|
|
fmt.Printf("%s Signing binaries...\n", buildHeaderStyle.Render("Sign:"))
|
|
}
|
|
|
|
// Convert buildpkg.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, signCfg, signingArtifacts); err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s Signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if signCfg.MacOS.Notarize {
|
|
if err := signing.NotarizeBinaries(ctx, signCfg, signingArtifacts); err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s Notarization failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Archive artifacts if enabled
|
|
var archivedArtifacts []buildpkg.Artifact
|
|
if doArchive && len(artifacts) > 0 {
|
|
if !ciMode {
|
|
fmt.Println()
|
|
fmt.Printf("%s Creating archives...\n", buildHeaderStyle.Render("Archive:"))
|
|
}
|
|
|
|
archivedArtifacts, err = buildpkg.ArchiveAll(artifacts)
|
|
if err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s Archive failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
if !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 []buildpkg.Artifact
|
|
if doChecksum && len(archivedArtifacts) > 0 {
|
|
if !ciMode {
|
|
fmt.Println()
|
|
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
|
|
}
|
|
|
|
checksummedArtifacts, err = buildpkg.ChecksumAll(archivedArtifacts)
|
|
if err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Write CHECKSUMS.txt
|
|
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
|
|
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Sign checksums with GPG
|
|
if signCfg.Enabled {
|
|
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !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),
|
|
)
|
|
}
|
|
} else if doChecksum && len(artifacts) > 0 && !doArchive {
|
|
// Checksum raw binaries if archiving is disabled
|
|
if !ciMode {
|
|
fmt.Println()
|
|
fmt.Printf("%s Computing checksums...\n", buildHeaderStyle.Render("Checksum:"))
|
|
}
|
|
|
|
checksummedArtifacts, err = buildpkg.ChecksumAll(artifacts)
|
|
if err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s Checksum failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Write CHECKSUMS.txt
|
|
checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt")
|
|
if err := buildpkg.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s Failed to write CHECKSUMS.txt: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Sign checksums with GPG
|
|
if signCfg.Enabled {
|
|
if err := signing.SignChecksums(ctx, signCfg, checksumPath); err != nil {
|
|
if !ciMode {
|
|
fmt.Printf("%s GPG signing failed: %v\n", buildErrorStyle.Render("Error:"), err)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
if !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),
|
|
)
|
|
}
|
|
}
|
|
|
|
// Output results for CI mode
|
|
if ciMode {
|
|
// Determine which artifacts to output (prefer checksummed > archived > raw)
|
|
var outputArtifacts []buildpkg.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("failed to marshal artifacts: %w", err)
|
|
}
|
|
fmt.Println(string(output))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseTargets parses a comma-separated list of OS/arch pairs.
|
|
func parseTargets(targetsFlag string) ([]buildpkg.Target, error) {
|
|
parts := strings.Split(targetsFlag, ",")
|
|
var targets []buildpkg.Target
|
|
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
|
|
osArch := strings.Split(part, "/")
|
|
if len(osArch) != 2 {
|
|
return nil, fmt.Errorf("invalid target format %q, expected OS/arch (e.g., linux/amd64)", part)
|
|
}
|
|
|
|
targets = append(targets, buildpkg.Target{
|
|
OS: strings.TrimSpace(osArch[0]),
|
|
Arch: strings.TrimSpace(osArch[1]),
|
|
})
|
|
}
|
|
|
|
if len(targets) == 0 {
|
|
return nil, fmt.Errorf("no valid targets specified")
|
|
}
|
|
|
|
return targets, nil
|
|
}
|
|
|
|
// formatTargets returns a human-readable string of targets.
|
|
func formatTargets(targets []buildpkg.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 buildpkg.ProjectType) (buildpkg.Builder, error) {
|
|
switch projectType {
|
|
case buildpkg.ProjectTypeWails:
|
|
return builders.NewWailsBuilder(), nil
|
|
case buildpkg.ProjectTypeGo:
|
|
return builders.NewGoBuilder(), nil
|
|
case buildpkg.ProjectTypeDocker:
|
|
return builders.NewDockerBuilder(), nil
|
|
case buildpkg.ProjectTypeLinuxKit:
|
|
return builders.NewLinuxKitBuilder(), nil
|
|
case buildpkg.ProjectTypeTaskfile:
|
|
return builders.NewTaskfileBuilder(), nil
|
|
case buildpkg.ProjectTypeNode:
|
|
return nil, fmt.Errorf("Node.js builder not yet implemented")
|
|
case buildpkg.ProjectTypePHP:
|
|
return nil, fmt.Errorf("PHP builder not yet implemented")
|
|
default:
|
|
return nil, fmt.Errorf("unsupported project type: %s", projectType)
|
|
}
|
|
}
|
|
|
|
// --- SDK Build Logic ---
|
|
|
|
func runBuildSDK(specPath, lang, version string, dryRun bool) error {
|
|
ctx := context.Background()
|
|
|
|
projectDir, err := os.Getwd()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get working directory: %w", err)
|
|
}
|
|
|
|
// Load config
|
|
config := sdk.DefaultConfig()
|
|
if specPath != "" {
|
|
config.Spec = specPath
|
|
}
|
|
|
|
s := sdk.New(projectDir, config)
|
|
if version != "" {
|
|
s.SetVersion(version)
|
|
}
|
|
|
|
fmt.Printf("%s Generating SDKs\n", buildHeaderStyle.Render("Build SDK:"))
|
|
if dryRun {
|
|
fmt.Printf(" %s\n", buildDimStyle.Render("(dry-run mode)"))
|
|
}
|
|
fmt.Println()
|
|
|
|
// Detect spec
|
|
detectedSpec, err := s.DetectSpec()
|
|
if err != nil {
|
|
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
|
|
return err
|
|
}
|
|
fmt.Printf(" Spec: %s\n", buildTargetStyle.Render(detectedSpec))
|
|
|
|
if dryRun {
|
|
if lang != "" {
|
|
fmt.Printf(" Language: %s\n", buildTargetStyle.Render(lang))
|
|
} else {
|
|
fmt.Printf(" Languages: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
|
|
}
|
|
fmt.Println()
|
|
fmt.Printf("%s Would generate SDKs (dry-run)\n", buildSuccessStyle.Render("OK:"))
|
|
return nil
|
|
}
|
|
|
|
if lang != "" {
|
|
// Generate single language
|
|
if err := s.GenerateLanguage(ctx, lang); err != nil {
|
|
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
|
|
return err
|
|
}
|
|
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(lang))
|
|
} else {
|
|
// Generate all
|
|
if err := s.Generate(ctx); err != nil {
|
|
fmt.Printf("%s %v\n", buildErrorStyle.Render("Error:"), err)
|
|
return err
|
|
}
|
|
fmt.Printf(" Generated: %s\n", buildTargetStyle.Render(strings.Join(config.Languages, ", ")))
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Printf("%s SDK generation complete\n", buildSuccessStyle.Render("Success:"))
|
|
return nil
|
|
}
|
|
|
|
// --- PWA Build Logic ---
|
|
|
|
func runPwaBuild(pwaURL string) error {
|
|
fmt.Printf("Starting PWA build from URL: %s\n", pwaURL)
|
|
|
|
tempDir, err := os.MkdirTemp("", "core-pwa-build-*")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temporary directory: %w", err)
|
|
}
|
|
// defer os.RemoveAll(tempDir) // Keep temp dir for debugging
|
|
fmt.Printf("Downloading PWA to temporary directory: %s\n", tempDir)
|
|
|
|
if err := downloadPWA(pwaURL, tempDir); err != nil {
|
|
return fmt.Errorf("failed to download PWA: %w", err)
|
|
}
|
|
|
|
return runBuild(tempDir)
|
|
}
|
|
|
|
func downloadPWA(baseURL, destDir string) error {
|
|
// Fetch the main HTML page
|
|
resp, err := http.Get(baseURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch URL %s: %w", baseURL, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
// Find the manifest URL from the HTML
|
|
manifestURL, err := findManifestURL(string(body), baseURL)
|
|
if err != nil {
|
|
// If no manifest, it's not a PWA, but we can still try to package it as a simple site.
|
|
fmt.Println("Warning: no manifest file found. Proceeding with basic site download.")
|
|
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
|
|
return fmt.Errorf("failed to write index.html: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("Found manifest: %s\n", manifestURL)
|
|
|
|
// Fetch and parse the manifest
|
|
manifest, err := fetchManifest(manifestURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch or parse manifest: %w", err)
|
|
}
|
|
|
|
// Download all assets listed in the manifest
|
|
assets := collectAssets(manifest, manifestURL)
|
|
for _, assetURL := range assets {
|
|
if err := downloadAsset(assetURL, destDir); err != nil {
|
|
fmt.Printf("Warning: failed to download asset %s: %v\n", assetURL, err)
|
|
}
|
|
}
|
|
|
|
// Also save the root index.html
|
|
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
|
|
return fmt.Errorf("failed to write index.html: %w", err)
|
|
}
|
|
|
|
fmt.Println("PWA download complete.")
|
|
return nil
|
|
}
|
|
|
|
func findManifestURL(htmlContent, baseURL string) (string, error) {
|
|
doc, err := html.Parse(strings.NewReader(htmlContent))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var manifestPath string
|
|
var f func(*html.Node)
|
|
f = func(n *html.Node) {
|
|
if n.Type == html.ElementNode && n.Data == "link" {
|
|
var rel, href string
|
|
for _, a := range n.Attr {
|
|
if a.Key == "rel" {
|
|
rel = a.Val
|
|
}
|
|
if a.Key == "href" {
|
|
href = a.Val
|
|
}
|
|
}
|
|
if rel == "manifest" && href != "" {
|
|
manifestPath = href
|
|
return
|
|
}
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
f(c)
|
|
}
|
|
}
|
|
f(doc)
|
|
|
|
if manifestPath == "" {
|
|
return "", fmt.Errorf("no <link rel=\"manifest\"> tag found")
|
|
}
|
|
|
|
base, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
manifestURL, err := base.Parse(manifestPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return manifestURL.String(), nil
|
|
}
|
|
|
|
func fetchManifest(manifestURL string) (map[string]interface{}, error) {
|
|
resp, err := http.Get(manifestURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var manifest map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil {
|
|
return nil, err
|
|
}
|
|
return manifest, nil
|
|
}
|
|
|
|
func collectAssets(manifest map[string]interface{}, manifestURL string) []string {
|
|
var assets []string
|
|
base, _ := url.Parse(manifestURL)
|
|
|
|
// Add start_url
|
|
if startURL, ok := manifest["start_url"].(string); ok {
|
|
if resolved, err := base.Parse(startURL); err == nil {
|
|
assets = append(assets, resolved.String())
|
|
}
|
|
}
|
|
|
|
// Add icons
|
|
if icons, ok := manifest["icons"].([]interface{}); ok {
|
|
for _, icon := range icons {
|
|
if iconMap, ok := icon.(map[string]interface{}); ok {
|
|
if src, ok := iconMap["src"].(string); ok {
|
|
if resolved, err := base.Parse(src); err == nil {
|
|
assets = append(assets, resolved.String())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return assets
|
|
}
|
|
|
|
func downloadAsset(assetURL, destDir string) error {
|
|
resp, err := http.Get(assetURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
u, err := url.Parse(assetURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
path := filepath.Join(destDir, filepath.FromSlash(u.Path))
|
|
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
|
return err
|
|
}
|
|
|
|
out, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer out.Close()
|
|
|
|
_, err = io.Copy(out, resp.Body)
|
|
return err
|
|
}
|
|
|
|
// --- Standard Build Logic ---
|
|
|
|
func runBuild(fromPath string) error {
|
|
fmt.Printf("Starting build from path: %s\n", fromPath)
|
|
|
|
info, err := os.Stat(fromPath)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid path specified: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("path specified must be a directory")
|
|
}
|
|
|
|
buildDir := ".core/build/app"
|
|
htmlDir := filepath.Join(buildDir, "html")
|
|
appName := filepath.Base(fromPath)
|
|
if strings.HasPrefix(appName, "core-pwa-build-") {
|
|
appName = "pwa-app"
|
|
}
|
|
outputExe := appName
|
|
|
|
if err := os.RemoveAll(buildDir); err != nil {
|
|
return fmt.Errorf("failed to clean build directory: %w", err)
|
|
}
|
|
|
|
// 1. Generate the project from the embedded template
|
|
fmt.Println("Generating application from template...")
|
|
templateFS, err := debme.FS(guiTemplate, "tmpl/gui")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to anchor template filesystem: %w", err)
|
|
}
|
|
sod := gosod.New(templateFS)
|
|
if sod == nil {
|
|
return fmt.Errorf("failed to create new sod instance")
|
|
}
|
|
|
|
templateData := map[string]string{"AppName": appName}
|
|
if err := sod.Extract(buildDir, templateData); err != nil {
|
|
return fmt.Errorf("failed to extract template: %w", err)
|
|
}
|
|
|
|
// 2. Copy the user's web app files
|
|
fmt.Println("Copying application files...")
|
|
if err := copyDir(fromPath, htmlDir); err != nil {
|
|
return fmt.Errorf("failed to copy application files: %w", err)
|
|
}
|
|
|
|
// 3. Compile the application
|
|
fmt.Println("Compiling application...")
|
|
|
|
// Run go mod tidy
|
|
cmd := exec.Command("go", "mod", "tidy")
|
|
cmd.Dir = buildDir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("go mod tidy failed: %w", err)
|
|
}
|
|
|
|
// Run go build
|
|
cmd = exec.Command("go", "build", "-o", outputExe)
|
|
cmd.Dir = buildDir
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("go build failed: %w", err)
|
|
}
|
|
|
|
fmt.Printf("\nBuild successful! Executable created at: %s/%s\n", buildDir, outputExe)
|
|
return nil
|
|
}
|
|
|
|
// copyDir recursively copies a directory from src to dst.
|
|
func copyDir(src, dst string) error {
|
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
relPath, err := filepath.Rel(src, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dstPath := filepath.Join(dst, relPath)
|
|
|
|
if info.IsDir() {
|
|
return os.MkdirAll(dstPath, info.Mode())
|
|
}
|
|
|
|
srcFile, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer srcFile.Close()
|
|
|
|
dstFile, err := os.Create(dstPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dstFile.Close()
|
|
|
|
_, err = io.Copy(dstFile, srcFile)
|
|
return err
|
|
})
|
|
}
|