Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ All file operations use `io.Medium` from `forge.lthn.ai/core/go-io`. Production

- **UK English** in comments and strings (colour, organisation, notarisation)
- **Strict types** — all parameters and return types explicitly typed
- **Error wrapping** — `fmt.Errorf("package.Function: message: %w", err)`
- **Error wrapping** — `coreerr.E("package.Function", "message", err)` via `coreerr "forge.lthn.ai/core/go-log"`
- **testify** (`assert`/`require`) for assertions
- **Test naming** — `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (edge cases)
- **Conventional commits** — `type(scope): description`
Expand Down
25 changes: 15 additions & 10 deletions cmd/build/cmd_build.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"embed"

"forge.lthn.ai/core/cli/pkg/cli"
_ "forge.lthn.ai/core/go-build/locales" // registers locale translations
"forge.lthn.ai/core/go-i18n"
)

Expand Down Expand Up @@ -58,17 +59,14 @@ var (
)

var buildCmd = &cli.Command{
Use: "build",
Short: i18n.T("cmd.build.short"),
Long: i18n.T("cmd.build.long"),
Use: "build",
RunE: func(cmd *cli.Command, args []string) error {
return runProjectBuild(cmd.Context(), buildType, ciMode, targets, outputDir, doArchive, doChecksum, configPath, format, push, imageName, noSign, notarize, verbose)
},
}

var fromPathCmd = &cli.Command{
Use: "from-path",
Short: i18n.T("cmd.build.from_path.short"),
Use: "from-path",
RunE: func(cmd *cli.Command, args []string) error {
if fromPath == "" {
return errPathRequired
Expand All @@ -78,8 +76,7 @@ var fromPathCmd = &cli.Command{
}

var pwaCmd = &cli.Command{
Use: "pwa",
Short: i18n.T("cmd.build.pwa.short"),
Use: "pwa",
RunE: func(cmd *cli.Command, args []string) error {
if pwaURL == "" {
return errURLRequired
Expand All @@ -89,14 +86,21 @@ var pwaCmd = &cli.Command{
}

var sdkBuildCmd = &cli.Command{
Use: "sdk",
Short: i18n.T("cmd.build.sdk.short"),
Long: i18n.T("cmd.build.sdk.long"),
Use: "sdk",
RunE: func(cmd *cli.Command, args []string) error {
return runBuildSDK(sdkSpec, sdkLang, sdkVersion, sdkDryRun)
},
}

func setBuildI18n() {
buildCmd.Short = i18n.T("cmd.build.short")
buildCmd.Long = i18n.T("cmd.build.long")
fromPathCmd.Short = i18n.T("cmd.build.from_path.short")
pwaCmd.Short = i18n.T("cmd.build.pwa.short")
sdkBuildCmd.Short = i18n.T("cmd.build.sdk.short")
sdkBuildCmd.Long = i18n.T("cmd.build.sdk.long")
}

func initBuildFlags() {
// Main build command flags
buildCmd.Flags().StringVar(&buildType, "type", "", i18n.T("cmd.build.flag.type"))
Expand Down Expand Up @@ -137,6 +141,7 @@ func initBuildFlags() {

// AddBuildCommands registers the 'build' command and all subcommands.
func AddBuildCommands(root *cli.Command) {
setBuildI18n()
initBuildFlags()
AddReleaseCommand(buildCmd)
root.AddCommand(buildCmd)
Expand Down
21 changes: 11 additions & 10 deletions cmd/build/cmd_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"forge.lthn.ai/core/go-build/pkg/build/signing"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
)

// runProjectBuild handles the main `core build` command with auto-detection.
Expand All @@ -29,13 +30,13 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
// 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)
return coreerr.E("build.Run", "failed to 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)
return coreerr.E("build.Run", "failed to load config", err)
}

// Detect project type if not specified
Expand All @@ -48,10 +49,10 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
} 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)
return coreerr.E("build.Run", "failed to detect project type", err)
}
if projectType == "" {
return fmt.Errorf("%s", i18n.T("cmd.build.error.no_project_type", map[string]any{"Dir": projectDir}))
return coreerr.E("build.Run", "no buildable project type found in "+projectDir, nil)
}
}

Expand Down Expand Up @@ -257,7 +258,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets
// 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)
return coreerr.E("build.Run", "failed to marshal artifacts", err)
}
fmt.Println(string(output))
} else if !verbose {
Expand Down Expand Up @@ -346,7 +347,7 @@ func parseTargets(targetsFlag string) ([]build.Target, error) {

osArch := strings.Split(part, "/")
if len(osArch) != 2 {
return nil, fmt.Errorf("%s", i18n.T("cmd.build.error.invalid_target", map[string]any{"Target": part}))
return nil, coreerr.E("build.parseTargets", "invalid target format (expected os/arch): "+part, nil)
}

targets = append(targets, build.Target{
Expand All @@ -356,7 +357,7 @@ func parseTargets(targetsFlag string) ([]build.Target, error) {
}

if len(targets) == 0 {
return nil, fmt.Errorf("%s", i18n.T("cmd.build.error.no_targets"))
return nil, coreerr.E("build.parseTargets", "no valid targets specified", nil)
}

return targets, nil
Expand Down Expand Up @@ -387,10 +388,10 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) {
case build.ProjectTypeCPP:
return builders.NewCPPBuilder(), nil
case build.ProjectTypeNode:
return nil, fmt.Errorf("%s", i18n.T("cmd.build.error.node_not_implemented"))
return nil, coreerr.E("build.getBuilder", "node.js builder not yet implemented", nil)
case build.ProjectTypePHP:
return nil, fmt.Errorf("%s", i18n.T("cmd.build.error.php_not_implemented"))
return nil, coreerr.E("build.getBuilder", "PHP builder not yet implemented", nil)
default:
return nil, fmt.Errorf("%s: %s", i18n.T("cmd.build.error.unsupported_type"), projectType)
return nil, coreerr.E("build.getBuilder", "unsupported project type: "+string(projectType), nil)
}
}
55 changes: 26 additions & 29 deletions cmd/build/cmd_pwa.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ package buildcmd

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand All @@ -19,15 +18,17 @@ import (
"strings"

"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
"github.com/leaanthony/debme"
"github.com/leaanthony/gosod"
"golang.org/x/net/html"
)

// Error sentinels for build commands
var (
errPathRequired = errors.New("the --path flag is required")
errURLRequired = errors.New("the --url flag is required")
errPathRequired = coreerr.E("buildcmd.Init", "the --path flag is required", nil)
errURLRequired = coreerr.E("buildcmd.Init", "the --url flag is required", nil)
)

// runPwaBuild downloads a PWA from URL and builds it.
Expand All @@ -36,13 +37,13 @@ func runPwaBuild(pwaURL string) error {

tempDir, err := os.MkdirTemp("", "core-pwa-build-*")
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "create temporary directory"}), err)
return coreerr.E("pwa.runPwaBuild", i18n.T("common.error.failed", map[string]any{"Action": "create temporary directory"}), err)
}
// defer os.RemoveAll(tempDir) // Keep temp dir for debugging
fmt.Printf("%s %s\n", i18n.T("cmd.build.pwa.downloading_to"), tempDir)

if err := downloadPWA(pwaURL, tempDir); err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "download PWA"}), err)
return coreerr.E("pwa.runPwaBuild", i18n.T("common.error.failed", map[string]any{"Action": "download PWA"}), err)
}

return runBuild(tempDir)
Expand All @@ -53,22 +54,22 @@ func downloadPWA(baseURL, destDir string) error {
// Fetch the main HTML page
resp, err := http.Get(baseURL)
if err != nil {
return fmt.Errorf("%s %s: %w", i18n.T("common.error.failed", map[string]any{"Action": "fetch URL"}), baseURL, err)
return coreerr.E("pwa.downloadPWA", i18n.T("common.error.failed", map[string]any{"Action": "fetch URL"})+" "+baseURL, err)
}
defer func() { _ = resp.Body.Close() }()

body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "read response body"}), err)
return coreerr.E("pwa.downloadPWA", i18n.T("common.error.failed", map[string]any{"Action": "read response body"}), 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.Printf("%s %s\n", i18n.T("common.label.warning"), i18n.T("cmd.build.pwa.no_manifest"))
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "write index.html"}), err)
if err := coreio.Local.Write(filepath.Join(destDir, "index.html"), string(body)); err != nil {
return coreerr.E("pwa.downloadPWA", i18n.T("common.error.failed", map[string]any{"Action": "write index.html"}), err)
}
return nil
}
Expand All @@ -78,7 +79,7 @@ func downloadPWA(baseURL, destDir string) error {
// Fetch and parse the manifest
manifest, err := fetchManifest(manifestURL)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "fetch or parse manifest"}), err)
return coreerr.E("pwa.downloadPWA", i18n.T("common.error.failed", map[string]any{"Action": "fetch or parse manifest"}), err)
}

// Download all assets listed in the manifest
Expand All @@ -90,8 +91,8 @@ func downloadPWA(baseURL, destDir string) error {
}

// Also save the root index.html
if err := os.WriteFile(filepath.Join(destDir, "index.html"), body, 0644); err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "write index.html"}), err)
if err := coreio.Local.Write(filepath.Join(destDir, "index.html"), string(body)); err != nil {
return coreerr.E("pwa.downloadPWA", i18n.T("common.error.failed", map[string]any{"Action": "write index.html"}), err)
}

fmt.Println(i18n.T("cmd.build.pwa.download_complete"))
Expand Down Expand Up @@ -130,7 +131,7 @@ func findManifestURL(htmlContent, baseURL string) (string, error) {
f(doc)

if manifestPath == "" {
return "", fmt.Errorf("%s", i18n.T("cmd.build.pwa.error.no_manifest_tag"))
return "", coreerr.E("pwa.findManifestURL", i18n.T("cmd.build.pwa.error.no_manifest_tag"), nil)
}

base, err := url.Parse(baseURL)
Expand Down Expand Up @@ -203,7 +204,7 @@ func downloadAsset(assetURL, destDir string) error {
}

path := filepath.Join(destDir, filepath.FromSlash(u.Path))
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil {
return err
}

Expand All @@ -221,12 +222,8 @@ func downloadAsset(assetURL, destDir string) error {
func runBuild(fromPath string) error {
fmt.Printf("%s %s\n", i18n.T("cmd.build.from_path.starting"), fromPath)

info, err := os.Stat(fromPath)
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("cmd.build.from_path.error.invalid_path"), err)
}
if !info.IsDir() {
return fmt.Errorf("%s", i18n.T("cmd.build.from_path.error.must_be_directory"))
if !coreio.Local.IsDir(fromPath) {
return coreerr.E("pwa.runBuild", i18n.T("cmd.build.from_path.error.must_be_directory"), nil)
}

buildDir := ".core/build/app"
Expand All @@ -237,30 +234,30 @@ func runBuild(fromPath string) error {
}
outputExe := appName

if err := os.RemoveAll(buildDir); err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "clean build directory"}), err)
if err := coreio.Local.DeleteAll(buildDir); err != nil {
return coreerr.E("pwa.runBuild", i18n.T("common.error.failed", map[string]any{"Action": "clean build directory"}), err)
}

// 1. Generate the project from the embedded template
fmt.Println(i18n.T("cmd.build.from_path.generating_template"))
templateFS, err := debme.FS(guiTemplate, "tmpl/gui")
if err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "anchor template filesystem"}), err)
return coreerr.E("pwa.runBuild", i18n.T("common.error.failed", map[string]any{"Action": "anchor template filesystem"}), err)
}
sod := gosod.New(templateFS)
if sod == nil {
return fmt.Errorf("%s", i18n.T("common.error.failed", map[string]any{"Action": "create new sod instance"}))
return coreerr.E("pwa.runBuild", i18n.T("common.error.failed", map[string]any{"Action": "create new sod instance"}), nil)
}

templateData := map[string]string{"AppName": appName}
if err := sod.Extract(buildDir, templateData); err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "extract template"}), err)
return coreerr.E("pwa.runBuild", i18n.T("common.error.failed", map[string]any{"Action": "extract template"}), err)
}

// 2. Copy the user's web app files
fmt.Println(i18n.T("cmd.build.from_path.copying_files"))
if err := copyDir(fromPath, htmlDir); err != nil {
return fmt.Errorf("%s: %w", i18n.T("common.error.failed", map[string]any{"Action": "copy application files"}), err)
return coreerr.E("pwa.runBuild", i18n.T("common.error.failed", map[string]any{"Action": "copy application files"}), err)
}

// 3. Compile the application
Expand All @@ -272,7 +269,7 @@ func runBuild(fromPath string) error {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s: %w", i18n.T("cmd.build.from_path.error.go_mod_tidy"), err)
return coreerr.E("pwa.runBuild", i18n.T("cmd.build.from_path.error.go_mod_tidy"), err)
}

// Run go build
Expand All @@ -281,7 +278,7 @@ func runBuild(fromPath string) error {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s: %w", i18n.T("cmd.build.from_path.error.go_build"), err)
return coreerr.E("pwa.runBuild", i18n.T("cmd.build.from_path.error.go_build"), err)
}

fmt.Printf("\n%s %s/%s\n", i18n.T("cmd.build.from_path.success"), buildDir, outputExe)
Expand All @@ -303,7 +300,7 @@ func copyDir(src, dst string) error {
dstPath := filepath.Join(dst, relPath)

if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
return coreio.Local.EnsureDir(dstPath)
}

srcFile, err := os.Open(path)
Expand Down
13 changes: 9 additions & 4 deletions cmd/build/cmd_release.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@ var (
)

var releaseCmd = &cli.Command{
Use: "release",
Short: i18n.T("cmd.build.release.short"),
Long: i18n.T("cmd.build.release.long"),
Use: "release",
RunE: func(cmd *cli.Command, args []string) error {
return runRelease(cmd.Context(), !releaseGoForLaunch, releaseVersion, releaseDraft, releasePrerelease)
},
}

func init() {
func setReleaseI18n() {
releaseCmd.Short = i18n.T("cmd.build.release.short")
releaseCmd.Long = i18n.T("cmd.build.release.long")
}

func initReleaseFlags() {
releaseCmd.Flags().BoolVar(&releaseGoForLaunch, "we-are-go-for-launch", false, i18n.T("cmd.build.release.flag.go_for_launch"))
releaseCmd.Flags().StringVar(&releaseVersion, "version", "", i18n.T("cmd.build.release.flag.version"))
releaseCmd.Flags().BoolVar(&releaseDraft, "draft", false, i18n.T("cmd.build.release.flag.draft"))
Expand All @@ -38,6 +41,8 @@ func init() {

// AddReleaseCommand adds the release subcommand to the build command.
func AddReleaseCommand(buildCmd *cli.Command) {
setReleaseI18n()
initReleaseFlags()
buildCmd.AddCommand(releaseCmd)
}

Expand Down
3 changes: 2 additions & 1 deletion cmd/build/cmd_sdk.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"forge.lthn.ai/core/go-build/pkg/sdk"
"forge.lthn.ai/core/go-i18n"
coreerr "forge.lthn.ai/core/go-log"
)

// runBuildSDK handles the `core build sdk` command.
Expand All @@ -21,7 +22,7 @@ func runBuildSDK(specPath, lang, version string, dryRun bool) error {

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)
return coreerr.E("build.SDK", "failed to get working directory", err)
}

// Load config
Expand Down
Loading