cli/cmd/build/build.go
Snider e4d79ce952 feat(php): add quality commands and split cmd/php for maintainability
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>
2026-01-29 23:58:03 +00:00

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)", &notarize)
// 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
})
}