diff --git a/cmd/build/cmd_build.go b/cmd/build/cmd_build.go index 07974ed..2249b1e 100644 --- a/cmd/build/cmd_build.go +++ b/cmd/build/cmd_build.go @@ -140,6 +140,7 @@ func initBuildFlags() { } // AddBuildCommands registers the 'build' command and all subcommands. +// Usage example: call buildcmd.AddBuildCommands(...) from integrating code. func AddBuildCommands(root *cli.Command) { setBuildI18n() initBuildFlags() diff --git a/cmd/build/cmd_project.go b/cmd/build/cmd_project.go index 1e32a64..8eee9a7 100644 --- a/cmd/build/cmd_project.go +++ b/cmd/build/cmd_project.go @@ -7,19 +7,17 @@ package buildcmd import ( "context" - "encoding/json" - "fmt" - "os" - "path/filepath" "runtime" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "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/i18n" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" + "forge.lthn.ai/core/cli/pkg/cli" ) // runProjectBuild handles the main `core build` command with auto-detection. @@ -28,7 +26,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets fs := io.Local // Get current working directory as project root - projectDir, err := os.Getwd() + projectDir, err := ax.Getwd() if err != nil { return coreerr.E("build.Run", "failed to get working directory", err) } @@ -78,14 +76,14 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets if outputDir == "" { outputDir = "dist" } - if !filepath.IsAbs(outputDir) { - outputDir = filepath.Join(projectDir, outputDir) + if !ax.IsAbs(outputDir) { + outputDir = ax.Join(projectDir, outputDir) } - outputDir = filepath.Clean(outputDir) + outputDir = ax.Clean(outputDir) // Ensure config path is absolute if provided - if configPath != "" && !filepath.IsAbs(configPath) { - configPath = filepath.Join(projectDir, configPath) + if configPath != "" && !ax.IsAbs(configPath) { + configPath = ax.Join(projectDir, configPath) } // Determine binary name @@ -94,17 +92,17 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets binaryName = buildCfg.Project.Name } if binaryName == "" { - binaryName = filepath.Base(projectDir) + binaryName = ax.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() + 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 @@ -131,30 +129,30 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets // Parse formats for LinuxKit if format != "" { - cfg.Formats = strings.Split(format, ",") + cfg.Formats = core.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) + cli.Print("%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]any{"Count": len(artifacts)})) - fmt.Println() + 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 := filepath.Rel(projectDir, artifact.Path) + relPath, err := ax.Rel(projectDir, artifact.Path) if err != nil { relPath = artifact.Path } - fmt.Printf(" %s %s %s\n", + cli.Print(" %s %s %s\n", buildSuccessStyle.Render("*"), buildTargetStyle.Render(relPath), - buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), + buildDimStyle.Render(core.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), ) } } @@ -170,8 +168,8 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets 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")) + 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 @@ -182,7 +180,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets 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) + cli.Print("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.signing_failed"), err) } return err } @@ -190,7 +188,7 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets 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) + cli.Print("%s %s: %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), i18n.T("cmd.build.error.notarization_failed"), err) } return err } @@ -201,28 +199,28 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets 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")) + cli.Blank() + cli.Print("%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) + cli.Print("%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) + relPath, err := ax.Rel(projectDir, artifact.Path) if err != nil { relPath = artifact.Path } - fmt.Printf(" %s %s %s\n", + cli.Print(" %s %s %s\n", buildSuccessStyle.Render("*"), buildTargetStyle.Render(relPath), - buildDimStyle.Render(fmt.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), + buildDimStyle.Render(core.Sprintf("(%s/%s)", artifact.OS, artifact.Arch)), ) } } @@ -256,17 +254,17 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets } // JSON output for CI - output, err := json.MarshalIndent(outputArtifacts, "", " ") + output, err := ax.JSONMarshal(outputArtifacts) if err != nil { return coreerr.E("build.Run", "failed to marshal artifacts", err) } - fmt.Println(string(output)) + cli.Print("%s\n", output) } else if !verbose { // Minimal output: just success with artifact count - fmt.Printf("%s %s %s\n", + 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(fmt.Sprintf("(%s)", outputDir)), + buildDimStyle.Render(core.Sprintf("(%s)", outputDir)), ) } @@ -277,23 +275,23 @@ func runProjectBuild(ctx context.Context, buildType string, ciMode bool, targets 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")) + 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(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) + 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 := filepath.Join(outputDir, "CHECKSUMS.txt") + checksumPath := ax.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) + 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 } @@ -302,7 +300,7 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, 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) + 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 } @@ -310,22 +308,22 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, if verbose && !ciMode { for _, artifact := range checksummedArtifacts { - relPath, err := filepath.Rel(projectDir, artifact.Path) + relPath, err := ax.Rel(projectDir, artifact.Path) if err != nil { relPath = artifact.Path } - fmt.Printf(" %s %s\n", + cli.Print(" %s %s\n", buildSuccessStyle.Render("*"), buildTargetStyle.Render(relPath), ) - fmt.Printf(" %s\n", buildDimStyle.Render(artifact.Checksum)) + cli.Print(" %s\n", buildDimStyle.Render(artifact.Checksum)) } - relChecksumPath, err := filepath.Rel(projectDir, checksumPath) + relChecksumPath, err := ax.Rel(projectDir, checksumPath) if err != nil { relChecksumPath = checksumPath } - fmt.Printf(" %s %s\n", + cli.Print(" %s %s\n", buildSuccessStyle.Render("*"), buildTargetStyle.Render(relChecksumPath), ) @@ -336,23 +334,23 @@ func computeAndWriteChecksums(ctx context.Context, projectDir, outputDir string, // parseTargets parses a comma-separated list of OS/arch pairs. func parseTargets(targetsFlag string) ([]build.Target, error) { - parts := strings.Split(targetsFlag, ",") + parts := core.Split(targetsFlag, ",") var targets []build.Target for _, part := range parts { - part = strings.TrimSpace(part) + part = core.Trim(part) if part == "" { continue } - osArch := strings.Split(part, "/") + 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: strings.TrimSpace(osArch[0]), - Arch: strings.TrimSpace(osArch[1]), + OS: core.Trim(osArch[0]), + Arch: core.Trim(osArch[1]), }) } @@ -369,7 +367,7 @@ func formatTargets(targets []build.Target) string { for _, t := range targets { parts = append(parts, t.String()) } - return strings.Join(parts, ", ") + return core.Join(", ", parts...) } // getBuilder returns the appropriate builder for the project type. diff --git a/cmd/build/cmd_pwa.go b/cmd/build/cmd_pwa.go index fe1a8a1..7148a00 100644 --- a/cmd/build/cmd_pwa.go +++ b/cmd/build/cmd_pwa.go @@ -7,16 +7,13 @@ package buildcmd import ( - "encoding/json" - "fmt" + "context" "io" "net/http" "net/url" - "os" - "os/exec" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/i18n" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" @@ -33,14 +30,14 @@ var ( // runPwaBuild downloads a PWA from URL and builds it. func runPwaBuild(pwaURL string) error { - fmt.Printf("%s %s\n", i18n.T("cmd.build.pwa.starting"), pwaURL) + core.Print(nil, "%s %s", i18n.T("cmd.build.pwa.starting"), pwaURL) - tempDir, err := os.MkdirTemp("", "core-pwa-build-*") + tempDir, err := ax.TempDir("core-pwa-build-*") if err != nil { 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) + core.Print(nil, "%s %s", i18n.T("cmd.build.pwa.downloading_to"), tempDir) if err := downloadPWA(pwaURL, tempDir); err != nil { return coreerr.E("pwa.runPwaBuild", i18n.T("common.error.failed", map[string]any{"Action": "download PWA"}), err) @@ -67,14 +64,14 @@ func downloadPWA(baseURL, destDir string) error { 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 := coreio.Local.Write(filepath.Join(destDir, "index.html"), string(body)); err != nil { + core.Print(nil, "%s %s", i18n.T("common.label.warning"), i18n.T("cmd.build.pwa.no_manifest")) + if err := coreio.Local.Write(ax.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 } - fmt.Printf("%s %s\n", i18n.T("cmd.build.pwa.found_manifest"), manifestURL) + core.Print(nil, "%s %s", i18n.T("cmd.build.pwa.found_manifest"), manifestURL) // Fetch and parse the manifest manifest, err := fetchManifest(manifestURL) @@ -86,22 +83,22 @@ func downloadPWA(baseURL, destDir string) error { assets := collectAssets(manifest, manifestURL) for _, assetURL := range assets { if err := downloadAsset(assetURL, destDir); err != nil { - fmt.Printf("%s %s %s: %v\n", i18n.T("common.label.warning"), i18n.T("common.error.failed", map[string]any{"Action": "download asset"}), assetURL, err) + core.Print(nil, "%s %s %s: %v", i18n.T("common.label.warning"), i18n.T("common.error.failed", map[string]any{"Action": "download asset"}), assetURL, err) } } // Also save the root index.html - if err := coreio.Local.Write(filepath.Join(destDir, "index.html"), string(body)); err != nil { + if err := coreio.Local.Write(ax.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")) + core.Println(i18n.T("cmd.build.pwa.download_complete")) return nil } // findManifestURL extracts the manifest URL from HTML content. func findManifestURL(htmlContent, baseURL string) (string, error) { - doc, err := html.Parse(strings.NewReader(htmlContent)) + doc, err := html.Parse(core.NewReader(htmlContent)) if err != nil { return "", err } @@ -155,8 +152,13 @@ func fetchManifest(manifestURL string) (map[string]any, error) { } defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var manifest map[string]any - if err := json.NewDecoder(resp.Body).Decode(&manifest); err != nil { + if err := ax.JSONUnmarshal(body, &manifest); err != nil { return nil, err } return manifest, nil @@ -203,12 +205,13 @@ func downloadAsset(assetURL, destDir string) error { return err } - path := filepath.Join(destDir, filepath.FromSlash(u.Path)) - if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil { + assetPath := core.TrimPrefix(ax.FromSlash(u.Path), ax.DS()) + path := ax.Join(destDir, assetPath) + if err := coreio.Local.EnsureDir(ax.Dir(path)); err != nil { return err } - out, err := os.Create(path) + out, err := ax.Create(path) if err != nil { return err } @@ -220,16 +223,16 @@ func downloadAsset(assetURL, destDir string) error { // runBuild builds a desktop application from a local directory. func runBuild(fromPath string) error { - fmt.Printf("%s %s\n", i18n.T("cmd.build.from_path.starting"), fromPath) + core.Print(nil, "%s %s", i18n.T("cmd.build.from_path.starting"), fromPath) 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" - htmlDir := filepath.Join(buildDir, "html") - appName := filepath.Base(fromPath) - if strings.HasPrefix(appName, "core-pwa-build-") { + htmlDir := ax.Join(buildDir, "html") + appName := ax.Base(fromPath) + if core.HasPrefix(appName, "core-pwa-build-") { appName = "pwa-app" } outputExe := appName @@ -239,7 +242,7 @@ func runBuild(fromPath string) error { } // 1. Generate the project from the embedded template - fmt.Println(i18n.T("cmd.build.from_path.generating_template")) + core.Println(i18n.T("cmd.build.from_path.generating_template")) templateFS, err := debme.FS(guiTemplate, "tmpl/gui") if err != nil { return coreerr.E("pwa.runBuild", i18n.T("common.error.failed", map[string]any{"Action": "anchor template filesystem"}), err) @@ -255,67 +258,75 @@ func runBuild(fromPath string) error { } // 2. Copy the user's web app files - fmt.Println(i18n.T("cmd.build.from_path.copying_files")) + core.Println(i18n.T("cmd.build.from_path.copying_files")) if err := copyDir(fromPath, htmlDir); err != nil { return coreerr.E("pwa.runBuild", i18n.T("common.error.failed", map[string]any{"Action": "copy application files"}), err) } // 3. Compile the application - fmt.Println(i18n.T("cmd.build.from_path.compiling")) + core.Println(i18n.T("cmd.build.from_path.compiling")) // 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 { + if err := ax.ExecDir(context.Background(), buildDir, "go", "mod", "tidy"); err != nil { return coreerr.E("pwa.runBuild", i18n.T("cmd.build.from_path.error.go_mod_tidy"), 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 { + if err := ax.ExecDir(context.Background(), buildDir, "go", "build", "-o", outputExe); err != nil { 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) + core.Println() + core.Print(nil, "%s %s/%s", i18n.T("cmd.build.from_path.success"), 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 coreio.Local.EnsureDir(dstPath) - } - - srcFile, err := os.Open(path) - if err != nil { - return err - } - defer func() { _ = srcFile.Close() }() - - dstFile, err := os.Create(dstPath) - if err != nil { - return err - } - defer func() { _ = dstFile.Close() }() - - _, err = io.Copy(dstFile, srcFile) + if err := coreio.Local.EnsureDir(dst); err != nil { return err - }) + } + + entries, err := coreio.Local.List(src) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := ax.Join(src, entry.Name()) + dstPath := ax.Join(dst, entry.Name()) + + if entry.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + continue + } + + srcFile, err := ax.Open(srcPath) + if err != nil { + return err + } + + dstFile, err := ax.Create(dstPath) + if err != nil { + _ = srcFile.Close() + return err + } + + if _, err := io.Copy(dstFile, srcFile); err != nil { + _ = srcFile.Close() + _ = dstFile.Close() + return err + } + if err := srcFile.Close(); err != nil { + _ = dstFile.Close() + return err + } + if err := dstFile.Close(); err != nil { + return err + } + } + + return nil } diff --git a/cmd/build/cmd_release.go b/cmd/build/cmd_release.go index bbec186..bef9738 100644 --- a/cmd/build/cmd_release.go +++ b/cmd/build/cmd_release.go @@ -4,8 +4,8 @@ package buildcmd import ( "context" - "os" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/release" "dappco.re/go/core/i18n" coreerr "dappco.re/go/core/log" @@ -40,6 +40,7 @@ func initReleaseFlags() { } // AddReleaseCommand adds the release subcommand to the build command. +// Usage example: call buildcmd.AddReleaseCommand(...) from integrating code. func AddReleaseCommand(buildCmd *cli.Command) { setReleaseI18n() initReleaseFlags() @@ -49,7 +50,7 @@ func AddReleaseCommand(buildCmd *cli.Command) { // runRelease executes the full release workflow: build + archive + checksum + publish. func runRelease(ctx context.Context, dryRun bool, version string, draft, prerelease bool) error { // Get current directory - projectDir, err := os.Getwd() + projectDir, err := ax.Getwd() if err != nil { return coreerr.E("release", "get working directory", err) } diff --git a/cmd/build/cmd_sdk.go b/cmd/build/cmd_sdk.go index 9320c0a..cb9dda9 100644 --- a/cmd/build/cmd_sdk.go +++ b/cmd/build/cmd_sdk.go @@ -7,20 +7,20 @@ package buildcmd import ( "context" - "fmt" - "os" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/sdk" "dappco.re/go/core/i18n" coreerr "dappco.re/go/core/log" + "forge.lthn.ai/core/cli/pkg/cli" ) // runBuildSDK handles the `core build sdk` command. func runBuildSDK(specPath, lang, version string, dryRun bool) error { ctx := context.Background() - projectDir, err := os.Getwd() + projectDir, err := ax.Getwd() if err != nil { return coreerr.E("build.SDK", "failed to get working directory", err) } @@ -36,48 +36,48 @@ func runBuildSDK(specPath, lang, version string, dryRun bool) error { s.SetVersion(version) } - fmt.Printf("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.sdk.label")), i18n.T("cmd.build.sdk.generating")) + cli.Print("%s %s\n", buildHeaderStyle.Render(i18n.T("cmd.build.sdk.label")), i18n.T("cmd.build.sdk.generating")) if dryRun { - fmt.Printf(" %s\n", buildDimStyle.Render(i18n.T("cmd.build.sdk.dry_run_mode"))) + cli.Print(" %s\n", buildDimStyle.Render(i18n.T("cmd.build.sdk.dry_run_mode"))) } - fmt.Println() + cli.Blank() // Detect spec detectedSpec, err := s.DetectSpec() if err != nil { - fmt.Printf("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err) + cli.Print("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err) return err } - fmt.Printf(" %s %s\n", i18n.T("common.label.spec"), buildTargetStyle.Render(detectedSpec)) + cli.Print(" %s %s\n", i18n.T("common.label.spec"), buildTargetStyle.Render(detectedSpec)) if dryRun { if lang != "" { - fmt.Printf(" %s %s\n", i18n.T("cmd.build.sdk.language_label"), buildTargetStyle.Render(lang)) + cli.Print(" %s %s\n", i18n.T("cmd.build.sdk.language_label"), buildTargetStyle.Render(lang)) } else { - fmt.Printf(" %s %s\n", i18n.T("cmd.build.sdk.languages_label"), buildTargetStyle.Render(strings.Join(config.Languages, ", "))) + cli.Print(" %s %s\n", i18n.T("cmd.build.sdk.languages_label"), buildTargetStyle.Render(core.Join(", ", config.Languages...))) } - fmt.Println() - fmt.Printf("%s %s\n", buildSuccessStyle.Render(i18n.T("cmd.build.label.ok")), i18n.T("cmd.build.sdk.would_generate")) + cli.Blank() + cli.Print("%s %s\n", buildSuccessStyle.Render(i18n.T("cmd.build.label.ok")), i18n.T("cmd.build.sdk.would_generate")) return nil } if lang != "" { // Generate single language if err := s.GenerateLanguage(ctx, lang); err != nil { - fmt.Printf("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err) + cli.Print("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err) return err } - fmt.Printf(" %s %s\n", i18n.T("cmd.build.sdk.generated_label"), buildTargetStyle.Render(lang)) + cli.Print(" %s %s\n", i18n.T("cmd.build.sdk.generated_label"), buildTargetStyle.Render(lang)) } else { // Generate all if err := s.Generate(ctx); err != nil { - fmt.Printf("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err) + cli.Print("%s %v\n", buildErrorStyle.Render(i18n.T("common.label.error")), err) return err } - fmt.Printf(" %s %s\n", i18n.T("cmd.build.sdk.generated_label"), buildTargetStyle.Render(strings.Join(config.Languages, ", "))) + cli.Print(" %s %s\n", i18n.T("cmd.build.sdk.generated_label"), buildTargetStyle.Render(core.Join(", ", config.Languages...))) } - fmt.Println() - fmt.Printf("%s %s\n", buildSuccessStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.build.sdk.complete")) + cli.Blank() + cli.Print("%s %s\n", buildSuccessStyle.Render(i18n.T("common.label.success")), i18n.T("cmd.build.sdk.complete")) return nil } diff --git a/cmd/ci/ci.go b/cmd/ci/ci.go index 5108200..77ecc23 100644 --- a/cmd/ci/ci.go +++ b/cmd/ci/ci.go @@ -2,10 +2,9 @@ package ci import ( "context" - "os" - "os/exec" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/release" "dappco.re/go/core/i18n" coreerr "dappco.re/go/core/log" @@ -96,7 +95,7 @@ func initCIFlags() { func runCIPublish(dryRun bool, version string, draft, prerelease bool) error { ctx := context.Background() - projectDir, err := os.Getwd() + projectDir, err := ax.Getwd() if err != nil { return cli.WrapVerb(err, "get", "working directory") } @@ -155,7 +154,7 @@ func runCIPublish(dryRun bool, version string, draft, prerelease bool) error { // runCIReleaseInit scaffolds a release config. func runCIReleaseInit() error { - cwd, err := os.Getwd() + cwd, err := ax.Getwd() if err != nil { return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) } @@ -184,7 +183,7 @@ func runCIReleaseInit() error { // runChangelog generates a changelog between two git refs. func runChangelog(fromRef, toRef string) error { - cwd, err := os.Getwd() + cwd, err := ax.Getwd() if err != nil { return cli.Err("%s: %w", i18n.T("i18n.fail.get", "working directory"), err) } @@ -217,7 +216,7 @@ func runChangelog(fromRef, toRef string) error { // runCIReleaseVersion shows the determined version. func runCIReleaseVersion() error { - projectDir, err := os.Getwd() + projectDir, err := ax.Getwd() if err != nil { return cli.WrapVerb(err, "get", "working directory") } @@ -232,11 +231,9 @@ func runCIReleaseVersion() error { } func latestTag(dir string) (string, error) { - cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") - cmd.Dir = dir - out, err := cmd.Output() + out, err := ax.RunDir(context.Background(), dir, "git", "describe", "--tags", "--abbrev=0") if err != nil { return "", err } - return strings.TrimSpace(string(out)), nil + return core.Trim(out), nil } diff --git a/cmd/ci/cmd.go b/cmd/ci/cmd.go index dce91e4..65e0c95 100644 --- a/cmd/ci/cmd.go +++ b/cmd/ci/cmd.go @@ -18,6 +18,7 @@ func init() { } // AddCICommands registers the 'ci' command and all subcommands. +// Usage example: call ci.AddCICommands(...) from integrating code. func AddCICommands(root *cli.Command) { setCII18n() initCIFlags() diff --git a/cmd/sdk/cmd.go b/cmd/sdk/cmd.go index acb9421..05c8c01 100644 --- a/cmd/sdk/cmd.go +++ b/cmd/sdk/cmd.go @@ -8,8 +8,7 @@ package sdkcmd import ( - "os" - + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/sdk" "dappco.re/go/core/i18n" coreerr "dappco.re/go/core/log" @@ -61,6 +60,7 @@ func setSDKI18n() { } // AddSDKCommands registers the 'sdk' command and all subcommands. +// Usage example: call sdkcmd.AddSDKCommands(...) from integrating code. func AddSDKCommands(root *cli.Command) { setSDKI18n() @@ -79,7 +79,7 @@ func AddSDKCommands(root *cli.Command) { } func runSDKDiff(basePath, specPath string) error { - projectDir, err := os.Getwd() + projectDir, err := ax.Getwd() if err != nil { return coreerr.E("sdk.Diff", "failed to get working directory", err) } @@ -119,7 +119,7 @@ func runSDKDiff(basePath, specPath string) error { } func runSDKValidate(specPath string) error { - projectDir, err := os.Getwd() + projectDir, err := ax.Getwd() if err != nil { return coreerr.E("sdk.Validate", "failed to get working directory", err) } diff --git a/go.mod b/go.mod index 2b400da..545a2d6 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,13 @@ module dappco.re/go/core/build go 1.26.0 require ( - dappco.re/go/core/api v0.1.5 - dappco.re/go/core/i18n v0.1.7 + dappco.re/go/core v0.8.0-alpha.1 + dappco.re/go/core/api v0.2.0 + dappco.re/go/core/i18n v0.2.0 dappco.re/go/core/io v0.2.0 dappco.re/go/core/log v0.1.0 - dappco.re/go/core/ws v0.2.5 + dappco.re/go/core/process v0.3.0 + dappco.re/go/core/ws v0.3.0 forge.lthn.ai/core/cli v0.3.7 github.com/Snider/Borg v0.2.0 github.com/gin-gonic/gin v1.12.0 @@ -23,7 +25,6 @@ require ( require ( cloud.google.com/go v0.123.0 // indirect - dappco.re/go/core v0.5.0 // indirect forge.lthn.ai/core/go v0.3.2 // indirect forge.lthn.ai/core/go-i18n v0.1.7 // indirect forge.lthn.ai/core/go-inference v0.1.7 // indirect @@ -155,14 +156,3 @@ require ( golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) - -replace ( - dappco.re/go/core => /Users/snider/Code/core/go - dappco.re/go/core/api => /Users/snider/Code/core/api - dappco.re/go/core/i18n => /Users/snider/Code/core/go-i18n - dappco.re/go/core/io => /Users/snider/Code/core/go-io - dappco.re/go/core/log => /Users/snider/Code/core/go-log - dappco.re/go/core/ws => /Users/snider/Code/core/go-ws - forge.lthn.ai/core/cli => /Users/snider/Code/core/cli - forge.lthn.ai/core/go-inference => /Users/snider/Code/core/go-inference -) diff --git a/go.sum b/go.sum index 95f2039..67559dd 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,27 @@ cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/api v0.2.0 h1:5OcN9nawpp18Jp6dB1OwI2CBfs0Tacb0y0zqxFB6TJ0= +dappco.re/go/core/api v0.2.0/go.mod h1:AtgNAx8lDY+qhVObFdNQOjSUQrHX1BeiDdMuA6RIfzo= +dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI= +dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok= +dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= +dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= +dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= +dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= +dappco.re/go/core/process v0.3.0 h1:BPF9R79+8ZWe34qCIy/sZy+P4HwbaO95js2oPJL7IqM= +dappco.re/go/core/process v0.3.0/go.mod h1:qwx8kt6x+J9gn7fu8lavuess72Ye9jPBODqDZQ9K0as= +dappco.re/go/core/ws v0.3.0 h1:ZxR8y5pfrWvnCHVN7qExXz7fdP5a063uNqyqE0Ab8pQ= +dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic= +forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= +forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ= forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo= forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA= forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8= +forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q= +forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc= diff --git a/internal/ax/ax.go b/internal/ax/ax.go new file mode 100644 index 0000000..60ac91f --- /dev/null +++ b/internal/ax/ax.go @@ -0,0 +1,408 @@ +package ax + +import ( + "context" + "io" + "io/fs" + "os" + "runtime" + "syscall" + + "dappco.re/go/core" + coreio "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" + process "dappco.re/go/core/process" + processexec "dappco.re/go/core/process/exec" +) + +// DS returns the current platform directory separator. +// +// Usage example: read ax.DS() when building Core-aware filesystem paths. +func DS() string { + if sep := core.Env("DS"); sep != "" { + return sep + } + if runtime.GOOS == "windows" { + return "\\" + } + return "/" +} + +// Clean normalises a filesystem path using Core path primitives. +// +// Usage example: clean := ax.Clean("./dist/../dist/output") +func Clean(path string) string { + return core.CleanPath(path, DS()) +} + +// Join combines path segments without relying on path/filepath. +// +// Usage example: path := ax.Join(projectDir, ".core", "build.yaml") +func Join(parts ...string) string { + return Clean(core.Join(DS(), parts...)) +} + +// Abs resolves a path against the current working directory. +// +// Usage example: abs, err := ax.Abs("./testdata") +func Abs(path string) (string, error) { + if core.PathIsAbs(path) { + return Clean(path), nil + } + + cwd, err := Getwd() + if err != nil { + return "", err + } + + return Join(cwd, path), nil +} + +// Rel returns target relative to base when target is inside base. +// +// Usage example: rel, err := ax.Rel(projectDir, artifactPath) +func Rel(base, target string) (string, error) { + base = Clean(base) + target = Clean(target) + + if base == target { + return ".", nil + } + + prefix := base + if !core.HasSuffix(prefix, DS()) { + prefix = core.Concat(prefix, DS()) + } + + if core.HasPrefix(target, prefix) { + return core.TrimPrefix(target, prefix), nil + } + + return "", coreerr.E("ax.Rel", "path is outside base: "+target, nil) +} + +// Base returns the last path element. +// +// Usage example: name := ax.Base("/tmp/dist/app.tar.gz") +func Base(path string) string { + return core.PathBase(path) +} + +// Dir returns the parent directory for a path. +// +// Usage example: dir := ax.Dir("/tmp/dist/app.tar.gz") +func Dir(path string) string { + return core.PathDir(path) +} + +// Ext returns the filename extension including the dot. +// +// Usage example: ext := ax.Ext("app.tar.gz") +func Ext(path string) string { + return core.PathExt(path) +} + +// IsAbs reports whether a path is absolute. +// +// Usage example: if ax.IsAbs(outputDir) { ... } +func IsAbs(path string) bool { + return core.PathIsAbs(path) +} + +// FromSlash rewrites slash-separated paths for the current platform. +// +// Usage example: path := ax.FromSlash("ui/dist/index.html") +func FromSlash(path string) string { + if DS() == "/" { + return path + } + return core.Replace(path, "/", DS()) +} + +// Getwd returns the current working directory from Core environment metadata. +// +// Usage example: cwd, err := ax.Getwd() +func Getwd() (string, error) { + cwd := core.Env("DIR_CWD") + if cwd == "" { + wd, err := os.Getwd() + if err != nil { + return "", coreerr.E("ax.Getwd", "failed to get current working directory", err) + } + return wd, nil + } + return cwd, nil +} + +// TempDir creates a temporary directory via Core's filesystem primitive. +// +// Usage example: dir, err := ax.TempDir("core-build-*") +func TempDir(prefix string) (string, error) { + dir := (&core.Fs{}).NewUnrestricted().TempDir(prefix) + if dir == "" { + return "", coreerr.E("ax.TempDir", "failed to create temporary directory", nil) + } + return dir, nil +} + +// ReadFile reads a file into bytes via io.Local. +// +// Usage example: data, err := ax.ReadFile("go.mod") +func ReadFile(path string) ([]byte, error) { + content, err := coreio.Local.Read(path) + if err != nil { + return nil, coreerr.E("ax.ReadFile", "failed to read file "+path, err) + } + return []byte(content), nil +} + +// WriteFile writes bytes via io.Local with an explicit mode. +// +// Usage example: err := ax.WriteFile("README.md", []byte("hi"), 0o644) +func WriteFile(path string, data []byte, mode fs.FileMode) error { + if err := coreio.Local.WriteMode(path, string(data), mode); err != nil { + return coreerr.E("ax.WriteFile", "failed to write file "+path, err) + } + return nil +} + +// WriteString writes text via io.Local with an explicit mode. +// +// Usage example: err := ax.WriteString("README.md", "hi", 0o644) +func WriteString(path, data string, mode fs.FileMode) error { + if err := coreio.Local.WriteMode(path, data, mode); err != nil { + return coreerr.E("ax.WriteString", "failed to write file "+path, err) + } + return nil +} + +// MkdirAll ensures a directory exists. +// +// Usage example: err := ax.MkdirAll("dist/linux_arm64", 0o755) +func MkdirAll(path string, mode fs.FileMode) error { + if mode == 0 { + mode = 0o755 + } + if err := os.MkdirAll(path, mode); err != nil { + return coreerr.E("ax.MkdirAll", "failed to create directory "+path, err) + } + return nil +} + +// Mkdir ensures a directory exists. +// +// Usage example: err := ax.Mkdir(".core", 0o755) +func Mkdir(path string, mode fs.FileMode) error { + return MkdirAll(path, mode) +} + +// RemoveAll removes a file or directory tree. +// +// Usage example: err := ax.RemoveAll("dist") +func RemoveAll(path string) error { + if err := coreio.Local.DeleteAll(path); err != nil { + return coreerr.E("ax.RemoveAll", "failed to remove path "+path, err) + } + return nil +} + +// Stat returns file metadata from io.Local. +// +// Usage example: info, err := ax.Stat("go.mod") +func Stat(path string) (fs.FileInfo, error) { + info, err := coreio.Local.Stat(path) + if err != nil { + return nil, coreerr.E("ax.Stat", "failed to stat path "+path, err) + } + return info, nil +} + +// ReadDir lists directory entries via io.Local. +// +// Usage example: entries, err := ax.ReadDir("dist") +func ReadDir(path string) ([]fs.DirEntry, error) { + entries, err := coreio.Local.List(path) + if err != nil { + return nil, coreerr.E("ax.ReadDir", "failed to list directory "+path, err) + } + return entries, nil +} + +// Open opens a file for reading via io.Local. +// +// Usage example: file, err := ax.Open("README.md") +func Open(path string) (fs.File, error) { + file, err := coreio.Local.Open(path) + if err != nil { + return nil, coreerr.E("ax.Open", "failed to open file "+path, err) + } + return file, nil +} + +// Create opens a file for writing via io.Local. +// +// Usage example: file, err := ax.Create("dist/output.txt") +func Create(path string) (io.WriteCloser, error) { + file, err := coreio.Local.Create(path) + if err != nil { + return nil, coreerr.E("ax.Create", "failed to create file "+path, err) + } + return file, nil +} + +// Exists reports whether a path exists. +// +// Usage example: if ax.Exists("dist") { ... } +func Exists(path string) bool { + return coreio.Local.Exists(path) +} + +// IsFile reports whether a path is a regular file. +// +// Usage example: if ax.IsFile("go.mod") { ... } +func IsFile(path string) bool { + return coreio.Local.IsFile(path) +} + +// IsDir reports whether a path is a directory. +// +// Usage example: if ax.IsDir(".core") { ... } +func IsDir(path string) bool { + return coreio.Local.IsDir(path) +} + +// Chmod updates file permissions without importing os. +// +// Usage example: err := ax.Chmod("dist/app", 0o755) +func Chmod(path string, mode fs.FileMode) error { + if err := os.Chmod(path, mode); err != nil { + return coreerr.E("ax.Chmod", "failed to change permissions on "+path, err) + } + return nil +} + +// Getuid returns the current process UID. +// +// Usage example: uid := ax.Getuid() +func Getuid() int { + return syscall.Getuid() +} + +// Getgid returns the current process GID. +// +// Usage example: gid := ax.Getgid() +func Getgid() int { + return syscall.Getgid() +} + +// Geteuid returns the effective UID. +// +// Usage example: if ax.Geteuid() == 0 { ... } +func Geteuid() int { + return syscall.Geteuid() +} + +// JSONMarshal returns a JSON string using Core's JSON wrapper. +// +// Usage example: data, err := ax.JSONMarshal(cfg) +func JSONMarshal(value any) (string, error) { + result := core.JSONMarshal(value) + if !result.OK { + err, ok := result.Value.(error) + if !ok { + return "", coreerr.E("ax.JSONMarshal", "failed to marshal JSON", nil) + } + return "", coreerr.E("ax.JSONMarshal", "failed to marshal JSON", err) + } + encoded, ok := result.Value.([]byte) + if !ok { + return "", coreerr.E("ax.JSONMarshal", "failed to marshal JSON", nil) + } + return string(encoded), nil +} + +// JSONUnmarshal decodes JSON into target using Core's JSON wrapper. +// +// Usage example: err := ax.JSONUnmarshal(data, &cfg) +func JSONUnmarshal(data []byte, target any) error { + result := core.JSONUnmarshal(data, target) + if !result.OK { + err, ok := result.Value.(error) + if !ok { + return coreerr.E("ax.JSONUnmarshal", "failed to unmarshal JSON", nil) + } + return coreerr.E("ax.JSONUnmarshal", "failed to unmarshal JSON", err) + } + return nil +} + +// LookPath resolves a program on PATH via the Core process package. +// +// Usage example: path, err := ax.LookPath("git") +func LookPath(name string) (string, error) { + program := process.Program{Name: name} + if err := program.Find(); err != nil { + return "", coreerr.E("ax.LookPath", "failed to locate command "+name, err) + } + return program.Path, nil +} + +// Run executes a command and returns trimmed combined output. +// +// Usage example: output, err := ax.Run(ctx, "git", "status", "--short") +func Run(ctx context.Context, command string, args ...string) (string, error) { + program := process.Program{Name: command} + return program.Run(ctx, args...) +} + +// RunDir executes a command in the provided directory and returns combined output. +// +// Usage example: output, err := ax.RunDir(ctx, repoDir, "git", "log", "--oneline") +func RunDir(ctx context.Context, dir, command string, args ...string) (string, error) { + program := process.Program{Name: command} + return program.RunDir(ctx, dir, args...) +} + +// Exec executes a command without capturing output. +// +// Usage example: err := ax.Exec(ctx, "go", "test", "./...") +func Exec(ctx context.Context, command string, args ...string) error { + return processexec.Command(ctx, command, args...).Run() +} + +// ExecDir executes a command in a specific directory without capturing output. +// +// Usage example: err := ax.ExecDir(ctx, repoDir, "go", "test", "./...") +func ExecDir(ctx context.Context, dir, command string, args ...string) error { + return processexec.Command(ctx, command, args...).WithDir(dir).Run() +} + +// ExecWithEnv executes a command with additional environment variables. +// +// Usage example: err := ax.ExecWithEnv(ctx, repoDir, []string{"GOOS=linux"}, "go", "build") +func ExecWithEnv(ctx context.Context, dir string, env []string, command string, args ...string) error { + return processexec.Command(ctx, command, args...).WithDir(dir).WithEnv(env).Run() +} + +// ExecWithWriters executes a command and streams output to the provided writers. +// +// Usage example: err := ax.ExecWithWriters(ctx, repoDir, nil, w, w, "docker", "build", ".") +func ExecWithWriters(ctx context.Context, dir string, env []string, stdout, stderr io.Writer, command string, args ...string) error { + cmd := processexec.Command(ctx, command, args...).WithDir(dir).WithEnv(env) + if stdout != nil { + cmd = cmd.WithStdout(stdout) + } + if stderr != nil { + cmd = cmd.WithStderr(stderr) + } + return cmd.Run() +} + +// CombinedOutput executes a command and returns combined output. +// +// Usage example: output, err := ax.CombinedOutput(ctx, repoDir, nil, "go", "test", "./...") +func CombinedOutput(ctx context.Context, dir string, env []string, command string, args ...string) (string, error) { + cmd := processexec.Command(ctx, command, args...).WithDir(dir).WithEnv(env) + output, err := cmd.CombinedOutput() + return core.Trim(string(output)), err +} diff --git a/locales/embed.go b/locales/embed.go index 1fa659a..98b854b 100644 --- a/locales/embed.go +++ b/locales/embed.go @@ -7,6 +7,8 @@ import ( "dappco.re/go/core/i18n" ) +// Usage example: use locales.FS from package consumers as needed. +// //go:embed *.json var FS embed.FS diff --git a/pkg/api/embed.go b/pkg/api/embed.go index 2270c3c..2624f38 100644 --- a/pkg/api/embed.go +++ b/pkg/api/embed.go @@ -7,5 +7,7 @@ import "embed" // Assets holds the built UI bundle (core-build.js and related files). // The directory is populated by running `npm run build` in the ui/ directory. // +// Usage example: use api.Assets from package consumers as needed. +// //go:embed all:ui/dist var Assets embed.FS diff --git a/pkg/api/provider.go b/pkg/api/provider.go index 645c923..3a75428 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -6,12 +6,12 @@ package api import ( + "io/fs" "net/http" - "os" - "path/filepath" "dappco.re/go/core/api" "dappco.re/go/core/api/pkg/provider" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/build/pkg/build/builders" "dappco.re/go/core/build/pkg/release" @@ -24,6 +24,7 @@ import ( // BuildProvider wraps go-build's build, release, and SDK operations as a // service provider. It implements Provider, Streamable, Describable, and // Renderable. +// Usage example: declare a value of type api.BuildProvider in integrating code. type BuildProvider struct { hub *ws.Hub projectDir string @@ -41,6 +42,7 @@ var ( // NewProvider creates a BuildProvider for the given project directory. // If projectDir is empty, the current working directory is used. // The WS hub is used to emit real-time build events; pass nil if not available. +// Usage example: call api.NewProvider(...) from integrating code. func NewProvider(projectDir string, hub *ws.Hub) *BuildProvider { if projectDir == "" { projectDir = "." @@ -53,12 +55,15 @@ func NewProvider(projectDir string, hub *ws.Hub) *BuildProvider { } // Name implements api.RouteGroup. +// Usage example: call value.Name(...) from integrating code. func (p *BuildProvider) Name() string { return "build" } // BasePath implements api.RouteGroup. +// Usage example: call value.BasePath(...) from integrating code. func (p *BuildProvider) BasePath() string { return "/api/v1/build" } // Element implements provider.Renderable. +// Usage example: call value.Element(...) from integrating code. func (p *BuildProvider) Element() provider.ElementSpec { return provider.ElementSpec{ Tag: "core-build-panel", @@ -67,6 +72,7 @@ func (p *BuildProvider) Element() provider.ElementSpec { } // Channels implements provider.Streamable. +// Usage example: call value.Channels(...) from integrating code. func (p *BuildProvider) Channels() []string { return []string{ "build.started", @@ -79,6 +85,7 @@ func (p *BuildProvider) Channels() []string { } // RegisterRoutes implements api.RouteGroup. +// Usage example: call value.RegisterRoutes(...) from integrating code. func (p *BuildProvider) RegisterRoutes(rg *gin.RouterGroup) { // Build rg.GET("/config", p.getConfig) @@ -97,6 +104,7 @@ func (p *BuildProvider) RegisterRoutes(rg *gin.RouterGroup) { } // Describe implements api.DescribableGroup. +// Usage example: call value.Describe(...) from integrating code. func (p *BuildProvider) Describe() []api.RouteDescription { return []api.RouteDescription{ { @@ -180,7 +188,7 @@ func (p *BuildProvider) Describe() []api.RouteDescription { // resolveDir returns the absolute project directory. func (p *BuildProvider) resolveDir() (string, error) { - return filepath.Abs(p.projectDir) + return ax.Abs(p.projectDir) } // -- Build Handlers ----------------------------------------------------------- @@ -278,10 +286,10 @@ func (p *BuildProvider) triggerBuild(c *gin.Context) { binaryName = cfg.Project.Name } if binaryName == "" { - binaryName = filepath.Base(dir) + binaryName = ax.Base(dir) } - outputDir := filepath.Join(dir, "dist") + outputDir := ax.Join(dir, "dist") buildConfig := &build.Config{ FS: p.medium, @@ -348,7 +356,7 @@ func (p *BuildProvider) listArtifacts(c *gin.Context) { return } - distDir := filepath.Join(dir, "dist") + distDir := ax.Join(dir, "dist") if !p.medium.IsDir(distDir) { c.JSON(http.StatusOK, api.OK(map[string]any{ "artifacts": []artifactInfo{}, @@ -374,7 +382,7 @@ func (p *BuildProvider) listArtifacts(c *gin.Context) { } artifacts = append(artifacts, artifactInfo{ Name: entry.Name(), - Path: filepath.Join(distDir, entry.Name()), + Path: ax.Join(distDir, entry.Name()), Size: info.Size(), }) } @@ -566,7 +574,7 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) { case build.ProjectTypeGo: return builders.NewGoBuilder(), nil default: - return nil, os.ErrNotExist + return nil, fs.ErrNotExist } } diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index aca88c4..e5c6110 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -3,7 +3,7 @@ package api import ( - "os" + "io/fs" "testing" "dappco.re/go/core/build/pkg/build" @@ -11,14 +11,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestBuildProvider_Good_Identity(t *testing.T) { +func TestProvider_BuildProviderIdentity_Good(t *testing.T) { p := NewProvider(".", nil) assert.Equal(t, "build", p.Name()) assert.Equal(t, "/api/v1/build", p.BasePath()) } -func TestBuildProvider_Good_Element(t *testing.T) { +func TestProvider_BuildProviderElement_Good(t *testing.T) { p := NewProvider(".", nil) el := p.Element() @@ -26,7 +26,7 @@ func TestBuildProvider_Good_Element(t *testing.T) { assert.Equal(t, "/assets/core-build.js", el.Source) } -func TestBuildProvider_Good_Channels(t *testing.T) { +func TestProvider_BuildProviderChannels_Good(t *testing.T) { p := NewProvider(".", nil) channels := p.Channels() @@ -39,7 +39,7 @@ func TestBuildProvider_Good_Channels(t *testing.T) { assert.Len(t, channels, 6) } -func TestBuildProvider_Good_Describe(t *testing.T) { +func TestProvider_BuildProviderDescribe_Good(t *testing.T) { p := NewProvider(".", nil) routes := p.Describe() @@ -63,23 +63,23 @@ func TestBuildProvider_Good_Describe(t *testing.T) { assert.Equal(t, "POST", paths["/sdk/generate"]) } -func TestBuildProvider_Good_DefaultProjectDir(t *testing.T) { +func TestProvider_BuildProviderDefaultProjectDir_Good(t *testing.T) { p := NewProvider("", nil) assert.Equal(t, ".", p.projectDir) } -func TestBuildProvider_Good_CustomProjectDir(t *testing.T) { +func TestProvider_BuildProviderCustomProjectDir_Good(t *testing.T) { p := NewProvider("/tmp/myproject", nil) assert.Equal(t, "/tmp/myproject", p.projectDir) } -func TestBuildProvider_Good_NilHub(t *testing.T) { +func TestProvider_BuildProviderNilHub_Good(t *testing.T) { p := NewProvider(".", nil) // emitEvent should not panic with nil hub p.emitEvent("build.started", map[string]any{"test": true}) } -func TestGetBuilder_Good_SupportedTypes(t *testing.T) { +func TestProvider_GetBuilderSupportedTypes_Good(t *testing.T) { b, err := getBuilder(build.ProjectTypeGo) require.NoError(t, err) assert.Equal(t, "go", b.Name()) @@ -89,19 +89,19 @@ func TestGetBuilder_Good_SupportedTypes(t *testing.T) { assert.Equal(t, "wails", b.Name()) } -func TestGetBuilder_Bad_UnsupportedType(t *testing.T) { +func TestProvider_GetBuilderUnsupportedType_Bad(t *testing.T) { _, err := getBuilder(build.ProjectType("unknown")) - assert.ErrorIs(t, err, os.ErrNotExist) + assert.ErrorIs(t, err, fs.ErrNotExist) } -func TestBuildProvider_Good_ResolveDir(t *testing.T) { +func TestProvider_BuildProviderResolveDir_Good(t *testing.T) { p := NewProvider("/tmp", nil) dir, err := p.resolveDir() require.NoError(t, err) assert.Equal(t, "/tmp", dir) } -func TestBuildProvider_Good_ResolveDirRelative(t *testing.T) { +func TestProvider_BuildProviderResolveDirRelative_Good(t *testing.T) { p := NewProvider(".", nil) dir, err := p.resolveDir() require.NoError(t, err) @@ -109,7 +109,7 @@ func TestBuildProvider_Good_ResolveDirRelative(t *testing.T) { assert.True(t, len(dir) > 1 && dir[0] == '/') } -func TestBuildProvider_Good_MediumSet(t *testing.T) { +func TestProvider_BuildProviderMediumSet_Good(t *testing.T) { p := NewProvider(".", nil) assert.NotNil(t, p.medium, "medium should be set to io.Local") } diff --git a/pkg/build/archive.go b/pkg/build/archive.go index 58af1c4..e2dbb0e 100644 --- a/pkg/build/archive.go +++ b/pkg/build/archive.go @@ -6,17 +6,17 @@ import ( "archive/zip" "bytes" "compress/gzip" - "fmt" "io" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" io_interface "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" "github.com/Snider/Borg/pkg/compress" ) // ArchiveFormat specifies the compression format for archives. +// Usage example: declare a value of type build.ArchiveFormat in integrating code. type ArchiveFormat string const ( @@ -32,6 +32,7 @@ const ( // Uses tar.gz for linux/darwin and zip for windows. // The archive is created alongside the binary (e.g., dist/myapp_linux_amd64.tar.gz). // Returns a new Artifact with Path pointing to the archive. +// Usage example: call build.Archive(...) from integrating code. func Archive(fs io_interface.Medium, artifact Artifact) (Artifact, error) { return ArchiveWithFormat(fs, artifact, ArchiveFormatGzip) } @@ -39,6 +40,7 @@ func Archive(fs io_interface.Medium, artifact Artifact) (Artifact, error) { // ArchiveXZ creates an archive for a single artifact using xz compression. // Uses tar.xz for linux/darwin and zip for windows. // Returns a new Artifact with Path pointing to the archive. +// Usage example: call build.ArchiveXZ(...) from integrating code. func ArchiveXZ(fs io_interface.Medium, artifact Artifact) (Artifact, error) { return ArchiveWithFormat(fs, artifact, ArchiveFormatXZ) } @@ -47,6 +49,7 @@ func ArchiveXZ(fs io_interface.Medium, artifact Artifact) (Artifact, error) { // Uses tar.gz or tar.xz for linux/darwin and zip for windows. // The archive is created alongside the binary (e.g., dist/myapp_linux_amd64.tar.xz). // Returns a new Artifact with Path pointing to the archive. +// Usage example: call build.ArchiveWithFormat(...) from integrating code. func ArchiveWithFormat(fs io_interface.Medium, artifact Artifact, format ArchiveFormat) (Artifact, error) { if artifact.Path == "" { return Artifact{}, coreerr.E("build.Archive", "artifact path is empty", nil) @@ -94,18 +97,21 @@ func ArchiveWithFormat(fs io_interface.Medium, artifact Artifact, format Archive // ArchiveAll archives all artifacts using gzip compression. // Returns a slice of new artifacts pointing to the archives. +// Usage example: call build.ArchiveAll(...) from integrating code. func ArchiveAll(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) { return ArchiveAllWithFormat(fs, artifacts, ArchiveFormatGzip) } // ArchiveAllXZ archives all artifacts using xz compression. // Returns a slice of new artifacts pointing to the archives. +// Usage example: call build.ArchiveAllXZ(...) from integrating code. func ArchiveAllXZ(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) { return ArchiveAllWithFormat(fs, artifacts, ArchiveFormatXZ) } // ArchiveAllWithFormat archives all artifacts with the specified format. // Returns a slice of new artifacts pointing to the archives. +// Usage example: call build.ArchiveAllWithFormat(...) from integrating code. func ArchiveAllWithFormat(fs io_interface.Medium, artifacts []Artifact, format ArchiveFormat) ([]Artifact, error) { if len(artifacts) == 0 { return nil, nil @@ -127,18 +133,18 @@ func ArchiveAllWithFormat(fs io_interface.Medium, artifacts []Artifact, format A // Format: dist/myapp_linux_amd64.tar.gz (binary name taken from artifact path). func archiveFilename(artifact Artifact, ext string) string { // Get the directory containing the binary (e.g., dist/linux_amd64) - dir := filepath.Dir(artifact.Path) + dir := ax.Dir(artifact.Path) // Go up one level to the output directory (e.g., dist) - outputDir := filepath.Dir(dir) + outputDir := ax.Dir(dir) // Get the binary name without extension - binaryName := filepath.Base(artifact.Path) - binaryName = strings.TrimSuffix(binaryName, ".exe") + binaryName := ax.Base(artifact.Path) + binaryName = core.TrimSuffix(binaryName, ".exe") // Construct archive name: myapp_linux_amd64.tar.gz - archiveName := fmt.Sprintf("%s_%s_%s%s", binaryName, artifact.OS, artifact.Arch, ext) + archiveName := core.Sprintf("%s_%s_%s%s", binaryName, artifact.OS, artifact.Arch, ext) - return filepath.Join(outputDir, archiveName) + return ax.Join(outputDir, archiveName) } // createTarXzArchive creates a tar.xz archive containing a single file. @@ -165,7 +171,7 @@ func createTarXzArchive(fs io_interface.Medium, src, dst string) error { if err != nil { return coreerr.E("build.createTarXzArchive", "failed to create tar header", err) } - header.Name = filepath.Base(src) + header.Name = ax.Base(src) if err := tarWriter.WriteHeader(header); err != nil { return coreerr.E("build.createTarXzArchive", "failed to write tar header", err) @@ -234,7 +240,7 @@ func createTarGzArchive(fs io_interface.Medium, src, dst string) error { return coreerr.E("build.createTarGzArchive", "failed to create tar header", err) } // Use just the filename, not the full path - header.Name = filepath.Base(src) + header.Name = ax.Base(src) // Write header if err := tarWriter.WriteHeader(header); err != nil { @@ -280,7 +286,7 @@ func createZipArchive(fs io_interface.Medium, src, dst string) error { return coreerr.E("build.createZipArchive", "failed to create zip header", err) } // Use just the filename, not the full path - header.Name = filepath.Base(src) + header.Name = ax.Base(src) header.Method = zip.Deflate // Create file in archive diff --git a/pkg/build/archive_test.go b/pkg/build/archive_test.go index b0e475d..1805947 100644 --- a/pkg/build/archive_test.go +++ b/pkg/build/archive_test.go @@ -6,10 +6,11 @@ import ( "bytes" "compress/gzip" "io" - "os" - "path/filepath" + stdfs "io/fs" "testing" + "dappco.re/go/core/build/internal/ax" + io_interface "dappco.re/go/core/io" "github.com/Snider/Borg/pkg/compress" "github.com/stretchr/testify/assert" @@ -24,20 +25,20 @@ func setupArchiveTestFile(t *testing.T, name, os_, arch string) (binaryPath stri outputDir = t.TempDir() // Create platform directory: dist/os_arch - platformDir := filepath.Join(outputDir, os_+"_"+arch) - err := os.MkdirAll(platformDir, 0755) + platformDir := ax.Join(outputDir, os_+"_"+arch) + err := ax.MkdirAll(platformDir, 0755) require.NoError(t, err) // Create test binary - binaryPath = filepath.Join(platformDir, name) + binaryPath = ax.Join(platformDir, name) content := []byte("#!/bin/bash\necho 'Hello, World!'\n") - err = os.WriteFile(binaryPath, content, 0755) + err = ax.WriteFile(binaryPath, content, 0755) require.NoError(t, err) return binaryPath, outputDir } -func TestArchive_Good(t *testing.T) { +func TestArchive_Archive_Good(t *testing.T) { fs := io_interface.Local t.Run("creates tar.gz for linux", func(t *testing.T) { binaryPath, outputDir := setupArchiveTestFile(t, "myapp", "linux", "amd64") @@ -52,7 +53,7 @@ func TestArchive_Good(t *testing.T) { require.NoError(t, err) // Verify archive was created - expectedPath := filepath.Join(outputDir, "myapp_linux_amd64.tar.gz") + expectedPath := ax.Join(outputDir, "myapp_linux_amd64.tar.gz") assert.Equal(t, expectedPath, result.Path) assert.FileExists(t, result.Path) @@ -76,7 +77,7 @@ func TestArchive_Good(t *testing.T) { result, err := Archive(fs, artifact) require.NoError(t, err) - expectedPath := filepath.Join(outputDir, "myapp_darwin_arm64.tar.gz") + expectedPath := ax.Join(outputDir, "myapp_darwin_arm64.tar.gz") assert.Equal(t, expectedPath, result.Path) assert.FileExists(t, result.Path) @@ -96,7 +97,7 @@ func TestArchive_Good(t *testing.T) { require.NoError(t, err) // Windows archives should strip .exe from archive name - expectedPath := filepath.Join(outputDir, "myapp_windows_amd64.zip") + expectedPath := ax.Join(outputDir, "myapp_windows_amd64.zip") assert.Equal(t, expectedPath, result.Path) assert.FileExists(t, result.Path) @@ -130,7 +131,7 @@ func TestArchive_Good(t *testing.T) { result, err := ArchiveXZ(fs, artifact) require.NoError(t, err) - expectedPath := filepath.Join(outputDir, "myapp_linux_amd64.tar.xz") + expectedPath := ax.Join(outputDir, "myapp_linux_amd64.tar.xz") assert.Equal(t, expectedPath, result.Path) assert.FileExists(t, result.Path) @@ -149,7 +150,7 @@ func TestArchive_Good(t *testing.T) { result, err := ArchiveWithFormat(fs, artifact, ArchiveFormatXZ) require.NoError(t, err) - expectedPath := filepath.Join(outputDir, "myapp_darwin_arm64.tar.xz") + expectedPath := ax.Join(outputDir, "myapp_darwin_arm64.tar.xz") assert.Equal(t, expectedPath, result.Path) assert.FileExists(t, result.Path) @@ -169,7 +170,7 @@ func TestArchive_Good(t *testing.T) { require.NoError(t, err) // Windows should still get .zip regardless of format - expectedPath := filepath.Join(outputDir, "myapp_windows_amd64.zip") + expectedPath := ax.Join(outputDir, "myapp_windows_amd64.zip") assert.Equal(t, expectedPath, result.Path) assert.FileExists(t, result.Path) @@ -177,7 +178,7 @@ func TestArchive_Good(t *testing.T) { }) } -func TestArchive_Bad(t *testing.T) { +func TestArchive_Archive_Bad(t *testing.T) { fs := io_interface.Local t.Run("returns error for empty path", func(t *testing.T) { artifact := Artifact{ @@ -221,7 +222,7 @@ func TestArchive_Bad(t *testing.T) { }) } -func TestArchiveAll_Good(t *testing.T) { +func TestArchive_ArchiveAll_Good(t *testing.T) { fs := io_interface.Local t.Run("archives multiple artifacts", func(t *testing.T) { outputDir := t.TempDir() @@ -239,8 +240,8 @@ func TestArchiveAll_Good(t *testing.T) { } for _, target := range targets { - platformDir := filepath.Join(outputDir, target.os_+"_"+target.arch) - err := os.MkdirAll(platformDir, 0755) + platformDir := ax.Join(outputDir, target.os_+"_"+target.arch) + err := ax.MkdirAll(platformDir, 0755) require.NoError(t, err) name := "myapp" @@ -248,8 +249,8 @@ func TestArchiveAll_Good(t *testing.T) { name = "myapp.exe" } - binaryPath := filepath.Join(platformDir, name) - err = os.WriteFile(binaryPath, []byte("binary content"), 0755) + binaryPath := ax.Join(platformDir, name) + err = ax.WriteFile(binaryPath, []byte("binary content"), 0755) require.NoError(t, err) artifacts = append(artifacts, Artifact{ @@ -284,7 +285,7 @@ func TestArchiveAll_Good(t *testing.T) { }) } -func TestArchiveAll_Bad(t *testing.T) { +func TestArchive_ArchiveAll_Bad(t *testing.T) { fs := io_interface.Local t.Run("returns partial results on error", func(t *testing.T) { binaryPath, _ := setupArchiveTestFile(t, "myapp", "linux", "amd64") @@ -302,7 +303,7 @@ func TestArchiveAll_Bad(t *testing.T) { }) } -func TestArchiveFilename_Good(t *testing.T) { +func TestArchive_ArchiveFilename_Good(t *testing.T) { t.Run("generates correct tar.gz filename", func(t *testing.T) { artifact := Artifact{ Path: "/output/linux_amd64/myapp", @@ -344,7 +345,7 @@ func TestArchive_RoundTrip_Good(t *testing.T) { binaryPath, _ := setupArchiveTestFile(t, "roundtrip-app", "linux", "amd64") // Read original content - originalContent, err := os.ReadFile(binaryPath) + originalContent, err := ax.ReadFile(binaryPath) require.NoError(t, err) artifact := Artifact{ @@ -366,7 +367,7 @@ func TestArchive_RoundTrip_Good(t *testing.T) { t.Run("tar.xz round trip preserves content", func(t *testing.T) { binaryPath, _ := setupArchiveTestFile(t, "roundtrip-xz", "linux", "arm64") - originalContent, err := os.ReadFile(binaryPath) + originalContent, err := ax.ReadFile(binaryPath) require.NoError(t, err) artifact := Artifact{ @@ -386,7 +387,7 @@ func TestArchive_RoundTrip_Good(t *testing.T) { t.Run("zip round trip preserves content", func(t *testing.T) { binaryPath, _ := setupArchiveTestFile(t, "roundtrip.exe", "windows", "amd64") - originalContent, err := os.ReadFile(binaryPath) + originalContent, err := ax.ReadFile(binaryPath) require.NoError(t, err) artifact := Artifact{ @@ -418,21 +419,21 @@ func TestArchive_RoundTrip_Good(t *testing.T) { // Extract and verify permissions are preserved mode := extractTarGzFileMode(t, archiveArtifact.Path, "perms-app") // The original file was written with 0755 - assert.Equal(t, os.FileMode(0755), mode&os.ModePerm) + assert.Equal(t, stdfs.FileMode(0o755), mode&stdfs.ModePerm) }) t.Run("round trip with large binary content", func(t *testing.T) { outputDir := t.TempDir() - platformDir := filepath.Join(outputDir, "linux_amd64") - require.NoError(t, os.MkdirAll(platformDir, 0755)) + platformDir := ax.Join(outputDir, "linux_amd64") + require.NoError(t, ax.MkdirAll(platformDir, 0755)) // Create a larger file (64KB) largeContent := make([]byte, 64*1024) for i := range largeContent { largeContent[i] = byte(i % 256) } - binaryPath := filepath.Join(platformDir, "large-app") - require.NoError(t, os.WriteFile(binaryPath, largeContent, 0755)) + binaryPath := ax.Join(platformDir, "large-app") + require.NoError(t, ax.WriteFile(binaryPath, largeContent, 0755)) artifact := Artifact{ Path: binaryPath, @@ -449,16 +450,16 @@ func TestArchive_RoundTrip_Good(t *testing.T) { t.Run("archive is smaller than original for tar.gz", func(t *testing.T) { outputDir := t.TempDir() - platformDir := filepath.Join(outputDir, "linux_amd64") - require.NoError(t, os.MkdirAll(platformDir, 0755)) + platformDir := ax.Join(outputDir, "linux_amd64") + require.NoError(t, ax.MkdirAll(platformDir, 0755)) // Create a compressible file (repeated pattern) compressibleContent := make([]byte, 4096) for i := range compressibleContent { compressibleContent[i] = 'A' } - binaryPath := filepath.Join(platformDir, "compressible-app") - require.NoError(t, os.WriteFile(binaryPath, compressibleContent, 0755)) + binaryPath := ax.Join(platformDir, "compressible-app") + require.NoError(t, ax.WriteFile(binaryPath, compressibleContent, 0755)) artifact := Artifact{ Path: binaryPath, @@ -469,9 +470,9 @@ func TestArchive_RoundTrip_Good(t *testing.T) { archiveArtifact, err := Archive(fs, artifact) require.NoError(t, err) - originalInfo, err := os.Stat(binaryPath) + originalInfo, err := ax.Stat(binaryPath) require.NoError(t, err) - archiveInfo, err := os.Stat(archiveArtifact.Path) + archiveInfo, err := ax.Stat(archiveArtifact.Path) require.NoError(t, err) // Compressed archive should be smaller than original @@ -483,7 +484,7 @@ func TestArchive_RoundTrip_Good(t *testing.T) { func extractTarGzFile(t *testing.T, archivePath, fileName string) []byte { t.Helper() - file, err := os.Open(archivePath) + file, err := ax.Open(archivePath) require.NoError(t, err) defer func() { _ = file.Close() }() @@ -509,10 +510,10 @@ func extractTarGzFile(t *testing.T, archivePath, fileName string) []byte { } // extractTarGzFileMode extracts the file mode of a named file from a tar.gz archive. -func extractTarGzFileMode(t *testing.T, archivePath, fileName string) os.FileMode { +func extractTarGzFileMode(t *testing.T, archivePath, fileName string) stdfs.FileMode { t.Helper() - file, err := os.Open(archivePath) + file, err := ax.Open(archivePath) require.NoError(t, err) defer func() { _ = file.Close() }() @@ -539,7 +540,7 @@ func extractTarGzFileMode(t *testing.T, archivePath, fileName string) os.FileMod func extractTarXzFile(t *testing.T, archivePath, fileName string) []byte { t.Helper() - xzData, err := os.ReadFile(archivePath) + xzData, err := ax.ReadFile(archivePath) require.NoError(t, err) tarData, err := compress.Decompress(xzData) @@ -590,7 +591,7 @@ func extractZipFile(t *testing.T, archivePath, fileName string) []byte { func verifyTarGzContent(t *testing.T, archivePath, expectedName string) { t.Helper() - file, err := os.Open(archivePath) + file, err := ax.Open(archivePath) require.NoError(t, err) defer func() { _ = file.Close() }() @@ -626,7 +627,7 @@ func verifyTarXzContent(t *testing.T, archivePath, expectedName string) { t.Helper() // Read the xz-compressed file - xzData, err := os.ReadFile(archivePath) + xzData, err := ax.ReadFile(archivePath) require.NoError(t, err) // Decompress with Borg diff --git a/pkg/build/build.go b/pkg/build/build.go index 783db1f..4d3e84e 100644 --- a/pkg/build/build.go +++ b/pkg/build/build.go @@ -10,6 +10,7 @@ import ( ) // ProjectType represents a detected project type. +// Usage example: declare a value of type build.ProjectType in integrating code. type ProjectType string // Project type constants for build detection. @@ -33,17 +34,20 @@ const ( ) // Target represents a build target platform. +// Usage example: declare a value of type build.Target in integrating code. type Target struct { OS string Arch string } // String returns the target in GOOS/GOARCH format. +// Usage example: call value.String(...) from integrating code. func (t Target) String() string { return t.OS + "/" + t.Arch } // Artifact represents a build output file. +// Usage example: declare a value of type build.Artifact in integrating code. type Artifact struct { Path string OS string @@ -52,6 +56,7 @@ type Artifact struct { } // Config holds build configuration. +// Usage example: declare a value of type build.Config in integrating code. type Config struct { // FS is the medium used for file operations. FS io.Medium @@ -82,6 +87,7 @@ type Config struct { } // Builder defines the interface for project-specific build implementations. +// Usage example: declare a value of type build.Builder in integrating code. type Builder interface { // Name returns the builder's identifier. Name() string diff --git a/pkg/build/builders/cpp.go b/pkg/build/builders/cpp.go index f2ff572..6718f94 100644 --- a/pkg/build/builders/cpp.go +++ b/pkg/build/builders/cpp.go @@ -3,13 +3,10 @@ package builders import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" "runtime" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" @@ -17,19 +14,23 @@ import ( // CPPBuilder implements the Builder interface for C++ projects using CMake + Conan. // It wraps the Makefile-based build system from the .core/build submodule. +// Usage example: declare a value of type builders.CPPBuilder in integrating code. type CPPBuilder struct{} // NewCPPBuilder creates a new CPPBuilder instance. +// Usage example: call builders.NewCPPBuilder(...) from integrating code. func NewCPPBuilder() *CPPBuilder { return &CPPBuilder{} } // Name returns the builder's identifier. +// Usage example: call value.Name(...) from integrating code. func (b *CPPBuilder) Name() string { return "cpp" } // Detect checks if this builder can handle the project in the given directory. +// Usage example: call value.Detect(...) from integrating code. func (b *CPPBuilder) Detect(fs io.Medium, dir string) (bool, error) { return build.IsCPPProject(fs, dir), nil } @@ -37,6 +38,7 @@ func (b *CPPBuilder) Detect(fs io.Medium, dir string) (bool, error) { // Build compiles the C++ project using Make targets. // The build flow is: make configure → make build → make package. // Cross-compilation is handled via Conan profiles specified in .core/build.yaml. +// Usage example: call value.Build(...) from integrating code. func (b *CPPBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { if cfg == nil { return nil, coreerr.E("CPPBuilder.Build", "config is nil", nil) @@ -83,7 +85,7 @@ func (b *CPPBuilder) buildTarget(ctx context.Context, cfg *build.Config, target // buildHost runs the standard make configure → make build → make package flow. func (b *CPPBuilder) buildHost(ctx context.Context, cfg *build.Config, target build.Target) ([]build.Artifact, error) { - fmt.Printf("Building C++ project for %s/%s (host)\n", target.OS, target.Arch) + core.Print(nil, "Building C++ project for %s/%s (host)", target.OS, target.Arch) // Step 1: Configure (runs conan install + cmake configure) if err := b.runMake(ctx, cfg.ProjectDir, "configure"); err != nil { @@ -113,7 +115,7 @@ func (b *CPPBuilder) buildCross(ctx context.Context, cfg *build.Config, target b return nil, coreerr.E("CPPBuilder.buildCross", "no Conan profile mapped for target "+target.OS+"/"+target.Arch, nil) } - fmt.Printf("Building C++ project for %s/%s (cross: %s)\n", target.OS, target.Arch, profile) + core.Print(nil, "Building C++ project for %s/%s (cross: %s)", target.OS, target.Arch, profile) // The Makefile exposes each profile as a top-level target if err := b.runMake(ctx, cfg.ProjectDir, profile); err != nil { @@ -125,13 +127,7 @@ func (b *CPPBuilder) buildCross(ctx context.Context, cfg *build.Config, target b // runMake executes a make target in the project directory. func (b *CPPBuilder) runMake(ctx context.Context, projectDir string, target string) error { - cmd := exec.CommandContext(ctx, "make", target) - cmd.Dir = projectDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = os.Environ() - - if err := cmd.Run(); err != nil { + if err := ax.ExecDir(ctx, projectDir, "make", target); err != nil { return coreerr.E("CPPBuilder.runMake", "make "+target+" failed", err) } return nil @@ -139,7 +135,7 @@ func (b *CPPBuilder) runMake(ctx context.Context, projectDir string, target stri // findArtifacts searches for built packages in build/packages/. func (b *CPPBuilder) findArtifacts(fs io.Medium, projectDir string, target build.Target) ([]build.Artifact, error) { - packagesDir := filepath.Join(projectDir, "build", "packages") + packagesDir := ax.Join(projectDir, "build", "packages") if !fs.IsDir(packagesDir) { // Fall back to searching build/release/src/ for raw binaries @@ -159,12 +155,12 @@ func (b *CPPBuilder) findArtifacts(fs io.Medium, projectDir string, target build name := entry.Name() // Skip checksum files and hidden files - if strings.HasSuffix(name, ".sha256") || strings.HasPrefix(name, ".") { + if core.HasSuffix(name, ".sha256") || core.HasPrefix(name, ".") { continue } artifacts = append(artifacts, build.Artifact{ - Path: filepath.Join(packagesDir, name), + Path: ax.Join(packagesDir, name), OS: target.OS, Arch: target.Arch, }) @@ -175,7 +171,7 @@ func (b *CPPBuilder) findArtifacts(fs io.Medium, projectDir string, target build // findBinaries searches for compiled binaries in build/release/src/. func (b *CPPBuilder) findBinaries(fs io.Medium, projectDir string, target build.Target) ([]build.Artifact, error) { - binDir := filepath.Join(projectDir, "build", "release", "src") + binDir := ax.Join(projectDir, "build", "release", "src") if !fs.IsDir(binDir) { return nil, coreerr.E("CPPBuilder.findBinaries", "no build output found in "+binDir, nil) @@ -194,16 +190,16 @@ func (b *CPPBuilder) findBinaries(fs io.Medium, projectDir string, target build. name := entry.Name() // Skip non-executable files (libraries, cmake files, etc.) - if strings.HasSuffix(name, ".a") || strings.HasSuffix(name, ".o") || - strings.HasSuffix(name, ".cmake") || strings.HasPrefix(name, ".") { + if core.HasSuffix(name, ".a") || core.HasSuffix(name, ".o") || + core.HasSuffix(name, ".cmake") || core.HasPrefix(name, ".") { continue } - fullPath := filepath.Join(binDir, name) + fullPath := ax.Join(binDir, name) // On Unix, check if file is executable if target.OS != "windows" { - info, err := os.Stat(fullPath) + info, err := fs.Stat(fullPath) if err != nil { continue } @@ -244,7 +240,7 @@ func (b *CPPBuilder) targetToProfile(target build.Target) string { // validateMake checks if make is available. func (b *CPPBuilder) validateMake() error { - if _, err := exec.LookPath("make"); err != nil { + if _, err := ax.LookPath("make"); err != nil { return coreerr.E("CPPBuilder.validateMake", "make not found. Install build-essential (Linux) or Xcode Command Line Tools (macOS)", nil) } return nil diff --git a/pkg/build/builders/cpp_test.go b/pkg/build/builders/cpp_test.go index e90bc3f..9314b81 100644 --- a/pkg/build/builders/cpp_test.go +++ b/pkg/build/builders/cpp_test.go @@ -1,27 +1,27 @@ package builders import ( - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestCPPBuilder_Name_Good(t *testing.T) { +func TestCPP_CPPBuilderName_Good(t *testing.T) { builder := NewCPPBuilder() assert.Equal(t, "cpp", builder.Name()) } -func TestCPPBuilder_Detect_Good(t *testing.T) { +func TestCPP_CPPBuilderDetect_Good(t *testing.T) { fs := io.Local t.Run("detects C++ project with CMakeLists.txt", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "CMakeLists.txt"), []byte("cmake_minimum_required(VERSION 3.16)"), 0644) + err := ax.WriteFile(ax.Join(dir, "CMakeLists.txt"), []byte("cmake_minimum_required(VERSION 3.16)"), 0644) require.NoError(t, err) builder := NewCPPBuilder() @@ -32,7 +32,7 @@ func TestCPPBuilder_Detect_Good(t *testing.T) { t.Run("returns false for non-C++ project", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + err := ax.WriteFile(ax.Join(dir, "go.mod"), []byte("module test"), 0644) require.NoError(t, err) builder := NewCPPBuilder() @@ -51,7 +51,7 @@ func TestCPPBuilder_Detect_Good(t *testing.T) { }) } -func TestCPPBuilder_Build_Bad(t *testing.T) { +func TestCPP_CPPBuilderBuild_Bad(t *testing.T) { t.Run("returns error for nil config", func(t *testing.T) { builder := NewCPPBuilder() artifacts, err := builder.Build(nil, nil, []build.Target{{OS: "linux", Arch: "amd64"}}) @@ -61,7 +61,7 @@ func TestCPPBuilder_Build_Bad(t *testing.T) { }) } -func TestCPPBuilder_TargetToProfile_Good(t *testing.T) { +func TestCPP_CPPBuilderTargetToProfile_Good(t *testing.T) { builder := NewCPPBuilder() tests := []struct { @@ -84,7 +84,7 @@ func TestCPPBuilder_TargetToProfile_Good(t *testing.T) { } } -func TestCPPBuilder_TargetToProfile_Bad(t *testing.T) { +func TestCPP_CPPBuilderTargetToProfile_Bad(t *testing.T) { builder := NewCPPBuilder() t.Run("returns empty for unknown target", func(t *testing.T) { @@ -93,18 +93,18 @@ func TestCPPBuilder_TargetToProfile_Bad(t *testing.T) { }) } -func TestCPPBuilder_FindArtifacts_Good(t *testing.T) { +func TestCPP_CPPBuilderFindArtifacts_Good(t *testing.T) { fs := io.Local t.Run("finds packages in build/packages", func(t *testing.T) { dir := t.TempDir() - packagesDir := filepath.Join(dir, "build", "packages") - require.NoError(t, os.MkdirAll(packagesDir, 0755)) + packagesDir := ax.Join(dir, "build", "packages") + require.NoError(t, ax.MkdirAll(packagesDir, 0755)) // Create mock package files - require.NoError(t, os.WriteFile(filepath.Join(packagesDir, "test-1.0-linux-x86_64.tar.xz"), []byte("pkg"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(packagesDir, "test-1.0-linux-x86_64.tar.xz.sha256"), []byte("checksum"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(packagesDir, "test-1.0-linux-x86_64.rpm"), []byte("rpm"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(packagesDir, "test-1.0-linux-x86_64.tar.xz"), []byte("pkg"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(packagesDir, "test-1.0-linux-x86_64.tar.xz.sha256"), []byte("checksum"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(packagesDir, "test-1.0-linux-x86_64.rpm"), []byte("rpm"), 0644)) builder := NewCPPBuilder() target := build.Target{OS: "linux", Arch: "amd64"} @@ -116,21 +116,21 @@ func TestCPPBuilder_FindArtifacts_Good(t *testing.T) { for _, a := range artifacts { assert.Equal(t, "linux", a.OS) assert.Equal(t, "amd64", a.Arch) - assert.False(t, filepath.Ext(a.Path) == ".sha256") + assert.False(t, ax.Ext(a.Path) == ".sha256") } }) t.Run("falls back to binaries in build/release/src", func(t *testing.T) { dir := t.TempDir() - binDir := filepath.Join(dir, "build", "release", "src") - require.NoError(t, os.MkdirAll(binDir, 0755)) + binDir := ax.Join(dir, "build", "release", "src") + require.NoError(t, ax.MkdirAll(binDir, 0755)) // Create mock binary (executable) - binPath := filepath.Join(binDir, "test-daemon") - require.NoError(t, os.WriteFile(binPath, []byte("binary"), 0755)) + binPath := ax.Join(binDir, "test-daemon") + require.NoError(t, ax.WriteFile(binPath, []byte("binary"), 0755)) // Create a library (should be skipped) - require.NoError(t, os.WriteFile(filepath.Join(binDir, "libcrypto.a"), []byte("lib"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(binDir, "libcrypto.a"), []byte("lib"), 0644)) builder := NewCPPBuilder() target := build.Target{OS: "linux", Arch: "amd64"} @@ -143,7 +143,7 @@ func TestCPPBuilder_FindArtifacts_Good(t *testing.T) { }) } -func TestCPPBuilder_Interface_Good(t *testing.T) { +func TestCPP_CPPBuilderInterface_Good(t *testing.T) { var _ build.Builder = (*CPPBuilder)(nil) var _ build.Builder = NewCPPBuilder() } diff --git a/pkg/build/builders/docker.go b/pkg/build/builders/docker.go index 7025bce..6cff56b 100644 --- a/pkg/build/builders/docker.go +++ b/pkg/build/builders/docker.go @@ -3,33 +3,34 @@ package builders import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // DockerBuilder builds Docker images. +// Usage example: declare a value of type builders.DockerBuilder in integrating code. type DockerBuilder struct{} // NewDockerBuilder creates a new Docker builder. +// Usage example: call builders.NewDockerBuilder(...) from integrating code. func NewDockerBuilder() *DockerBuilder { return &DockerBuilder{} } // Name returns the builder's identifier. +// Usage example: call value.Name(...) from integrating code. func (b *DockerBuilder) Name() string { return "docker" } // Detect checks if a Dockerfile exists in the directory. +// Usage example: call value.Detect(...) from integrating code. func (b *DockerBuilder) Detect(fs io.Medium, dir string) (bool, error) { - dockerfilePath := filepath.Join(dir, "Dockerfile") + dockerfilePath := ax.Join(dir, "Dockerfile") if fs.IsFile(dockerfilePath) { return true, nil } @@ -37,6 +38,7 @@ func (b *DockerBuilder) Detect(fs io.Medium, dir string) (bool, error) { } // Build builds Docker images for the specified targets. +// Usage example: call value.Build(...) from integrating code. func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { // Validate docker CLI is available if err := b.validateDockerCli(); err != nil { @@ -51,7 +53,7 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] // Determine Dockerfile path dockerfile := cfg.Dockerfile if dockerfile == "" { - dockerfile = filepath.Join(cfg.ProjectDir, "Dockerfile") + dockerfile = ax.Join(cfg.ProjectDir, "Dockerfile") } // Validate Dockerfile exists @@ -65,13 +67,13 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] imageName = cfg.Name } if imageName == "" { - imageName = filepath.Base(cfg.ProjectDir) + imageName = ax.Base(cfg.ProjectDir) } // Build platform string from targets var platforms []string for _, t := range targets { - platforms = append(platforms, fmt.Sprintf("%s/%s", t.OS, t.Arch)) + platforms = append(platforms, core.Sprintf("%s/%s", t.OS, t.Arch)) } // If no targets specified, use current platform @@ -98,13 +100,13 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] var imageRefs []string for _, tag := range tags { // Expand version template - expandedTag := strings.ReplaceAll(tag, "{{.Version}}", cfg.Version) - expandedTag = strings.ReplaceAll(expandedTag, "{{Version}}", cfg.Version) + expandedTag := core.Replace(tag, "{{.Version}}", cfg.Version) + expandedTag = core.Replace(expandedTag, "{{Version}}", cfg.Version) if registry != "" { - imageRefs = append(imageRefs, fmt.Sprintf("%s/%s:%s", registry, imageName, expandedTag)) + imageRefs = append(imageRefs, core.Sprintf("%s/%s:%s", registry, imageName, expandedTag)) } else { - imageRefs = append(imageRefs, fmt.Sprintf("%s:%s", imageName, expandedTag)) + imageRefs = append(imageRefs, core.Sprintf("%s:%s", imageName, expandedTag)) } } @@ -112,7 +114,7 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] args := []string{"buildx", "build"} // Multi-platform support - args = append(args, "--platform", strings.Join(platforms, ",")) + args = append(args, "--platform", core.Join(",", platforms...)) // Add all tags for _, ref := range imageRefs { @@ -124,14 +126,14 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] // Build arguments for k, v := range cfg.BuildArgs { - expandedValue := strings.ReplaceAll(v, "{{.Version}}", cfg.Version) - expandedValue = strings.ReplaceAll(expandedValue, "{{Version}}", cfg.Version) - args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, expandedValue)) + expandedValue := core.Replace(v, "{{.Version}}", cfg.Version) + expandedValue = core.Replace(expandedValue, "{{Version}}", cfg.Version) + args = append(args, "--build-arg", core.Sprintf("%s=%s", k, expandedValue)) } // Always add VERSION build arg if version is set if cfg.Version != "" { - args = append(args, "--build-arg", fmt.Sprintf("VERSION=%s", cfg.Version)) + args = append(args, "--build-arg", core.Sprintf("VERSION=%s", cfg.Version)) } // Output to local docker images or push @@ -143,8 +145,8 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] args = append(args, "--load") } else { // Multi-platform builds can't use --load, output to tarball - outputPath := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s.tar", imageName)) - args = append(args, "--output", fmt.Sprintf("type=oci,dest=%s", outputPath)) + outputPath := ax.Join(cfg.OutputDir, core.Sprintf("%s.tar", imageName)) + args = append(args, "--output", core.Sprintf("type=oci,dest=%s", outputPath)) } } @@ -156,17 +158,11 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] return nil, coreerr.E("DockerBuilder.Build", "failed to create output directory", err) } - // Execute build - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Dir = cfg.ProjectDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + core.Print(nil, "Building Docker image: %s", imageName) + core.Print(nil, " Platforms: %s", core.Join(", ", platforms...)) + core.Print(nil, " Tags: %s", core.Join(", ", imageRefs...)) - fmt.Printf("Building Docker image: %s\n", imageName) - fmt.Printf(" Platforms: %s\n", strings.Join(platforms, ", ")) - fmt.Printf(" Tags: %s\n", strings.Join(imageRefs, ", ")) - - if err := cmd.Run(); err != nil { + if err := ax.ExecDir(ctx, cfg.ProjectDir, "docker", args...); err != nil { return nil, coreerr.E("DockerBuilder.Build", "buildx build failed", err) } @@ -185,8 +181,7 @@ func (b *DockerBuilder) Build(ctx context.Context, cfg *build.Config, targets [] // validateDockerCli checks if the docker CLI is available. func (b *DockerBuilder) validateDockerCli() error { - cmd := exec.Command("docker", "--version") - if err := cmd.Run(); err != nil { + if _, err := ax.LookPath("docker"); err != nil { return coreerr.E("DockerBuilder.validateDockerCli", "docker CLI not found. Install it from https://docs.docker.com/get-docker/", err) } return nil @@ -195,19 +190,14 @@ func (b *DockerBuilder) validateDockerCli() error { // ensureBuildx ensures docker buildx is available and has a builder. func (b *DockerBuilder) ensureBuildx(ctx context.Context) error { // Check if buildx is available - cmd := exec.CommandContext(ctx, "docker", "buildx", "version") - if err := cmd.Run(); err != nil { + if err := ax.Exec(ctx, "docker", "buildx", "version"); err != nil { return coreerr.E("DockerBuilder.ensureBuildx", "buildx is not available. Install it from https://docs.docker.com/buildx/working-with-buildx/", err) } // Check if we have a builder, create one if not - cmd = exec.CommandContext(ctx, "docker", "buildx", "inspect", "--bootstrap") - if err := cmd.Run(); err != nil { + if err := ax.Exec(ctx, "docker", "buildx", "inspect", "--bootstrap"); err != nil { // Try to create a builder - cmd = exec.CommandContext(ctx, "docker", "buildx", "create", "--use", "--bootstrap") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := ax.Exec(ctx, "docker", "buildx", "create", "--use", "--bootstrap"); err != nil { return coreerr.E("DockerBuilder.ensureBuildx", "failed to create buildx builder", err) } } diff --git a/pkg/build/builders/docker_test.go b/pkg/build/builders/docker_test.go index 9982d32..81f8b15 100644 --- a/pkg/build/builders/docker_test.go +++ b/pkg/build/builders/docker_test.go @@ -1,27 +1,27 @@ package builders import ( - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestDockerBuilder_Name_Good(t *testing.T) { +func TestDocker_DockerBuilderName_Good(t *testing.T) { builder := NewDockerBuilder() assert.Equal(t, "docker", builder.Name()) } -func TestDockerBuilder_Detect_Good(t *testing.T) { +func TestDocker_DockerBuilderDetect_Good(t *testing.T) { fs := io.Local t.Run("detects Dockerfile", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM alpine\n"), 0644) + err := ax.WriteFile(ax.Join(dir, "Dockerfile"), []byte("FROM alpine\n"), 0644) require.NoError(t, err) builder := NewDockerBuilder() @@ -42,7 +42,7 @@ func TestDockerBuilder_Detect_Good(t *testing.T) { t.Run("returns false for non-Docker project", func(t *testing.T) { dir := t.TempDir() // Create a Go project instead - err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + err := ax.WriteFile(ax.Join(dir, "go.mod"), []byte("module test"), 0644) require.NoError(t, err) builder := NewDockerBuilder() @@ -53,7 +53,7 @@ func TestDockerBuilder_Detect_Good(t *testing.T) { t.Run("does not match docker-compose.yml", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("version: '3'\n"), 0644) + err := ax.WriteFile(ax.Join(dir, "docker-compose.yml"), []byte("version: '3'\n"), 0644) require.NoError(t, err) builder := NewDockerBuilder() @@ -64,9 +64,9 @@ func TestDockerBuilder_Detect_Good(t *testing.T) { t.Run("does not match Dockerfile in subdirectory", func(t *testing.T) { dir := t.TempDir() - subDir := filepath.Join(dir, "subdir") - require.NoError(t, os.MkdirAll(subDir, 0755)) - err := os.WriteFile(filepath.Join(subDir, "Dockerfile"), []byte("FROM alpine\n"), 0644) + subDir := ax.Join(dir, "subdir") + require.NoError(t, ax.MkdirAll(subDir, 0755)) + err := ax.WriteFile(ax.Join(subDir, "Dockerfile"), []byte("FROM alpine\n"), 0644) require.NoError(t, err) builder := NewDockerBuilder() @@ -76,7 +76,7 @@ func TestDockerBuilder_Detect_Good(t *testing.T) { }) } -func TestDockerBuilder_Interface_Good(t *testing.T) { +func TestDocker_DockerBuilderInterface_Good(t *testing.T) { // Verify DockerBuilder implements Builder interface var _ build.Builder = (*DockerBuilder)(nil) var _ build.Builder = NewDockerBuilder() diff --git a/pkg/build/builders/go.go b/pkg/build/builders/go.go index 186c984..1a496da 100644 --- a/pkg/build/builders/go.go +++ b/pkg/build/builders/go.go @@ -3,32 +3,33 @@ package builders import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // GoBuilder implements the Builder interface for Go projects. +// Usage example: declare a value of type builders.GoBuilder in integrating code. type GoBuilder struct{} // NewGoBuilder creates a new GoBuilder instance. +// Usage example: call builders.NewGoBuilder(...) from integrating code. func NewGoBuilder() *GoBuilder { return &GoBuilder{} } // Name returns the builder's identifier. +// Usage example: call value.Name(...) from integrating code. func (b *GoBuilder) Name() string { return "go" } // Detect checks if this builder can handle the project in the given directory. // Uses IsGoProject from the build package which checks for go.mod or wails.json. +// Usage example: call value.Detect(...) from integrating code. func (b *GoBuilder) Detect(fs io.Medium, dir string) (bool, error) { return build.IsGoProject(fs, dir), nil } @@ -36,6 +37,7 @@ func (b *GoBuilder) Detect(fs io.Medium, dir string) (bool, error) { // Build compiles the Go project for the specified targets. // It sets GOOS, GOARCH, and CGO_ENABLED environment variables, // applies ldflags and trimpath, and runs go build. +// Usage example: call value.Build(...) from integrating code. func (b *GoBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { if cfg == nil { return nil, coreerr.E("GoBuilder.Build", "config is nil", nil) @@ -68,21 +70,21 @@ func (b *GoBuilder) buildTarget(ctx context.Context, cfg *build.Config, target b // Determine output binary name binaryName := cfg.Name if binaryName == "" { - binaryName = filepath.Base(cfg.ProjectDir) + binaryName = ax.Base(cfg.ProjectDir) } // Add .exe extension for Windows - if target.OS == "windows" && !strings.HasSuffix(binaryName, ".exe") { + if target.OS == "windows" && !core.HasSuffix(binaryName, ".exe") { binaryName += ".exe" } // Create platform-specific output path: output/os_arch/binary - platformDir := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch)) + platformDir := ax.Join(cfg.OutputDir, core.Sprintf("%s_%s", target.OS, target.Arch)) if err := cfg.FS.EnsureDir(platformDir); err != nil { return build.Artifact{}, coreerr.E("GoBuilder.buildTarget", "failed to create platform directory", err) } - outputPath := filepath.Join(platformDir, binaryName) + outputPath := ax.Join(platformDir, binaryName) // Build the go build arguments args := []string{"build"} @@ -92,7 +94,7 @@ func (b *GoBuilder) buildTarget(ctx context.Context, cfg *build.Config, target b // Add ldflags if specified if len(cfg.LDFlags) > 0 { - ldflags := strings.Join(cfg.LDFlags, " ") + ldflags := core.Join(" ", cfg.LDFlags...) args = append(args, "-ldflags", ldflags) } @@ -102,25 +104,21 @@ func (b *GoBuilder) buildTarget(ctx context.Context, cfg *build.Config, target b // Add the project directory as the build target (current directory) args = append(args, ".") - // Create the command - cmd := exec.CommandContext(ctx, "go", args...) - cmd.Dir = cfg.ProjectDir - // Set up environment - env := os.Environ() - env = append(env, fmt.Sprintf("GOOS=%s", target.OS)) - env = append(env, fmt.Sprintf("GOARCH=%s", target.Arch)) + env := []string{ + core.Sprintf("GOOS=%s", target.OS), + core.Sprintf("GOARCH=%s", target.Arch), + } if cfg.CGO { env = append(env, "CGO_ENABLED=1") } else { env = append(env, "CGO_ENABLED=0") } - cmd.Env = env // Capture output for error messages - output, err := cmd.CombinedOutput() + output, err := ax.CombinedOutput(ctx, cfg.ProjectDir, env, "go", args...) if err != nil { - return build.Artifact{}, coreerr.E("GoBuilder.buildTarget", "go build failed: "+string(output), err) + return build.Artifact{}, coreerr.E("GoBuilder.buildTarget", "go build failed: "+output, err) } return build.Artifact{ diff --git a/pkg/build/builders/go_test.go b/pkg/build/builders/go_test.go index 7eeb8cd..b0b3167 100644 --- a/pkg/build/builders/go_test.go +++ b/pkg/build/builders/go_test.go @@ -2,11 +2,11 @@ package builders import ( "context" - "os" - "path/filepath" "runtime" "testing" + "dappco.re/go/core/build/internal/ax" + "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" @@ -23,7 +23,7 @@ func setupGoTestProject(t *testing.T) string { go 1.21 ` - err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644) + err := ax.WriteFile(ax.Join(dir, "go.mod"), []byte(goMod), 0644) require.NoError(t, err) // Create a minimal main.go @@ -33,22 +33,22 @@ func main() { println("hello") } ` - err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0644) + err = ax.WriteFile(ax.Join(dir, "main.go"), []byte(mainGo), 0644) require.NoError(t, err) return dir } -func TestGoBuilder_Name_Good(t *testing.T) { +func TestGo_GoBuilderName_Good(t *testing.T) { builder := NewGoBuilder() assert.Equal(t, "go", builder.Name()) } -func TestGoBuilder_Detect_Good(t *testing.T) { +func TestGo_GoBuilderDetect_Good(t *testing.T) { fs := io.Local t.Run("detects Go project with go.mod", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + err := ax.WriteFile(ax.Join(dir, "go.mod"), []byte("module test"), 0644) require.NoError(t, err) builder := NewGoBuilder() @@ -59,7 +59,7 @@ func TestGoBuilder_Detect_Good(t *testing.T) { t.Run("detects Wails project", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte("{}"), 0644) + err := ax.WriteFile(ax.Join(dir, "wails.json"), []byte("{}"), 0644) require.NoError(t, err) builder := NewGoBuilder() @@ -71,7 +71,7 @@ func TestGoBuilder_Detect_Good(t *testing.T) { t.Run("returns false for non-Go project", func(t *testing.T) { dir := t.TempDir() // Create a Node.js project instead - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0644) + err := ax.WriteFile(ax.Join(dir, "package.json"), []byte("{}"), 0644) require.NoError(t, err) builder := NewGoBuilder() @@ -90,7 +90,7 @@ func TestGoBuilder_Detect_Good(t *testing.T) { }) } -func TestGoBuilder_Build_Good(t *testing.T) { +func TestGo_GoBuilderBuild_Good(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } @@ -178,7 +178,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { require.Len(t, artifacts, 1) // Verify .exe extension - assert.True(t, filepath.Ext(artifacts[0].Path) == ".exe") + assert.True(t, ax.Ext(artifacts[0].Path) == ".exe") assert.FileExists(t, artifacts[0].Path) }) @@ -202,7 +202,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { require.Len(t, artifacts, 1) // Binary should use the project directory base name - baseName := filepath.Base(projectDir) + baseName := ax.Base(projectDir) if runtime.GOOS == "windows" { baseName += ".exe" } @@ -233,7 +233,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { t.Run("creates output directory if missing", func(t *testing.T) { projectDir := setupGoTestProject(t) - outputDir := filepath.Join(t.TempDir(), "nested", "output") + outputDir := ax.Join(t.TempDir(), "nested", "output") builder := NewGoBuilder() cfg := &build.Config{ @@ -254,7 +254,7 @@ func TestGoBuilder_Build_Good(t *testing.T) { }) } -func TestGoBuilder_Build_Bad(t *testing.T) { +func TestGo_GoBuilderBuild_Bad(t *testing.T) { t.Run("returns error for nil config", func(t *testing.T) { builder := NewGoBuilder() @@ -310,11 +310,11 @@ func TestGoBuilder_Build_Bad(t *testing.T) { dir := t.TempDir() // Create go.mod - err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test\n\ngo 1.21"), 0644) + err := ax.WriteFile(ax.Join(dir, "go.mod"), []byte("module test\n\ngo 1.21"), 0644) require.NoError(t, err) // Create invalid Go code - err = os.WriteFile(filepath.Join(dir, "main.go"), []byte("this is not valid go code"), 0644) + err = ax.WriteFile(ax.Join(dir, "main.go"), []byte("this is not valid go code"), 0644) require.NoError(t, err) builder := NewGoBuilder() @@ -391,7 +391,7 @@ func TestGoBuilder_Build_Bad(t *testing.T) { }) } -func TestGoBuilder_Interface_Good(t *testing.T) { +func TestGo_GoBuilderInterface_Good(t *testing.T) { // Verify GoBuilder implements Builder interface var _ build.Builder = (*GoBuilder)(nil) var _ build.Builder = NewGoBuilder() diff --git a/pkg/build/builders/linuxkit.go b/pkg/build/builders/linuxkit.go index 3cb5907..812b0db 100644 --- a/pkg/build/builders/linuxkit.go +++ b/pkg/build/builders/linuxkit.go @@ -3,43 +3,44 @@ package builders import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // LinuxKitBuilder builds LinuxKit images. +// Usage example: declare a value of type builders.LinuxKitBuilder in integrating code. type LinuxKitBuilder struct{} // NewLinuxKitBuilder creates a new LinuxKit builder. +// Usage example: call builders.NewLinuxKitBuilder(...) from integrating code. func NewLinuxKitBuilder() *LinuxKitBuilder { return &LinuxKitBuilder{} } // Name returns the builder's identifier. +// Usage example: call value.Name(...) from integrating code. func (b *LinuxKitBuilder) Name() string { return "linuxkit" } // Detect checks if a linuxkit.yml or .yml config exists in the directory. +// Usage example: call value.Detect(...) from integrating code. func (b *LinuxKitBuilder) Detect(fs io.Medium, dir string) (bool, error) { // Check for linuxkit.yml - if fs.IsFile(filepath.Join(dir, "linuxkit.yml")) { + if fs.IsFile(ax.Join(dir, "linuxkit.yml")) { return true, nil } // Check for .core/linuxkit/ - lkDir := filepath.Join(dir, ".core", "linuxkit") + lkDir := ax.Join(dir, ".core", "linuxkit") if fs.IsDir(lkDir) { entries, err := fs.List(lkDir) if err == nil { for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".yml") { + if !entry.IsDir() && core.HasSuffix(entry.Name(), ".yml") { return true, nil } } @@ -49,6 +50,7 @@ func (b *LinuxKitBuilder) Detect(fs io.Medium, dir string) (bool, error) { } // Build builds LinuxKit images for the specified targets. +// Usage example: call value.Build(...) from integrating code. func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { // Validate linuxkit CLI is available if err := b.validateLinuxKitCli(); err != nil { @@ -59,17 +61,17 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets configPath := cfg.LinuxKitConfig if configPath == "" { // Auto-detect - if cfg.FS.IsFile(filepath.Join(cfg.ProjectDir, "linuxkit.yml")) { - configPath = filepath.Join(cfg.ProjectDir, "linuxkit.yml") + if cfg.FS.IsFile(ax.Join(cfg.ProjectDir, "linuxkit.yml")) { + configPath = ax.Join(cfg.ProjectDir, "linuxkit.yml") } else { // Look in .core/linuxkit/ - lkDir := filepath.Join(cfg.ProjectDir, ".core", "linuxkit") + lkDir := ax.Join(cfg.ProjectDir, ".core", "linuxkit") if cfg.FS.IsDir(lkDir) { entries, err := cfg.FS.List(lkDir) if err == nil { for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".yml") { - configPath = filepath.Join(lkDir, entry.Name()) + if !entry.IsDir() && core.HasSuffix(entry.Name(), ".yml") { + configPath = ax.Join(lkDir, entry.Name()) break } } @@ -96,7 +98,7 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets // Create output directory outputDir := cfg.OutputDir if outputDir == "" { - outputDir = filepath.Join(cfg.ProjectDir, "dist") + outputDir = ax.Join(cfg.ProjectDir, "dist") } if err := cfg.FS.EnsureDir(outputDir); err != nil { return nil, coreerr.E("LinuxKitBuilder.Build", "failed to create output directory", err) @@ -105,7 +107,7 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets // Determine base name from config file or project name baseName := cfg.Name if baseName == "" { - baseName = strings.TrimSuffix(filepath.Base(configPath), ".yml") + baseName = core.TrimSuffix(ax.Base(configPath), ".yml") } // If no targets, default to linux/amd64 @@ -119,23 +121,17 @@ func (b *LinuxKitBuilder) Build(ctx context.Context, cfg *build.Config, targets for _, target := range targets { // LinuxKit only supports Linux if target.OS != "linux" { - fmt.Printf("Skipping %s/%s (LinuxKit only supports Linux)\n", target.OS, target.Arch) + core.Print(nil, "Skipping %s/%s (LinuxKit only supports Linux)", target.OS, target.Arch) continue } for _, format := range formats { - outputName := fmt.Sprintf("%s-%s", baseName, target.Arch) + outputName := core.Sprintf("%s-%s", baseName, target.Arch) args := b.buildLinuxKitArgs(configPath, format, outputName, outputDir, target.Arch) - cmd := exec.CommandContext(ctx, "linuxkit", args...) - cmd.Dir = cfg.ProjectDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - fmt.Printf("Building LinuxKit image: %s (%s, %s)\n", outputName, format, target.Arch) - - if err := cmd.Run(); err != nil { + core.Print(nil, "Building LinuxKit image: %s (%s, %s)", outputName, format, target.Arch) + if err := ax.ExecDir(ctx, cfg.ProjectDir, "linuxkit", args...); err != nil { return nil, coreerr.E("LinuxKitBuilder.Build", "build failed for "+target.Arch+"/"+format, err) } @@ -189,7 +185,7 @@ func (b *LinuxKitBuilder) buildLinuxKitArgs(configPath, format, outputName, outp // getArtifactPath returns the expected path of the built artifact. func (b *LinuxKitBuilder) getArtifactPath(outputDir, outputName, format string) string { ext := b.getFormatExtension(format) - return filepath.Join(outputDir, outputName+ext) + return ax.Join(outputDir, outputName+ext) } // findArtifact searches for the built artifact with various naming conventions. @@ -202,7 +198,7 @@ func (b *LinuxKitBuilder) findArtifact(fs io.Medium, outputDir, outputName, form } for _, ext := range extensions { - path := filepath.Join(outputDir, outputName+ext) + path := ax.Join(outputDir, outputName+ext) if fs.Exists(path) { return path } @@ -212,10 +208,10 @@ func (b *LinuxKitBuilder) findArtifact(fs io.Medium, outputDir, outputName, form entries, err := fs.List(outputDir) if err == nil { for _, entry := range entries { - if strings.HasPrefix(entry.Name(), outputName) { - match := filepath.Join(outputDir, entry.Name()) + if core.HasPrefix(entry.Name(), outputName) { + match := ax.Join(outputDir, entry.Name()) // Return first match that looks like an image - ext := filepath.Ext(match) + ext := ax.Ext(match) if ext == ".iso" || ext == ".qcow2" || ext == ".raw" || ext == ".vmdk" || ext == ".vhd" { return match } @@ -244,14 +240,14 @@ func (b *LinuxKitBuilder) getFormatExtension(format string) string { case "aws": return ".raw" default: - return "." + strings.TrimSuffix(format, "-bios") + return "." + core.TrimSuffix(format, "-bios") } } // validateLinuxKitCli checks if the linuxkit CLI is available. func (b *LinuxKitBuilder) validateLinuxKitCli() error { // Check PATH first - if _, err := exec.LookPath("linuxkit"); err == nil { + if _, err := ax.LookPath("linuxkit"); err == nil { return nil } diff --git a/pkg/build/builders/linuxkit_test.go b/pkg/build/builders/linuxkit_test.go index 1315d91..0d63b99 100644 --- a/pkg/build/builders/linuxkit_test.go +++ b/pkg/build/builders/linuxkit_test.go @@ -1,27 +1,27 @@ package builders import ( - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestLinuxKitBuilder_Name_Good(t *testing.T) { +func TestLinuxKit_LinuxKitBuilderName_Good(t *testing.T) { builder := NewLinuxKitBuilder() assert.Equal(t, "linuxkit", builder.Name()) } -func TestLinuxKitBuilder_Detect_Good(t *testing.T) { +func TestLinuxKit_LinuxKitBuilderDetect_Good(t *testing.T) { fs := io.Local t.Run("detects linuxkit.yml in root", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "linuxkit.yml"), []byte("kernel:\n image: test\n"), 0644) + err := ax.WriteFile(ax.Join(dir, "linuxkit.yml"), []byte("kernel:\n image: test\n"), 0644) require.NoError(t, err) builder := NewLinuxKitBuilder() @@ -32,9 +32,9 @@ func TestLinuxKitBuilder_Detect_Good(t *testing.T) { t.Run("detects .core/linuxkit/*.yml", func(t *testing.T) { dir := t.TempDir() - lkDir := filepath.Join(dir, ".core", "linuxkit") - require.NoError(t, os.MkdirAll(lkDir, 0755)) - err := os.WriteFile(filepath.Join(lkDir, "server.yml"), []byte("kernel:\n image: test\n"), 0644) + lkDir := ax.Join(dir, ".core", "linuxkit") + require.NoError(t, ax.MkdirAll(lkDir, 0755)) + err := ax.WriteFile(ax.Join(lkDir, "server.yml"), []byte("kernel:\n image: test\n"), 0644) require.NoError(t, err) builder := NewLinuxKitBuilder() @@ -45,11 +45,11 @@ func TestLinuxKitBuilder_Detect_Good(t *testing.T) { t.Run("detects .core/linuxkit with multiple yml files", func(t *testing.T) { dir := t.TempDir() - lkDir := filepath.Join(dir, ".core", "linuxkit") - require.NoError(t, os.MkdirAll(lkDir, 0755)) - err := os.WriteFile(filepath.Join(lkDir, "server.yml"), []byte("kernel:\n"), 0644) + lkDir := ax.Join(dir, ".core", "linuxkit") + require.NoError(t, ax.MkdirAll(lkDir, 0755)) + err := ax.WriteFile(ax.Join(lkDir, "server.yml"), []byte("kernel:\n"), 0644) require.NoError(t, err) - err = os.WriteFile(filepath.Join(lkDir, "desktop.yml"), []byte("kernel:\n"), 0644) + err = ax.WriteFile(ax.Join(lkDir, "desktop.yml"), []byte("kernel:\n"), 0644) require.NoError(t, err) builder := NewLinuxKitBuilder() @@ -69,7 +69,7 @@ func TestLinuxKitBuilder_Detect_Good(t *testing.T) { t.Run("returns false for non-LinuxKit project", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + err := ax.WriteFile(ax.Join(dir, "go.mod"), []byte("module test"), 0644) require.NoError(t, err) builder := NewLinuxKitBuilder() @@ -80,8 +80,8 @@ func TestLinuxKitBuilder_Detect_Good(t *testing.T) { t.Run("returns false for empty .core/linuxkit directory", func(t *testing.T) { dir := t.TempDir() - lkDir := filepath.Join(dir, ".core", "linuxkit") - require.NoError(t, os.MkdirAll(lkDir, 0755)) + lkDir := ax.Join(dir, ".core", "linuxkit") + require.NoError(t, ax.MkdirAll(lkDir, 0755)) builder := NewLinuxKitBuilder() detected, err := builder.Detect(fs, dir) @@ -91,9 +91,9 @@ func TestLinuxKitBuilder_Detect_Good(t *testing.T) { t.Run("returns false when .core/linuxkit has only non-yml files", func(t *testing.T) { dir := t.TempDir() - lkDir := filepath.Join(dir, ".core", "linuxkit") - require.NoError(t, os.MkdirAll(lkDir, 0755)) - err := os.WriteFile(filepath.Join(lkDir, "README.md"), []byte("# LinuxKit\n"), 0644) + lkDir := ax.Join(dir, ".core", "linuxkit") + require.NoError(t, ax.MkdirAll(lkDir, 0755)) + err := ax.WriteFile(ax.Join(lkDir, "README.md"), []byte("# LinuxKit\n"), 0644) require.NoError(t, err) builder := NewLinuxKitBuilder() @@ -104,11 +104,11 @@ func TestLinuxKitBuilder_Detect_Good(t *testing.T) { t.Run("ignores subdirectories in .core/linuxkit", func(t *testing.T) { dir := t.TempDir() - lkDir := filepath.Join(dir, ".core", "linuxkit") - subDir := filepath.Join(lkDir, "subdir") - require.NoError(t, os.MkdirAll(subDir, 0755)) + lkDir := ax.Join(dir, ".core", "linuxkit") + subDir := ax.Join(lkDir, "subdir") + require.NoError(t, ax.MkdirAll(subDir, 0755)) // Put yml in subdir only, not in lkDir itself - err := os.WriteFile(filepath.Join(subDir, "server.yml"), []byte("kernel:\n"), 0644) + err := ax.WriteFile(ax.Join(subDir, "server.yml"), []byte("kernel:\n"), 0644) require.NoError(t, err) builder := NewLinuxKitBuilder() @@ -118,7 +118,7 @@ func TestLinuxKitBuilder_Detect_Good(t *testing.T) { }) } -func TestLinuxKitBuilder_GetFormatExtension_Good(t *testing.T) { +func TestLinuxKit_LinuxKitBuilderGetFormatExtension_Good(t *testing.T) { builder := NewLinuxKitBuilder() tests := []struct { @@ -149,7 +149,7 @@ func TestLinuxKitBuilder_GetFormatExtension_Good(t *testing.T) { } } -func TestLinuxKitBuilder_GetArtifactPath_Good(t *testing.T) { +func TestLinuxKit_LinuxKitBuilderGetArtifactPath_Good(t *testing.T) { builder := NewLinuxKitBuilder() t.Run("constructs correct path", func(t *testing.T) { @@ -163,7 +163,7 @@ func TestLinuxKitBuilder_GetArtifactPath_Good(t *testing.T) { }) } -func TestLinuxKitBuilder_BuildLinuxKitArgs_Good(t *testing.T) { +func TestLinuxKit_LinuxKitBuilderBuildLinuxKitArgs_Good(t *testing.T) { builder := NewLinuxKitBuilder() t.Run("builds args for amd64 without --arch", func(t *testing.T) { @@ -186,14 +186,14 @@ func TestLinuxKitBuilder_BuildLinuxKitArgs_Good(t *testing.T) { }) } -func TestLinuxKitBuilder_FindArtifact_Good(t *testing.T) { +func TestLinuxKit_LinuxKitBuilderFindArtifact_Good(t *testing.T) { fs := io.Local builder := NewLinuxKitBuilder() t.Run("finds artifact with exact extension", func(t *testing.T) { dir := t.TempDir() - artifactPath := filepath.Join(dir, "server-amd64.iso") - require.NoError(t, os.WriteFile(artifactPath, []byte("fake iso"), 0644)) + artifactPath := ax.Join(dir, "server-amd64.iso") + require.NoError(t, ax.WriteFile(artifactPath, []byte("fake iso"), 0644)) found := builder.findArtifact(fs, dir, "server-amd64", "iso") assert.Equal(t, artifactPath, found) @@ -209,15 +209,15 @@ func TestLinuxKitBuilder_FindArtifact_Good(t *testing.T) { t.Run("finds artifact with alternate naming", func(t *testing.T) { dir := t.TempDir() // Create file matching the name prefix + known image extension - artifactPath := filepath.Join(dir, "server-amd64.qcow2") - require.NoError(t, os.WriteFile(artifactPath, []byte("fake qcow2"), 0644)) + artifactPath := ax.Join(dir, "server-amd64.qcow2") + require.NoError(t, ax.WriteFile(artifactPath, []byte("fake qcow2"), 0644)) found := builder.findArtifact(fs, dir, "server-amd64", "qcow2") assert.Equal(t, artifactPath, found) }) } -func TestLinuxKitBuilder_Interface_Good(t *testing.T) { +func TestLinuxKit_LinuxKitBuilderInterface_Good(t *testing.T) { // Verify LinuxKitBuilder implements Builder interface var _ build.Builder = (*LinuxKitBuilder)(nil) var _ build.Builder = NewLinuxKitBuilder() diff --git a/pkg/build/builders/taskfile.go b/pkg/build/builders/taskfile.go index 0a36906..cf8deda 100644 --- a/pkg/build/builders/taskfile.go +++ b/pkg/build/builders/taskfile.go @@ -3,12 +3,10 @@ package builders import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "path" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" @@ -16,19 +14,23 @@ import ( // TaskfileBuilder builds projects using Taskfile (https://taskfile.dev/). // This is a generic builder that can handle any project type that has a Taskfile. +// Usage example: declare a value of type builders.TaskfileBuilder in integrating code. type TaskfileBuilder struct{} // NewTaskfileBuilder creates a new Taskfile builder. +// Usage example: call builders.NewTaskfileBuilder(...) from integrating code. func NewTaskfileBuilder() *TaskfileBuilder { return &TaskfileBuilder{} } // Name returns the builder's identifier. +// Usage example: call value.Name(...) from integrating code. func (b *TaskfileBuilder) Name() string { return "taskfile" } // Detect checks if a Taskfile exists in the directory. +// Usage example: call value.Detect(...) from integrating code. func (b *TaskfileBuilder) Detect(fs io.Medium, dir string) (bool, error) { // Check for Taskfile.yml, Taskfile.yaml, or Taskfile taskfiles := []string{ @@ -40,7 +42,7 @@ func (b *TaskfileBuilder) Detect(fs io.Medium, dir string) (bool, error) { } for _, tf := range taskfiles { - if fs.IsFile(filepath.Join(dir, tf)) { + if fs.IsFile(ax.Join(dir, tf)) { return true, nil } } @@ -48,6 +50,7 @@ func (b *TaskfileBuilder) Detect(fs io.Medium, dir string) (bool, error) { } // Build runs the Taskfile build task for each target platform. +// Usage example: call value.Build(...) from integrating code. func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { // Validate task CLI is available if err := b.validateTaskCli(); err != nil { @@ -57,7 +60,7 @@ func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets // Create output directory outputDir := cfg.OutputDir if outputDir == "" { - outputDir = filepath.Join(cfg.ProjectDir, "dist") + outputDir = ax.Join(cfg.ProjectDir, "dist") } if err := cfg.FS.EnsureDir(outputDir); err != nil { return nil, coreerr.E("TaskfileBuilder.Build", "failed to create output directory", err) @@ -94,54 +97,42 @@ func (b *TaskfileBuilder) Build(ctx context.Context, cfg *build.Config, targets func (b *TaskfileBuilder) runTask(ctx context.Context, cfg *build.Config, goos, goarch string) error { // Build task command args := []string{"build"} + env := []string{} // Pass variables if targets are specified if goos != "" { - args = append(args, fmt.Sprintf("GOOS=%s", goos)) + value := core.Sprintf("GOOS=%s", goos) + args = append(args, value) + env = append(env, value) } if goarch != "" { - args = append(args, fmt.Sprintf("GOARCH=%s", goarch)) + value := core.Sprintf("GOARCH=%s", goarch) + args = append(args, value) + env = append(env, value) } if cfg.OutputDir != "" { - args = append(args, fmt.Sprintf("OUTPUT_DIR=%s", cfg.OutputDir)) + value := core.Sprintf("OUTPUT_DIR=%s", cfg.OutputDir) + args = append(args, value) + env = append(env, value) } if cfg.Name != "" { - args = append(args, fmt.Sprintf("NAME=%s", cfg.Name)) + value := core.Sprintf("NAME=%s", cfg.Name) + args = append(args, value) + env = append(env, value) } if cfg.Version != "" { - args = append(args, fmt.Sprintf("VERSION=%s", cfg.Version)) - } - - cmd := exec.CommandContext(ctx, "task", args...) - cmd.Dir = cfg.ProjectDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Set environment variables - cmd.Env = os.Environ() - if goos != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("GOOS=%s", goos)) - } - if goarch != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("GOARCH=%s", goarch)) - } - if cfg.OutputDir != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("OUTPUT_DIR=%s", cfg.OutputDir)) - } - if cfg.Name != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("NAME=%s", cfg.Name)) - } - if cfg.Version != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("VERSION=%s", cfg.Version)) + value := core.Sprintf("VERSION=%s", cfg.Version) + args = append(args, value) + env = append(env, value) } if goos != "" && goarch != "" { - fmt.Printf("Running task build for %s/%s\n", goos, goarch) + core.Print(nil, "Running task build for %s/%s", goos, goarch) } else { - fmt.Println("Running task build") + core.Print(nil, "Running task build") } - if err := cmd.Run(); err != nil { + if err := ax.ExecWithEnv(ctx, cfg.ProjectDir, env, "task", args...); err != nil { return coreerr.E("TaskfileBuilder.runTask", "task build failed", err) } @@ -164,12 +155,12 @@ func (b *TaskfileBuilder) findArtifacts(fs io.Medium, outputDir string) []build. // Skip common non-artifact files name := entry.Name() - if strings.HasPrefix(name, ".") || name == "CHECKSUMS.txt" { + if core.HasPrefix(name, ".") || name == "CHECKSUMS.txt" { continue } artifacts = append(artifacts, build.Artifact{ - Path: filepath.Join(outputDir, name), + Path: ax.Join(outputDir, name), OS: "", Arch: "", }) @@ -183,15 +174,15 @@ func (b *TaskfileBuilder) findArtifactsForTarget(fs io.Medium, outputDir string, var artifacts []build.Artifact // 1. Look for platform-specific subdirectory: output/os_arch/ - platformSubdir := filepath.Join(outputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch)) + platformSubdir := ax.Join(outputDir, core.Sprintf("%s_%s", target.OS, target.Arch)) if fs.IsDir(platformSubdir) { entries, _ := fs.List(platformSubdir) for _, entry := range entries { if entry.IsDir() { // Handle .app bundles on macOS - if target.OS == "darwin" && strings.HasSuffix(entry.Name(), ".app") { + if target.OS == "darwin" && core.HasSuffix(entry.Name(), ".app") { artifacts = append(artifacts, build.Artifact{ - Path: filepath.Join(platformSubdir, entry.Name()), + Path: ax.Join(platformSubdir, entry.Name()), OS: target.OS, Arch: target.Arch, }) @@ -199,11 +190,11 @@ func (b *TaskfileBuilder) findArtifactsForTarget(fs io.Medium, outputDir string, continue } // Skip hidden files - if strings.HasPrefix(entry.Name(), ".") { + if core.HasPrefix(entry.Name(), ".") { continue } artifacts = append(artifacts, build.Artifact{ - Path: filepath.Join(platformSubdir, entry.Name()), + Path: ax.Join(platformSubdir, entry.Name()), OS: target.OS, Arch: target.Arch, }) @@ -215,9 +206,9 @@ func (b *TaskfileBuilder) findArtifactsForTarget(fs io.Medium, outputDir string, // 2. Look for files matching the target pattern in the root output dir patterns := []string{ - fmt.Sprintf("*-%s-%s*", target.OS, target.Arch), - fmt.Sprintf("*_%s_%s*", target.OS, target.Arch), - fmt.Sprintf("*-%s*", target.Arch), + core.Sprintf("*-%s-%s*", target.OS, target.Arch), + core.Sprintf("*_%s_%s*", target.OS, target.Arch), + core.Sprintf("*-%s*", target.Arch), } for _, pattern := range patterns { @@ -226,7 +217,7 @@ func (b *TaskfileBuilder) findArtifactsForTarget(fs io.Medium, outputDir string, match := entry.Name() // Simple glob matching if b.matchPattern(match, pattern) { - fullPath := filepath.Join(outputDir, match) + fullPath := ax.Join(outputDir, match) if fs.IsDir(fullPath) { continue } @@ -249,14 +240,14 @@ func (b *TaskfileBuilder) findArtifactsForTarget(fs io.Medium, outputDir string, // matchPattern implements glob matching for Taskfile artifacts. func (b *TaskfileBuilder) matchPattern(name, pattern string) bool { - matched, _ := filepath.Match(pattern, name) + matched, _ := path.Match(pattern, name) return matched } // validateTaskCli checks if the task CLI is available. func (b *TaskfileBuilder) validateTaskCli() error { // Check PATH first - if _, err := exec.LookPath("task"); err == nil { + if _, err := ax.LookPath("task"); err == nil { return nil } diff --git a/pkg/build/builders/taskfile_test.go b/pkg/build/builders/taskfile_test.go index 96810f1..02edaaa 100644 --- a/pkg/build/builders/taskfile_test.go +++ b/pkg/build/builders/taskfile_test.go @@ -1,27 +1,27 @@ package builders import ( - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestTaskfileBuilder_Name_Good(t *testing.T) { +func TestTaskfile_TaskfileBuilderName_Good(t *testing.T) { builder := NewTaskfileBuilder() assert.Equal(t, "taskfile", builder.Name()) } -func TestTaskfileBuilder_Detect_Good(t *testing.T) { +func TestTaskfile_TaskfileBuilderDetect_Good(t *testing.T) { fs := io.Local t.Run("detects Taskfile.yml", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte("version: '3'\n"), 0644) + err := ax.WriteFile(ax.Join(dir, "Taskfile.yml"), []byte("version: '3'\n"), 0644) require.NoError(t, err) builder := NewTaskfileBuilder() @@ -32,7 +32,7 @@ func TestTaskfileBuilder_Detect_Good(t *testing.T) { t.Run("detects Taskfile.yaml", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "Taskfile.yaml"), []byte("version: '3'\n"), 0644) + err := ax.WriteFile(ax.Join(dir, "Taskfile.yaml"), []byte("version: '3'\n"), 0644) require.NoError(t, err) builder := NewTaskfileBuilder() @@ -43,7 +43,7 @@ func TestTaskfileBuilder_Detect_Good(t *testing.T) { t.Run("detects Taskfile (no extension)", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "Taskfile"), []byte("version: '3'\n"), 0644) + err := ax.WriteFile(ax.Join(dir, "Taskfile"), []byte("version: '3'\n"), 0644) require.NoError(t, err) builder := NewTaskfileBuilder() @@ -54,7 +54,7 @@ func TestTaskfileBuilder_Detect_Good(t *testing.T) { t.Run("detects lowercase taskfile.yml", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "taskfile.yml"), []byte("version: '3'\n"), 0644) + err := ax.WriteFile(ax.Join(dir, "taskfile.yml"), []byte("version: '3'\n"), 0644) require.NoError(t, err) builder := NewTaskfileBuilder() @@ -65,7 +65,7 @@ func TestTaskfileBuilder_Detect_Good(t *testing.T) { t.Run("detects lowercase taskfile.yaml", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "taskfile.yaml"), []byte("version: '3'\n"), 0644) + err := ax.WriteFile(ax.Join(dir, "taskfile.yaml"), []byte("version: '3'\n"), 0644) require.NoError(t, err) builder := NewTaskfileBuilder() @@ -85,7 +85,7 @@ func TestTaskfileBuilder_Detect_Good(t *testing.T) { t.Run("returns false for non-Taskfile project", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "Makefile"), []byte("all:\n\techo hello\n"), 0644) + err := ax.WriteFile(ax.Join(dir, "Makefile"), []byte("all:\n\techo hello\n"), 0644) require.NoError(t, err) builder := NewTaskfileBuilder() @@ -96,9 +96,9 @@ func TestTaskfileBuilder_Detect_Good(t *testing.T) { t.Run("does not match Taskfile in subdirectory", func(t *testing.T) { dir := t.TempDir() - subDir := filepath.Join(dir, "subdir") - require.NoError(t, os.MkdirAll(subDir, 0755)) - err := os.WriteFile(filepath.Join(subDir, "Taskfile.yml"), []byte("version: '3'\n"), 0644) + subDir := ax.Join(dir, "subdir") + require.NoError(t, ax.MkdirAll(subDir, 0755)) + err := ax.WriteFile(ax.Join(subDir, "Taskfile.yml"), []byte("version: '3'\n"), 0644) require.NoError(t, err) builder := NewTaskfileBuilder() @@ -108,14 +108,14 @@ func TestTaskfileBuilder_Detect_Good(t *testing.T) { }) } -func TestTaskfileBuilder_FindArtifacts_Good(t *testing.T) { +func TestTaskfile_TaskfileBuilderFindArtifacts_Good(t *testing.T) { fs := io.Local builder := NewTaskfileBuilder() t.Run("finds files in output directory", func(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "myapp"), []byte("binary"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "myapp.tar.gz"), []byte("archive"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "myapp"), []byte("binary"), 0755)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "myapp.tar.gz"), []byte("archive"), 0644)) artifacts := builder.findArtifacts(fs, dir) assert.Len(t, artifacts, 2) @@ -123,8 +123,8 @@ func TestTaskfileBuilder_FindArtifacts_Good(t *testing.T) { t.Run("skips hidden files", func(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "myapp"), []byte("binary"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, ".hidden"), []byte("hidden"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "myapp"), []byte("binary"), 0755)) + require.NoError(t, ax.WriteFile(ax.Join(dir, ".hidden"), []byte("hidden"), 0644)) artifacts := builder.findArtifacts(fs, dir) assert.Len(t, artifacts, 1) @@ -133,8 +133,8 @@ func TestTaskfileBuilder_FindArtifacts_Good(t *testing.T) { t.Run("skips CHECKSUMS.txt", func(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "myapp"), []byte("binary"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "CHECKSUMS.txt"), []byte("sha256"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "myapp"), []byte("binary"), 0755)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "CHECKSUMS.txt"), []byte("sha256"), 0644)) artifacts := builder.findArtifacts(fs, dir) assert.Len(t, artifacts, 1) @@ -143,8 +143,8 @@ func TestTaskfileBuilder_FindArtifacts_Good(t *testing.T) { t.Run("skips directories", func(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "myapp"), []byte("binary"), 0755)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, "subdir"), 0755)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "myapp"), []byte("binary"), 0755)) + require.NoError(t, ax.MkdirAll(ax.Join(dir, "subdir"), 0755)) artifacts := builder.findArtifacts(fs, dir) assert.Len(t, artifacts, 1) @@ -163,15 +163,15 @@ func TestTaskfileBuilder_FindArtifacts_Good(t *testing.T) { }) } -func TestTaskfileBuilder_FindArtifactsForTarget_Good(t *testing.T) { +func TestTaskfile_TaskfileBuilderFindArtifactsForTarget_Good(t *testing.T) { fs := io.Local builder := NewTaskfileBuilder() t.Run("finds artifacts in platform subdirectory", func(t *testing.T) { dir := t.TempDir() - platformDir := filepath.Join(dir, "linux_amd64") - require.NoError(t, os.MkdirAll(platformDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(platformDir, "myapp"), []byte("binary"), 0755)) + platformDir := ax.Join(dir, "linux_amd64") + require.NoError(t, ax.MkdirAll(platformDir, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(platformDir, "myapp"), []byte("binary"), 0755)) target := build.Target{OS: "linux", Arch: "amd64"} artifacts := builder.findArtifactsForTarget(fs, dir, target) @@ -182,7 +182,7 @@ func TestTaskfileBuilder_FindArtifactsForTarget_Good(t *testing.T) { t.Run("finds artifacts by name pattern in root", func(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "myapp-linux-amd64"), []byte("binary"), 0755)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "myapp-linux-amd64"), []byte("binary"), 0755)) target := build.Target{OS: "linux", Arch: "amd64"} artifacts := builder.findArtifactsForTarget(fs, dir, target) @@ -191,7 +191,7 @@ func TestTaskfileBuilder_FindArtifactsForTarget_Good(t *testing.T) { t.Run("returns empty when no matching artifacts", func(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "myapp"), []byte("binary"), 0755)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "myapp"), []byte("binary"), 0755)) target := build.Target{OS: "linux", Arch: "arm64"} artifacts := builder.findArtifactsForTarget(fs, dir, target) @@ -200,9 +200,9 @@ func TestTaskfileBuilder_FindArtifactsForTarget_Good(t *testing.T) { t.Run("handles .app bundles on darwin", func(t *testing.T) { dir := t.TempDir() - platformDir := filepath.Join(dir, "darwin_arm64") - appDir := filepath.Join(platformDir, "MyApp.app") - require.NoError(t, os.MkdirAll(appDir, 0755)) + platformDir := ax.Join(dir, "darwin_arm64") + appDir := ax.Join(platformDir, "MyApp.app") + require.NoError(t, ax.MkdirAll(appDir, 0755)) target := build.Target{OS: "darwin", Arch: "arm64"} artifacts := builder.findArtifactsForTarget(fs, dir, target) @@ -211,7 +211,7 @@ func TestTaskfileBuilder_FindArtifactsForTarget_Good(t *testing.T) { }) } -func TestTaskfileBuilder_MatchPattern_Good(t *testing.T) { +func TestTaskfile_TaskfileBuilderMatchPattern_Good(t *testing.T) { builder := NewTaskfileBuilder() t.Run("matches simple glob", func(t *testing.T) { @@ -227,7 +227,7 @@ func TestTaskfileBuilder_MatchPattern_Good(t *testing.T) { }) } -func TestTaskfileBuilder_Interface_Good(t *testing.T) { +func TestTaskfile_TaskfileBuilderInterface_Good(t *testing.T) { // Verify TaskfileBuilder implements Builder interface var _ build.Builder = (*TaskfileBuilder)(nil) var _ build.Builder = NewTaskfileBuilder() diff --git a/pkg/build/builders/wails.go b/pkg/build/builders/wails.go index 7d135dc..18b376a 100644 --- a/pkg/build/builders/wails.go +++ b/pkg/build/builders/wails.go @@ -3,31 +3,33 @@ package builders import ( "context" - "fmt" - "os/exec" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // WailsBuilder implements the Builder interface for Wails v3 projects. +// Usage example: declare a value of type builders.WailsBuilder in integrating code. type WailsBuilder struct{} // NewWailsBuilder creates a new WailsBuilder instance. +// Usage example: call builders.NewWailsBuilder(...) from integrating code. func NewWailsBuilder() *WailsBuilder { return &WailsBuilder{} } // Name returns the builder's identifier. +// Usage example: call value.Name(...) from integrating code. func (b *WailsBuilder) Name() string { return "wails" } // Detect checks if this builder can handle the project in the given directory. // Uses IsWailsProject from the build package which checks for wails.json. +// Usage example: call value.Detect(...) from integrating code. func (b *WailsBuilder) Detect(fs io.Medium, dir string) (bool, error) { return build.IsWailsProject(fs, dir), nil } @@ -36,6 +38,7 @@ func (b *WailsBuilder) Detect(fs io.Medium, dir string) (bool, error) { // It detects the Wails version and chooses the appropriate build strategy: // - Wails v3: Delegates to Taskfile (error if missing) // - Wails v2: Uses 'wails build' command +// Usage example: call value.Build(...) from integrating code. func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) { if cfg == nil { return nil, coreerr.E("WailsBuilder.Build", "config is nil", nil) @@ -83,12 +86,12 @@ func (b *WailsBuilder) Build(ctx context.Context, cfg *build.Config, targets []b // isWailsV3 checks if the project uses Wails v3 by inspecting go.mod. func (b *WailsBuilder) isWailsV3(fs io.Medium, dir string) bool { - goModPath := filepath.Join(dir, "go.mod") + goModPath := ax.Join(dir, "go.mod") content, err := fs.Read(goModPath) if err != nil { return false } - return strings.Contains(content, "github.com/wailsapp/wails/v3") + return core.Contains(content, "github.com/wailsapp/wails/v3") } // buildV2Target compiles for a single target platform using wails (v2). @@ -96,35 +99,31 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar // Determine output binary name binaryName := cfg.Name if binaryName == "" { - binaryName = filepath.Base(cfg.ProjectDir) + binaryName = ax.Base(cfg.ProjectDir) } // Build the wails build arguments args := []string{"build"} // Platform - args = append(args, "-platform", fmt.Sprintf("%s/%s", target.OS, target.Arch)) + args = append(args, "-platform", core.Sprintf("%s/%s", target.OS, target.Arch)) // Output (Wails v2 uses -o for the binary name, relative to build/bin usually, but we want to control it) // Actually, Wails v2 is opinionated about output dir (build/bin). // We might need to copy artifacts after build if we want them in cfg.OutputDir. // For now, let's try to let Wails do its thing and find the artifact. - // Create the command - cmd := exec.CommandContext(ctx, "wails", args...) - cmd.Dir = cfg.ProjectDir - // Capture output for error messages - output, err := cmd.CombinedOutput() + output, err := ax.CombinedOutput(ctx, cfg.ProjectDir, nil, "wails", args...) if err != nil { - return build.Artifact{}, coreerr.E("WailsBuilder.buildV2Target", "wails build failed: "+string(output), err) + return build.Artifact{}, coreerr.E("WailsBuilder.buildV2Target", "wails build failed: "+output, err) } // Wails v2 typically outputs to build/bin // We need to move/copy it to our desired output dir // Construct the source path where Wails v2 puts the binary - wailsOutputDir := filepath.Join(cfg.ProjectDir, "build", "bin") + wailsOutputDir := ax.Join(cfg.ProjectDir, "build", "bin") // Find the artifact in Wails output dir sourcePath, err := b.findArtifact(cfg.FS, wailsOutputDir, binaryName, target) @@ -134,12 +133,12 @@ func (b *WailsBuilder) buildV2Target(ctx context.Context, cfg *build.Config, tar // Move/Copy to our output dir // Create platform specific dir in our output - platformDir := filepath.Join(cfg.OutputDir, fmt.Sprintf("%s_%s", target.OS, target.Arch)) + platformDir := ax.Join(cfg.OutputDir, core.Sprintf("%s_%s", target.OS, target.Arch)) if err := cfg.FS.EnsureDir(platformDir); err != nil { return build.Artifact{}, coreerr.E("WailsBuilder.buildV2Target", "failed to create output dir", err) } - destPath := filepath.Join(platformDir, filepath.Base(sourcePath)) + destPath := ax.Join(platformDir, ax.Base(sourcePath)) // Simple copy using the medium content, err := cfg.FS.Read(sourcePath) @@ -165,21 +164,21 @@ func (b *WailsBuilder) findArtifact(fs io.Medium, platformDir, binaryName string case "windows": // Look for NSIS installer first, then plain exe candidates = []string{ - filepath.Join(platformDir, binaryName+"-installer.exe"), - filepath.Join(platformDir, binaryName+".exe"), - filepath.Join(platformDir, binaryName+"-amd64-installer.exe"), + ax.Join(platformDir, binaryName+"-installer.exe"), + ax.Join(platformDir, binaryName+".exe"), + ax.Join(platformDir, binaryName+"-amd64-installer.exe"), } case "darwin": // Look for .dmg, then .app bundle, then plain binary candidates = []string{ - filepath.Join(platformDir, binaryName+".dmg"), - filepath.Join(platformDir, binaryName+".app"), - filepath.Join(platformDir, binaryName), + ax.Join(platformDir, binaryName+".dmg"), + ax.Join(platformDir, binaryName+".app"), + ax.Join(platformDir, binaryName), } default: // Linux and others: look for plain binary candidates = []string{ - filepath.Join(platformDir, binaryName), + ax.Join(platformDir, binaryName), } } @@ -199,11 +198,11 @@ func (b *WailsBuilder) findArtifact(fs io.Medium, platformDir, binaryName string for _, entry := range entries { name := entry.Name() // Skip common non-artifact files - if strings.HasSuffix(name, ".go") || strings.HasSuffix(name, ".json") { + if core.HasSuffix(name, ".go") || core.HasSuffix(name, ".json") { continue } - path := filepath.Join(platformDir, name) + path := ax.Join(platformDir, name) info, err := entry.Info() if err != nil { continue @@ -211,7 +210,7 @@ func (b *WailsBuilder) findArtifact(fs io.Medium, platformDir, binaryName string // On Unix, check if it's executable; on Windows, check for .exe if target.OS == "windows" { - if strings.HasSuffix(name, ".exe") { + if core.HasSuffix(name, ".exe") { return path, nil } } else if info.Mode()&0111 != 0 || entry.IsDir() { @@ -238,7 +237,7 @@ func detectPackageManager(fs io.Medium, dir string) string { } for _, lf := range lockFiles { - if fs.IsFile(filepath.Join(dir, lf.file)) { + if fs.IsFile(ax.Join(dir, lf.file)) { return lf.manager } } diff --git a/pkg/build/builders/wails_test.go b/pkg/build/builders/wails_test.go index 09243bd..b1b87e5 100644 --- a/pkg/build/builders/wails_test.go +++ b/pkg/build/builders/wails_test.go @@ -2,12 +2,10 @@ package builders import ( "context" - "os" - "os/exec" - "path/filepath" "runtime" "testing" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" @@ -24,7 +22,7 @@ func setupWailsTestProject(t *testing.T) string { "name": "testapp", "outputfilename": "testapp" }` - err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte(wailsJSON), 0644) + err := ax.WriteFile(ax.Join(dir, "wails.json"), []byte(wailsJSON), 0o644) require.NoError(t, err) // Create a minimal go.mod @@ -34,7 +32,7 @@ go 1.21 require github.com/wailsapp/wails/v3 v3.0.0 ` - err = os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644) + err = ax.WriteFile(ax.Join(dir, "go.mod"), []byte(goMod), 0o644) require.NoError(t, err) // Create a minimal main.go @@ -44,7 +42,7 @@ func main() { println("hello wails") } ` - err = os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0644) + err = ax.WriteFile(ax.Join(dir, "main.go"), []byte(mainGo), 0o644) require.NoError(t, err) // Create a minimal Taskfile.yml @@ -55,7 +53,7 @@ tasks: - mkdir -p {{.OUTPUT_DIR}}/{{.GOOS}}_{{.GOARCH}} - touch {{.OUTPUT_DIR}}/{{.GOOS}}_{{.GOARCH}}/testapp ` - err = os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0644) + err = ax.WriteFile(ax.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644) require.NoError(t, err) return dir @@ -67,7 +65,7 @@ func setupWailsV2TestProject(t *testing.T) string { dir := t.TempDir() // wails.json - err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte("{}"), 0644) + err := ax.WriteFile(ax.Join(dir, "wails.json"), []byte("{}"), 0o644) require.NoError(t, err) // go.mod with v2 @@ -75,19 +73,19 @@ func setupWailsV2TestProject(t *testing.T) string { go 1.21 require github.com/wailsapp/wails/v2 v2.8.0 ` - err = os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644) + err = ax.WriteFile(ax.Join(dir, "go.mod"), []byte(goMod), 0o644) require.NoError(t, err) return dir } -func TestWailsBuilder_Build_Taskfile_Good(t *testing.T) { +func TestWails_WailsBuilderBuildTaskfile_Good(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } // Check if task is available - if _, err := exec.LookPath("task"); err != nil { + if _, err := ax.LookPath("task"); err != nil { t.Skip("task not installed, skipping test") } @@ -104,7 +102,7 @@ tasks: - mkdir -p {{.OUTPUT_DIR}}/{{.GOOS}}_{{.GOARCH}} - touch {{.OUTPUT_DIR}}/{{.GOOS}}_{{.GOARCH}}/testapp ` - err := os.WriteFile(filepath.Join(projectDir, "Taskfile.yml"), []byte(taskfile), 0644) + err := ax.WriteFile(ax.Join(projectDir, "Taskfile.yml"), []byte(taskfile), 0o644) require.NoError(t, err) builder := NewWailsBuilder() @@ -124,17 +122,17 @@ tasks: }) } -func TestWailsBuilder_Name_Good(t *testing.T) { +func TestWails_WailsBuilderName_Good(t *testing.T) { builder := NewWailsBuilder() assert.Equal(t, "wails", builder.Name()) } -func TestWailsBuilder_Build_V2_Good(t *testing.T) { +func TestWails_WailsBuilderBuildV2_Good(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } - if _, err := exec.LookPath("wails"); err != nil { + if _, err := ax.LookPath("wails"); err != nil { t.Skip("wails not installed, skipping integration test") } @@ -162,11 +160,11 @@ func TestWailsBuilder_Build_V2_Good(t *testing.T) { }) } -func TestWailsBuilder_Detect_Good(t *testing.T) { +func TestWails_WailsBuilderDetect_Good(t *testing.T) { fs := io.Local t.Run("detects Wails project with wails.json", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte("{}"), 0644) + err := ax.WriteFile(ax.Join(dir, "wails.json"), []byte("{}"), 0o644) require.NoError(t, err) builder := NewWailsBuilder() @@ -177,7 +175,7 @@ func TestWailsBuilder_Detect_Good(t *testing.T) { t.Run("returns false for Go-only project", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) + err := ax.WriteFile(ax.Join(dir, "go.mod"), []byte("module test"), 0o644) require.NoError(t, err) builder := NewWailsBuilder() @@ -188,7 +186,7 @@ func TestWailsBuilder_Detect_Good(t *testing.T) { t.Run("returns false for Node.js project", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0644) + err := ax.WriteFile(ax.Join(dir, "package.json"), []byte("{}"), 0o644) require.NoError(t, err) builder := NewWailsBuilder() @@ -207,11 +205,11 @@ func TestWailsBuilder_Detect_Good(t *testing.T) { }) } -func TestDetectPackageManager_Good(t *testing.T) { +func TestWails_DetectPackageManager_Good(t *testing.T) { fs := io.Local t.Run("detects bun from bun.lockb", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644) + err := ax.WriteFile(ax.Join(dir, "bun.lockb"), []byte(""), 0o644) require.NoError(t, err) result := detectPackageManager(fs, dir) @@ -220,7 +218,7 @@ func TestDetectPackageManager_Good(t *testing.T) { t.Run("detects pnpm from pnpm-lock.yaml", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte(""), 0644) + err := ax.WriteFile(ax.Join(dir, "pnpm-lock.yaml"), []byte(""), 0o644) require.NoError(t, err) result := detectPackageManager(fs, dir) @@ -229,7 +227,7 @@ func TestDetectPackageManager_Good(t *testing.T) { t.Run("detects yarn from yarn.lock", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644) + err := ax.WriteFile(ax.Join(dir, "yarn.lock"), []byte(""), 0o644) require.NoError(t, err) result := detectPackageManager(fs, dir) @@ -238,7 +236,7 @@ func TestDetectPackageManager_Good(t *testing.T) { t.Run("detects npm from package-lock.json", func(t *testing.T) { dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644) + err := ax.WriteFile(ax.Join(dir, "package-lock.json"), []byte(""), 0o644) require.NoError(t, err) result := detectPackageManager(fs, dir) @@ -255,9 +253,9 @@ func TestDetectPackageManager_Good(t *testing.T) { t.Run("prefers bun over other lock files", func(t *testing.T) { dir := t.TempDir() // Create multiple lock files - require.NoError(t, os.WriteFile(filepath.Join(dir, "bun.lockb"), []byte(""), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "bun.lockb"), []byte(""), 0o644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "yarn.lock"), []byte(""), 0o644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "package-lock.json"), []byte(""), 0o644)) result := detectPackageManager(fs, dir) assert.Equal(t, "bun", result) @@ -266,9 +264,9 @@ func TestDetectPackageManager_Good(t *testing.T) { t.Run("prefers pnpm over yarn and npm", func(t *testing.T) { dir := t.TempDir() // Create multiple lock files (no bun) - require.NoError(t, os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte(""), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "pnpm-lock.yaml"), []byte(""), 0o644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "yarn.lock"), []byte(""), 0o644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "package-lock.json"), []byte(""), 0o644)) result := detectPackageManager(fs, dir) assert.Equal(t, "pnpm", result) @@ -277,15 +275,15 @@ func TestDetectPackageManager_Good(t *testing.T) { t.Run("prefers yarn over npm", func(t *testing.T) { dir := t.TempDir() // Create multiple lock files (no bun or pnpm) - require.NoError(t, os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(""), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "yarn.lock"), []byte(""), 0o644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "package-lock.json"), []byte(""), 0o644)) result := detectPackageManager(fs, dir) assert.Equal(t, "yarn", result) }) } -func TestWailsBuilder_Build_Bad(t *testing.T) { +func TestWails_WailsBuilderBuild_Bad(t *testing.T) { t.Run("returns error for nil config", func(t *testing.T) { builder := NewWailsBuilder() @@ -313,13 +311,13 @@ func TestWailsBuilder_Build_Bad(t *testing.T) { }) } -func TestWailsBuilder_Build_Good(t *testing.T) { +func TestWails_WailsBuilderBuild_Good(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } // Check if wails3 is available in PATH - if _, err := exec.LookPath("wails3"); err != nil { + if _, err := ax.LookPath("wails3"); err != nil { t.Skip("wails3 not installed, skipping integration test") } @@ -349,13 +347,13 @@ func TestWailsBuilder_Build_Good(t *testing.T) { }) } -func TestWailsBuilder_Interface_Good(t *testing.T) { +func TestWails_WailsBuilderInterface_Good(t *testing.T) { // Verify WailsBuilder implements Builder interface var _ build.Builder = (*WailsBuilder)(nil) var _ build.Builder = NewWailsBuilder() } -func TestWailsBuilder_Ugly(t *testing.T) { +func TestWails_WailsBuilder_Ugly(t *testing.T) { t.Run("handles nonexistent frontend directory gracefully", func(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") @@ -363,7 +361,7 @@ func TestWailsBuilder_Ugly(t *testing.T) { // Create a Wails project without a frontend directory dir := t.TempDir() - err := os.WriteFile(filepath.Join(dir, "wails.json"), []byte("{}"), 0644) + err := ax.WriteFile(ax.Join(dir, "wails.json"), []byte("{}"), 0o644) require.NoError(t, err) builder := NewWailsBuilder() diff --git a/pkg/build/checksum.go b/pkg/build/checksum.go index f596452..c48f8fd 100644 --- a/pkg/build/checksum.go +++ b/pkg/build/checksum.go @@ -4,18 +4,16 @@ package build import ( "crypto/sha256" "encoding/hex" - "fmt" "io" - "path/filepath" - "slices" - - "strings" + "sort" + "dappco.re/go/core" io_interface "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // Checksum computes SHA256 for an artifact and returns the artifact with the Checksum field filled. +// Usage example: call build.Checksum(...) from integrating code. func Checksum(fs io_interface.Medium, artifact Artifact) (Artifact, error) { if artifact.Path == "" { return Artifact{}, coreerr.E("build.Checksum", "artifact path is empty", nil) @@ -46,6 +44,7 @@ func Checksum(fs io_interface.Medium, artifact Artifact) (Artifact, error) { // ChecksumAll computes checksums for all artifacts. // Returns a slice of artifacts with their Checksum fields filled. +// Usage example: call build.ChecksumAll(...) from integrating code. func ChecksumAll(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, error) { if len(artifacts) == 0 { return nil, nil @@ -70,6 +69,7 @@ func ChecksumAll(fs io_interface.Medium, artifacts []Artifact) ([]Artifact, erro // // The artifacts should have their Checksum fields filled (call ChecksumAll first). // Filenames are relative to the output directory (just the basename). +// Usage example: call build.WriteChecksumFile(...) from integrating code. func WriteChecksumFile(fs io_interface.Medium, artifacts []Artifact, path string) error { if len(artifacts) == 0 { return nil @@ -81,14 +81,14 @@ func WriteChecksumFile(fs io_interface.Medium, artifacts []Artifact, path string if artifact.Checksum == "" { return coreerr.E("build.WriteChecksumFile", "artifact "+artifact.Path+" has no checksum", nil) } - filename := filepath.Base(artifact.Path) - lines = append(lines, fmt.Sprintf("%s %s", artifact.Checksum, filename)) + filename := core.PathBase(artifact.Path) + lines = append(lines, core.Sprintf("%s %s", artifact.Checksum, filename)) } // Sort lines for consistent output - slices.Sort(lines) + sort.Strings(lines) - content := strings.Join(lines, "\n") + "\n" + content := core.Concat(core.Join("\n", lines...), "\n") // Write the file using the medium (which handles directory creation in Write) if err := fs.Write(path, content); err != nil { diff --git a/pkg/build/checksum_test.go b/pkg/build/checksum_test.go index 78f4ddc..11f2a6a 100644 --- a/pkg/build/checksum_test.go +++ b/pkg/build/checksum_test.go @@ -1,11 +1,10 @@ package build import ( - "os" - "path/filepath" - "strings" "testing" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,14 +15,14 @@ func setupChecksumTestFile(t *testing.T, content string) string { t.Helper() dir := t.TempDir() - path := filepath.Join(dir, "testfile") - err := os.WriteFile(path, []byte(content), 0644) + path := ax.Join(dir, "testfile") + err := ax.WriteFile(path, []byte(content), 0644) require.NoError(t, err) return path } -func TestChecksum_Good(t *testing.T) { +func TestChecksum_Checksum_Good(t *testing.T) { fs := io.Local t.Run("computes SHA256 checksum", func(t *testing.T) { // Known SHA256 of "Hello, World!\n" @@ -99,7 +98,7 @@ func TestChecksum_Good(t *testing.T) { }) } -func TestChecksum_Bad(t *testing.T) { +func TestChecksum_Checksum_Bad(t *testing.T) { fs := io.Local t.Run("returns error for empty path", func(t *testing.T) { artifact := Artifact{ @@ -128,7 +127,7 @@ func TestChecksum_Bad(t *testing.T) { }) } -func TestChecksumAll_Good(t *testing.T) { +func TestChecksum_ChecksumAll_Good(t *testing.T) { fs := io.Local t.Run("checksums multiple artifacts", func(t *testing.T) { paths := []string{ @@ -168,7 +167,7 @@ func TestChecksumAll_Good(t *testing.T) { }) } -func TestChecksumAll_Bad(t *testing.T) { +func TestChecksum_ChecksumAll_Bad(t *testing.T) { fs := io.Local t.Run("returns partial results on error", func(t *testing.T) { path := setupChecksumTestFile(t, "valid content") @@ -186,11 +185,11 @@ func TestChecksumAll_Bad(t *testing.T) { }) } -func TestWriteChecksumFile_Good(t *testing.T) { +func TestChecksum_WriteChecksumFile_Good(t *testing.T) { fs := io.Local t.Run("writes checksum file with correct format", func(t *testing.T) { dir := t.TempDir() - checksumPath := filepath.Join(dir, "CHECKSUMS.txt") + checksumPath := ax.Join(dir, "CHECKSUMS.txt") artifacts := []Artifact{ {Path: "/output/app_linux_amd64.tar.gz", Checksum: "abc123def456", OS: "linux", Arch: "amd64"}, @@ -201,10 +200,10 @@ func TestWriteChecksumFile_Good(t *testing.T) { require.NoError(t, err) // Read and verify content - content, err := os.ReadFile(checksumPath) + content, err := ax.ReadFile(checksumPath) require.NoError(t, err) - lines := strings.Split(strings.TrimSpace(string(content)), "\n") + lines := core.Split(core.Trim(string(content)), "\n") require.Len(t, lines, 2) // Lines should be sorted alphabetically @@ -214,7 +213,7 @@ func TestWriteChecksumFile_Good(t *testing.T) { t.Run("creates parent directories", func(t *testing.T) { dir := t.TempDir() - checksumPath := filepath.Join(dir, "nested", "deep", "CHECKSUMS.txt") + checksumPath := ax.Join(dir, "nested", "deep", "CHECKSUMS.txt") artifacts := []Artifact{ {Path: "/output/app.tar.gz", Checksum: "abc123", OS: "linux", Arch: "amd64"}, @@ -227,19 +226,18 @@ func TestWriteChecksumFile_Good(t *testing.T) { t.Run("does nothing for empty artifacts", func(t *testing.T) { dir := t.TempDir() - checksumPath := filepath.Join(dir, "CHECKSUMS.txt") + checksumPath := ax.Join(dir, "CHECKSUMS.txt") err := WriteChecksumFile(fs, []Artifact{}, checksumPath) require.NoError(t, err) // File should not exist - _, err = os.Stat(checksumPath) - assert.True(t, os.IsNotExist(err)) + assert.False(t, ax.Exists(checksumPath)) }) t.Run("does nothing for nil artifacts", func(t *testing.T) { dir := t.TempDir() - checksumPath := filepath.Join(dir, "CHECKSUMS.txt") + checksumPath := ax.Join(dir, "CHECKSUMS.txt") err := WriteChecksumFile(fs, nil, checksumPath) require.NoError(t, err) @@ -247,7 +245,7 @@ func TestWriteChecksumFile_Good(t *testing.T) { t.Run("uses only basename for filenames", func(t *testing.T) { dir := t.TempDir() - checksumPath := filepath.Join(dir, "CHECKSUMS.txt") + checksumPath := ax.Join(dir, "CHECKSUMS.txt") artifacts := []Artifact{ {Path: "/some/deep/nested/path/myapp_linux_amd64.tar.gz", Checksum: "checksum123", OS: "linux", Arch: "amd64"}, @@ -256,7 +254,7 @@ func TestWriteChecksumFile_Good(t *testing.T) { err := WriteChecksumFile(fs, artifacts, checksumPath) require.NoError(t, err) - content, err := os.ReadFile(checksumPath) + content, err := ax.ReadFile(checksumPath) require.NoError(t, err) // Should only contain the basename @@ -265,11 +263,11 @@ func TestWriteChecksumFile_Good(t *testing.T) { }) } -func TestWriteChecksumFile_Bad(t *testing.T) { +func TestChecksum_WriteChecksumFile_Bad(t *testing.T) { fs := io.Local t.Run("returns error for artifact without checksum", func(t *testing.T) { dir := t.TempDir() - checksumPath := filepath.Join(dir, "CHECKSUMS.txt") + checksumPath := ax.Join(dir, "CHECKSUMS.txt") artifacts := []Artifact{ {Path: "/output/app.tar.gz", Checksum: "", OS: "linux", Arch: "amd64"}, // No checksum diff --git a/pkg/build/config.go b/pkg/build/config.go index ea761f6..7acdbad 100644 --- a/pkg/build/config.go +++ b/pkg/build/config.go @@ -4,9 +4,8 @@ package build import ( "iter" - "os" - "path/filepath" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build/signing" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" @@ -14,13 +13,16 @@ import ( ) // ConfigFileName is the name of the build configuration file. +// Usage example: reference build.ConfigFileName from package consumers. const ConfigFileName = "build.yaml" // ConfigDir is the directory where build configuration is stored. +// Usage example: reference build.ConfigDir from package consumers. const ConfigDir = ".core" // BuildConfig holds the complete build configuration loaded from .core/build.yaml. // This is distinct from Config which holds runtime build parameters. +// Usage example: declare a value of type build.BuildConfig in integrating code. type BuildConfig struct { // Version is the config file format version. Version int `yaml:"version"` @@ -35,6 +37,7 @@ type BuildConfig struct { } // Project holds project metadata. +// Usage example: declare a value of type build.Project in integrating code. type Project struct { // Name is the project name. Name string `yaml:"name"` @@ -47,6 +50,7 @@ type Project struct { } // Build holds build-time settings. +// Usage example: declare a value of type build.Build in integrating code. type Build struct { // Type overrides project type auto-detection (e.g., "go", "wails", "docker"). Type string `yaml:"type"` @@ -62,6 +66,7 @@ type Build struct { // TargetConfig defines a build target in the config file. // This is separate from Target to allow for additional config-specific fields. +// Usage example: declare a value of type build.TargetConfig in integrating code. type TargetConfig struct { // OS is the target operating system (e.g., "linux", "darwin", "windows"). OS string `yaml:"os"` @@ -72,12 +77,13 @@ type TargetConfig struct { // LoadConfig loads build configuration from the .core/build.yaml file in the given directory. // If the config file does not exist, it returns DefaultConfig(). // Returns an error if the file exists but cannot be parsed. +// Usage example: call build.LoadConfig(...) from integrating code. func LoadConfig(fs io.Medium, dir string) (*BuildConfig, error) { - configPath := filepath.Join(dir, ConfigDir, ConfigFileName) + configPath := ax.Join(dir, ConfigDir, ConfigFileName) content, err := fs.Read(configPath) if err != nil { - if os.IsNotExist(err) { + if !fs.Exists(configPath) { return DefaultConfig(), nil } return nil, coreerr.E("build.LoadConfig", "failed to read config file", err) @@ -96,6 +102,7 @@ func LoadConfig(fs io.Medium, dir string) (*BuildConfig, error) { } // DefaultConfig returns sensible defaults for Go projects. +// Usage example: call build.DefaultConfig(...) from integrating code. func DefaultConfig() *BuildConfig { return &BuildConfig{ Version: 1, @@ -153,16 +160,19 @@ func applyDefaults(cfg *BuildConfig) { } // ConfigPath returns the path to the build config file for a given directory. +// Usage example: call build.ConfigPath(...) from integrating code. func ConfigPath(dir string) string { - return filepath.Join(dir, ConfigDir, ConfigFileName) + return ax.Join(dir, ConfigDir, ConfigFileName) } // ConfigExists checks if a build config file exists in the given directory. +// Usage example: call build.ConfigExists(...) from integrating code. func ConfigExists(fs io.Medium, dir string) bool { return fileExists(fs, ConfigPath(dir)) } // TargetsIter returns an iterator for the build targets. +// Usage example: call value.TargetsIter(...) from integrating code. func (cfg *BuildConfig) TargetsIter() iter.Seq[TargetConfig] { return func(yield func(TargetConfig) bool) { for _, t := range cfg.Targets { @@ -174,6 +184,7 @@ func (cfg *BuildConfig) TargetsIter() iter.Seq[TargetConfig] { } // ToTargets converts TargetConfig slice to Target slice for use with builders. +// Usage example: call value.ToTargets(...) from integrating code. func (cfg *BuildConfig) ToTargets() []Target { targets := make([]Target, len(cfg.Targets)) for i, t := range cfg.Targets { diff --git a/pkg/build/config_test.go b/pkg/build/config_test.go index 552eec8..e47af3b 100644 --- a/pkg/build/config_test.go +++ b/pkg/build/config_test.go @@ -1,10 +1,10 @@ package build import ( - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -16,19 +16,19 @@ func setupConfigTestDir(t *testing.T, configContent string) string { dir := t.TempDir() if configContent != "" { - coreDir := filepath.Join(dir, ConfigDir) - err := os.MkdirAll(coreDir, 0755) + coreDir := ax.Join(dir, ConfigDir) + err := ax.MkdirAll(coreDir, 0755) require.NoError(t, err) - configPath := filepath.Join(coreDir, ConfigFileName) - err = os.WriteFile(configPath, []byte(configContent), 0644) + configPath := ax.Join(coreDir, ConfigFileName) + err = ax.WriteFile(configPath, []byte(configContent), 0644) require.NoError(t, err) } return dir } -func TestLoadConfig_Good(t *testing.T) { +func TestConfig_LoadConfig_Good(t *testing.T) { fs := io.Local t.Run("loads valid config", func(t *testing.T) { content := ` @@ -142,7 +142,7 @@ targets: }) } -func TestLoadConfig_Bad(t *testing.T) { +func TestConfig_LoadConfig_Bad(t *testing.T) { fs := io.Local t.Run("returns error for invalid YAML", func(t *testing.T) { content := ` @@ -160,13 +160,13 @@ project: t.Run("returns error for unreadable file", func(t *testing.T) { dir := t.TempDir() - coreDir := filepath.Join(dir, ConfigDir) - err := os.MkdirAll(coreDir, 0755) + coreDir := ax.Join(dir, ConfigDir) + err := ax.MkdirAll(coreDir, 0755) require.NoError(t, err) // Create config as a directory instead of file - configPath := filepath.Join(coreDir, ConfigFileName) - err = os.Mkdir(configPath, 0755) + configPath := ax.Join(coreDir, ConfigFileName) + err = ax.Mkdir(configPath, 0755) require.NoError(t, err) cfg, err := LoadConfig(fs, dir) @@ -176,7 +176,7 @@ project: }) } -func TestDefaultConfig_Good(t *testing.T) { +func TestConfig_DefaultConfig_Good(t *testing.T) { t.Run("returns sensible defaults", func(t *testing.T) { cfg := DefaultConfig() @@ -212,14 +212,14 @@ func TestDefaultConfig_Good(t *testing.T) { }) } -func TestConfigPath_Good(t *testing.T) { +func TestConfig_ConfigPath_Good(t *testing.T) { t.Run("returns correct path", func(t *testing.T) { path := ConfigPath("/project/root") assert.Equal(t, "/project/root/.core/build.yaml", path) }) } -func TestConfigExists_Good(t *testing.T) { +func TestConfig_ConfigExists_Good(t *testing.T) { fs := io.Local t.Run("returns true when config exists", func(t *testing.T) { dir := setupConfigTestDir(t, "version: 1") @@ -237,10 +237,10 @@ func TestConfigExists_Good(t *testing.T) { }) } -func TestLoadConfig_Good_SignConfig(t *testing.T) { +func TestConfig_LoadConfigSignConfig_Good(t *testing.T) { tmpDir := t.TempDir() - coreDir := filepath.Join(tmpDir, ".core") - _ = os.MkdirAll(coreDir, 0755) + coreDir := ax.Join(tmpDir, ".core") + _ = ax.MkdirAll(coreDir, 0755) configContent := `version: 1 sign: @@ -251,7 +251,7 @@ sign: identity: "Developer ID Application: Test" notarize: true ` - _ = os.WriteFile(filepath.Join(coreDir, "build.yaml"), []byte(configContent), 0644) + _ = ax.WriteFile(ax.Join(coreDir, "build.yaml"), []byte(configContent), 0644) cfg, err := LoadConfig(io.Local, tmpDir) if err != nil { @@ -272,7 +272,7 @@ sign: } } -func TestBuildConfig_ToTargets_Good(t *testing.T) { +func TestConfig_BuildConfigToTargets_Good(t *testing.T) { t.Run("converts TargetConfig to Target", func(t *testing.T) { cfg := &BuildConfig{ Targets: []TargetConfig{ @@ -301,9 +301,9 @@ func TestBuildConfig_ToTargets_Good(t *testing.T) { } // TestLoadConfig_Testdata tests loading from the testdata fixture. -func TestLoadConfig_Testdata(t *testing.T) { +func TestConfig_LoadConfigTestdata_Good(t *testing.T) { fs := io.Local - abs, err := filepath.Abs("testdata/config-project") + abs, err := ax.Abs("testdata/config-project") require.NoError(t, err) t.Run("loads config-project fixture", func(t *testing.T) { diff --git a/pkg/build/discovery.go b/pkg/build/discovery.go index 4770336..eadb28b 100644 --- a/pkg/build/discovery.go +++ b/pkg/build/discovery.go @@ -1,9 +1,8 @@ package build import ( - "path/filepath" - "slices" - + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/io" ) @@ -33,14 +32,15 @@ var markers = []projectMarker{ // Discover detects project types in the given directory by checking for marker files. // Returns a slice of detected project types, ordered by priority (most specific first). // For example, a Wails project returns [wails, go] since it has both wails.json and go.mod. +// Usage example: call build.Discover(...) from integrating code. func Discover(fs io.Medium, dir string) ([]ProjectType, error) { var detected []ProjectType for _, m := range markers { - path := filepath.Join(dir, m.file) + path := ax.Join(dir, m.file) if fileExists(fs, path) { // Avoid duplicates (shouldn't happen with current markers, but defensive) - if !slices.Contains(detected, m.projectType) { + if !core.NewArray(detected...).Contains(m.projectType) { detected = append(detected, m.projectType) } } @@ -51,6 +51,7 @@ func Discover(fs io.Medium, dir string) ([]ProjectType, error) { // PrimaryType returns the most specific project type detected in the directory. // Returns empty string if no project type is detected. +// Usage example: call build.PrimaryType(...) from integrating code. func PrimaryType(fs io.Medium, dir string) (ProjectType, error) { types, err := Discover(fs, dir) if err != nil { @@ -63,29 +64,34 @@ func PrimaryType(fs io.Medium, dir string) (ProjectType, error) { } // IsGoProject checks if the directory contains a Go project (go.mod or wails.json). +// Usage example: call build.IsGoProject(...) from integrating code. func IsGoProject(fs io.Medium, dir string) bool { - return fileExists(fs, filepath.Join(dir, markerGoMod)) || - fileExists(fs, filepath.Join(dir, markerWails)) + return fileExists(fs, ax.Join(dir, markerGoMod)) || + fileExists(fs, ax.Join(dir, markerWails)) } // IsWailsProject checks if the directory contains a Wails project. +// Usage example: call build.IsWailsProject(...) from integrating code. func IsWailsProject(fs io.Medium, dir string) bool { - return fileExists(fs, filepath.Join(dir, markerWails)) + return fileExists(fs, ax.Join(dir, markerWails)) } // IsNodeProject checks if the directory contains a Node.js project. +// Usage example: call build.IsNodeProject(...) from integrating code. func IsNodeProject(fs io.Medium, dir string) bool { - return fileExists(fs, filepath.Join(dir, markerNodePackage)) + return fileExists(fs, ax.Join(dir, markerNodePackage)) } // IsPHPProject checks if the directory contains a PHP project. +// Usage example: call build.IsPHPProject(...) from integrating code. func IsPHPProject(fs io.Medium, dir string) bool { - return fileExists(fs, filepath.Join(dir, markerComposer)) + return fileExists(fs, ax.Join(dir, markerComposer)) } // IsCPPProject checks if the directory contains a C++ project (CMakeLists.txt). +// Usage example: call build.IsCPPProject(...) from integrating code. func IsCPPProject(fs io.Medium, dir string) bool { - return fileExists(fs, filepath.Join(dir, "CMakeLists.txt")) + return fileExists(fs, ax.Join(dir, "CMakeLists.txt")) } // fileExists checks if a file exists and is not a directory. diff --git a/pkg/build/discovery_test.go b/pkg/build/discovery_test.go index a4653aa..4794eb0 100644 --- a/pkg/build/discovery_test.go +++ b/pkg/build/discovery_test.go @@ -1,10 +1,10 @@ package build import ( - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -15,14 +15,14 @@ func setupTestDir(t *testing.T, markers ...string) string { t.Helper() dir := t.TempDir() for _, m := range markers { - path := filepath.Join(dir, m) - err := os.WriteFile(path, []byte("{}"), 0644) + path := ax.Join(dir, m) + err := ax.WriteFile(path, []byte("{}"), 0644) require.NoError(t, err) } return dir } -func TestDiscover_Good(t *testing.T) { +func TestDiscovery_Discover_Good(t *testing.T) { fs := io.Local t.Run("detects Go project", func(t *testing.T) { dir := setupTestDir(t, "go.mod") @@ -67,18 +67,18 @@ func TestDiscover_Good(t *testing.T) { }) } -func TestDiscover_Bad(t *testing.T) { +func TestDiscovery_Discover_Bad(t *testing.T) { fs := io.Local t.Run("non-existent directory returns empty slice", func(t *testing.T) { types, err := Discover(fs, "/non/existent/path") - assert.NoError(t, err) // os.Stat fails silently in fileExists + assert.NoError(t, err) // ax.Stat fails silently in fileExists assert.Empty(t, types) }) t.Run("directory marker is ignored", func(t *testing.T) { dir := t.TempDir() // Create go.mod as a directory instead of a file - err := os.Mkdir(filepath.Join(dir, "go.mod"), 0755) + err := ax.Mkdir(ax.Join(dir, "go.mod"), 0755) require.NoError(t, err) types, err := Discover(fs, dir) @@ -87,7 +87,7 @@ func TestDiscover_Bad(t *testing.T) { }) } -func TestPrimaryType_Good(t *testing.T) { +func TestDiscovery_PrimaryType_Good(t *testing.T) { fs := io.Local t.Run("returns wails for wails project", func(t *testing.T) { dir := setupTestDir(t, "wails.json", "go.mod") @@ -111,7 +111,7 @@ func TestPrimaryType_Good(t *testing.T) { }) } -func TestIsGoProject_Good(t *testing.T) { +func TestDiscovery_IsGoProject_Good(t *testing.T) { fs := io.Local t.Run("true with go.mod", func(t *testing.T) { dir := setupTestDir(t, "go.mod") @@ -129,7 +129,7 @@ func TestIsGoProject_Good(t *testing.T) { }) } -func TestIsWailsProject_Good(t *testing.T) { +func TestDiscovery_IsWailsProject_Good(t *testing.T) { fs := io.Local t.Run("true with wails.json", func(t *testing.T) { dir := setupTestDir(t, "wails.json") @@ -142,7 +142,7 @@ func TestIsWailsProject_Good(t *testing.T) { }) } -func TestIsNodeProject_Good(t *testing.T) { +func TestDiscovery_IsNodeProject_Good(t *testing.T) { fs := io.Local t.Run("true with package.json", func(t *testing.T) { dir := setupTestDir(t, "package.json") @@ -155,7 +155,7 @@ func TestIsNodeProject_Good(t *testing.T) { }) } -func TestIsPHPProject_Good(t *testing.T) { +func TestDiscovery_IsPHPProject_Good(t *testing.T) { fs := io.Local t.Run("true with composer.json", func(t *testing.T) { dir := setupTestDir(t, "composer.json") @@ -168,17 +168,17 @@ func TestIsPHPProject_Good(t *testing.T) { }) } -func TestTarget_Good(t *testing.T) { +func TestDiscovery_Target_Good(t *testing.T) { target := Target{OS: "linux", Arch: "amd64"} assert.Equal(t, "linux/amd64", target.String()) } -func TestFileExists_Good(t *testing.T) { +func TestDiscovery_FileExists_Good(t *testing.T) { fs := io.Local t.Run("returns true for existing file", func(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "test.txt") - err := os.WriteFile(path, []byte("content"), 0644) + path := ax.Join(dir, "test.txt") + err := ax.WriteFile(path, []byte("content"), 0644) require.NoError(t, err) assert.True(t, fileExists(fs, path)) }) @@ -195,9 +195,9 @@ func TestFileExists_Good(t *testing.T) { // TestDiscover_Testdata tests discovery using the testdata fixtures. // These serve as integration tests with realistic project structures. -func TestDiscover_Testdata(t *testing.T) { +func TestDiscovery_DiscoverTestdata_Good(t *testing.T) { fs := io.Local - testdataDir, err := filepath.Abs("testdata") + testdataDir, err := ax.Abs("testdata") require.NoError(t, err) tests := []struct { @@ -215,7 +215,7 @@ func TestDiscover_Testdata(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - dir := filepath.Join(testdataDir, tt.dir) + dir := ax.Join(testdataDir, tt.dir) types, err := Discover(fs, dir) assert.NoError(t, err) if len(tt.expected) == 0 { diff --git a/pkg/build/signing/codesign.go b/pkg/build/signing/codesign.go index a2d01fe..62c4e28 100644 --- a/pkg/build/signing/codesign.go +++ b/pkg/build/signing/codesign.go @@ -2,14 +2,15 @@ package signing import ( "context" - "os/exec" "runtime" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // MacOSSigner signs binaries using macOS codesign. +// Usage example: declare a value of type signing.MacOSSigner in integrating code. type MacOSSigner struct { config MacOSConfig } @@ -18,16 +19,19 @@ type MacOSSigner struct { var _ Signer = (*MacOSSigner)(nil) // NewMacOSSigner creates a new macOS signer. +// Usage example: call signing.NewMacOSSigner(...) from integrating code. func NewMacOSSigner(cfg MacOSConfig) *MacOSSigner { return &MacOSSigner{config: cfg} } // Name returns "codesign". +// Usage example: call value.Name(...) from integrating code. func (s *MacOSSigner) Name() string { return "codesign" } // Available checks if running on macOS with codesign and identity configured. +// Usage example: call value.Available(...) from integrating code. func (s *MacOSSigner) Available() bool { if runtime.GOOS != "darwin" { return false @@ -35,11 +39,12 @@ func (s *MacOSSigner) Available() bool { if s.config.Identity == "" { return false } - _, err := exec.LookPath("codesign") + _, err := ax.LookPath("codesign") return err == nil } // Sign codesigns a binary with hardened runtime. +// Usage example: call value.Sign(...) from integrating code. func (s *MacOSSigner) Sign(ctx context.Context, fs io.Medium, binary string) error { if !s.Available() { if runtime.GOOS != "darwin" { @@ -51,17 +56,15 @@ func (s *MacOSSigner) Sign(ctx context.Context, fs io.Medium, binary string) err return coreerr.E("codesign.Sign", "codesign tool not found in PATH", nil) } - cmd := exec.CommandContext(ctx, "codesign", + output, err := ax.CombinedOutput(ctx, "", nil, "codesign", "--sign", s.config.Identity, "--timestamp", "--options", "runtime", // Hardened runtime for notarization "--force", binary, ) - - output, err := cmd.CombinedOutput() if err != nil { - return coreerr.E("codesign.Sign", string(output), err) + return coreerr.E("codesign.Sign", output, err) } return nil @@ -69,6 +72,7 @@ func (s *MacOSSigner) Sign(ctx context.Context, fs io.Medium, binary string) err // Notarize submits binary to Apple for notarization and staples the ticket. // This blocks until Apple responds (typically 1-5 minutes). +// Usage example: call value.Notarize(...) from integrating code. func (s *MacOSSigner) Notarize(ctx context.Context, fs io.Medium, binary string) error { if s.config.AppleID == "" || s.config.TeamID == "" || s.config.AppPassword == "" { return coreerr.E("codesign.Notarize", "missing Apple credentials (apple_id, team_id, app_password)", nil) @@ -76,34 +80,32 @@ func (s *MacOSSigner) Notarize(ctx context.Context, fs io.Medium, binary string) // Create ZIP for submission zipPath := binary + ".zip" - zipCmd := exec.CommandContext(ctx, "zip", "-j", zipPath, binary) - if output, err := zipCmd.CombinedOutput(); err != nil { - return coreerr.E("codesign.Notarize", "failed to create zip: "+string(output), err) + if output, err := ax.CombinedOutput(ctx, "", nil, "zip", "-j", zipPath, binary); err != nil { + return coreerr.E("codesign.Notarize", "failed to create zip: "+output, err) } defer func() { _ = fs.Delete(zipPath) }() // Submit to Apple and wait - submitCmd := exec.CommandContext(ctx, "xcrun", "notarytool", "submit", + if output, err := ax.CombinedOutput(ctx, "", nil, "xcrun", "notarytool", "submit", zipPath, "--apple-id", s.config.AppleID, "--team-id", s.config.TeamID, "--password", s.config.AppPassword, "--wait", - ) - if output, err := submitCmd.CombinedOutput(); err != nil { - return coreerr.E("codesign.Notarize", "notarization failed: "+string(output), err) + ); err != nil { + return coreerr.E("codesign.Notarize", "notarization failed: "+output, err) } // Staple the ticket - stapleCmd := exec.CommandContext(ctx, "xcrun", "stapler", "staple", binary) - if output, err := stapleCmd.CombinedOutput(); err != nil { - return coreerr.E("codesign.Notarize", "failed to staple: "+string(output), err) + if output, err := ax.CombinedOutput(ctx, "", nil, "xcrun", "stapler", "staple", binary); err != nil { + return coreerr.E("codesign.Notarize", "failed to staple: "+output, err) } return nil } // ShouldNotarize returns true if notarization is enabled. +// Usage example: call value.ShouldNotarize(...) from integrating code. func (s *MacOSSigner) ShouldNotarize() bool { return s.config.Notarize } diff --git a/pkg/build/signing/codesign_test.go b/pkg/build/signing/codesign_test.go index 863bec0..91857bf 100644 --- a/pkg/build/signing/codesign_test.go +++ b/pkg/build/signing/codesign_test.go @@ -9,12 +9,12 @@ import ( "github.com/stretchr/testify/assert" ) -func TestMacOSSigner_Good_Name(t *testing.T) { +func TestCodesign_MacOSSignerName_Good(t *testing.T) { s := NewMacOSSigner(MacOSConfig{Identity: "Developer ID Application: Test"}) assert.Equal(t, "codesign", s.Name()) } -func TestMacOSSigner_Good_Available(t *testing.T) { +func TestCodesign_MacOSSignerAvailable_Good(t *testing.T) { s := NewMacOSSigner(MacOSConfig{Identity: "Developer ID Application: Test"}) if runtime.GOOS == "darwin" { @@ -25,12 +25,12 @@ func TestMacOSSigner_Good_Available(t *testing.T) { } } -func TestMacOSSigner_Bad_NoIdentity(t *testing.T) { +func TestCodesign_MacOSSignerNoIdentity_Bad(t *testing.T) { s := NewMacOSSigner(MacOSConfig{}) assert.False(t, s.Available()) } -func TestMacOSSigner_Sign_Bad(t *testing.T) { +func TestCodesign_MacOSSignerSign_Bad(t *testing.T) { t.Run("fails when not available", func(t *testing.T) { if runtime.GOOS == "darwin" { t.Skip("skipping on macOS") @@ -39,11 +39,11 @@ func TestMacOSSigner_Sign_Bad(t *testing.T) { s := NewMacOSSigner(MacOSConfig{Identity: "test"}) err := s.Sign(context.Background(), fs, "test") assert.Error(t, err) - assert.Contains(t, err.Error(), "not available") + assert.Contains(t, err.Error(), "only available on macOS") }) } -func TestMacOSSigner_Notarize_Bad(t *testing.T) { +func TestCodesign_MacOSSignerNotarize_Bad(t *testing.T) { fs := io.Local t.Run("fails with missing credentials", func(t *testing.T) { s := NewMacOSSigner(MacOSConfig{}) @@ -53,7 +53,7 @@ func TestMacOSSigner_Notarize_Bad(t *testing.T) { }) } -func TestMacOSSigner_ShouldNotarize(t *testing.T) { +func TestCodesign_MacOSSignerShouldNotarize_Good(t *testing.T) { s := NewMacOSSigner(MacOSConfig{Notarize: true}) assert.True(t, s.ShouldNotarize()) diff --git a/pkg/build/signing/gpg.go b/pkg/build/signing/gpg.go index 9106482..2c3a370 100644 --- a/pkg/build/signing/gpg.go +++ b/pkg/build/signing/gpg.go @@ -2,13 +2,14 @@ package signing import ( "context" - "os/exec" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // GPGSigner signs files using GPG. +// Usage example: declare a value of type signing.GPGSigner in integrating code. type GPGSigner struct { KeyID string } @@ -17,42 +18,44 @@ type GPGSigner struct { var _ Signer = (*GPGSigner)(nil) // NewGPGSigner creates a new GPG signer. +// Usage example: call signing.NewGPGSigner(...) from integrating code. func NewGPGSigner(keyID string) *GPGSigner { return &GPGSigner{KeyID: keyID} } // Name returns "gpg". +// Usage example: call value.Name(...) from integrating code. func (s *GPGSigner) Name() string { return "gpg" } // Available checks if gpg is installed and key is configured. +// Usage example: call value.Available(...) from integrating code. func (s *GPGSigner) Available() bool { if s.KeyID == "" { return false } - _, err := exec.LookPath("gpg") + _, err := ax.LookPath("gpg") return err == nil } // Sign creates a detached ASCII-armored signature. // For file.txt, creates file.txt.asc +// Usage example: call value.Sign(...) from integrating code. func (s *GPGSigner) Sign(ctx context.Context, fs io.Medium, file string) error { if !s.Available() { return coreerr.E("gpg.Sign", "gpg not available or key not configured", nil) } - cmd := exec.CommandContext(ctx, "gpg", + output, err := ax.CombinedOutput(ctx, "", nil, "gpg", "--detach-sign", "--armor", "--local-user", s.KeyID, "--output", file+".asc", file, ) - - output, err := cmd.CombinedOutput() if err != nil { - return coreerr.E("gpg.Sign", string(output), err) + return coreerr.E("gpg.Sign", output, err) } return nil diff --git a/pkg/build/signing/gpg_test.go b/pkg/build/signing/gpg_test.go index b67cda8..9ff57df 100644 --- a/pkg/build/signing/gpg_test.go +++ b/pkg/build/signing/gpg_test.go @@ -8,22 +8,22 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGPGSigner_Good_Name(t *testing.T) { +func TestGPG_GPGSignerName_Good(t *testing.T) { s := NewGPGSigner("ABCD1234") assert.Equal(t, "gpg", s.Name()) } -func TestGPGSigner_Good_Available(t *testing.T) { +func TestGPG_GPGSignerAvailable_Good(t *testing.T) { s := NewGPGSigner("ABCD1234") _ = s.Available() } -func TestGPGSigner_Bad_NoKey(t *testing.T) { +func TestGPG_GPGSignerNoKey_Bad(t *testing.T) { s := NewGPGSigner("") assert.False(t, s.Available()) } -func TestGPGSigner_Sign_Bad(t *testing.T) { +func TestGPG_GPGSignerSign_Bad(t *testing.T) { fs := io.Local t.Run("fails when no key", func(t *testing.T) { s := NewGPGSigner("") diff --git a/pkg/build/signing/sign.go b/pkg/build/signing/sign.go index 5c39e49..155cee5 100644 --- a/pkg/build/signing/sign.go +++ b/pkg/build/signing/sign.go @@ -2,15 +2,16 @@ package signing import ( "context" - "fmt" "runtime" + "dappco.re/go/core" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // Artifact represents a build output that can be signed. // This mirrors build.Artifact to avoid import cycles. +// Usage example: declare a value of type signing.Artifact in integrating code. type Artifact struct { Path string OS string @@ -19,6 +20,7 @@ type Artifact struct { // SignBinaries signs macOS binaries in the artifacts list. // Only signs darwin binaries when running on macOS with a configured identity. +// Usage example: call signing.SignBinaries(...) from integrating code. func SignBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts []Artifact) error { if !cfg.Enabled { return nil @@ -39,7 +41,7 @@ func SignBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts [ continue } - fmt.Printf(" Signing %s...\n", artifact.Path) + core.Print(nil, " Signing %s...", artifact.Path) if err := signer.Sign(ctx, fs, artifact.Path); err != nil { return coreerr.E("signing.SignBinaries", "failed to sign "+artifact.Path, err) } @@ -49,6 +51,7 @@ func SignBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts [ } // NotarizeBinaries notarizes macOS binaries if enabled. +// Usage example: call signing.NotarizeBinaries(...) from integrating code. func NotarizeBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifacts []Artifact) error { if !cfg.Enabled || !cfg.MacOS.Notarize { return nil @@ -68,7 +71,7 @@ func NotarizeBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifac continue } - fmt.Printf(" Notarizing %s (this may take a few minutes)...\n", artifact.Path) + core.Print(nil, " Notarizing %s (this may take a few minutes)...", artifact.Path) if err := signer.Notarize(ctx, fs, artifact.Path); err != nil { return coreerr.E("signing.NotarizeBinaries", "failed to notarize "+artifact.Path, err) } @@ -78,6 +81,7 @@ func NotarizeBinaries(ctx context.Context, fs io.Medium, cfg SignConfig, artifac } // SignChecksums signs the checksums file with GPG. +// Usage example: call signing.SignChecksums(...) from integrating code. func SignChecksums(ctx context.Context, fs io.Medium, cfg SignConfig, checksumFile string) error { if !cfg.Enabled { return nil @@ -88,7 +92,7 @@ func SignChecksums(ctx context.Context, fs io.Medium, cfg SignConfig, checksumFi return nil // Silently skip if not configured } - fmt.Printf(" Signing %s with GPG...\n", checksumFile) + core.Print(nil, " Signing %s with GPG...", checksumFile) if err := signer.Sign(ctx, fs, checksumFile); err != nil { return coreerr.E("signing.SignChecksums", "failed to sign checksums file "+checksumFile, err) } diff --git a/pkg/build/signing/signer.go b/pkg/build/signing/signer.go index 906d90e..a88591d 100644 --- a/pkg/build/signing/signer.go +++ b/pkg/build/signing/signer.go @@ -3,13 +3,13 @@ package signing import ( "context" - "os" - "strings" + "dappco.re/go/core" "dappco.re/go/core/io" ) // Signer defines the interface for code signing implementations. +// Usage example: declare a value of type signing.Signer in integrating code. type Signer interface { // Name returns the signer's identifier. Name() string @@ -20,6 +20,7 @@ type Signer interface { } // SignConfig holds signing configuration from .core/build.yaml. +// Usage example: declare a value of type signing.SignConfig in integrating code. type SignConfig struct { Enabled bool `yaml:"enabled"` GPG GPGConfig `yaml:"gpg,omitempty"` @@ -28,11 +29,13 @@ type SignConfig struct { } // GPGConfig holds GPG signing configuration. +// Usage example: declare a value of type signing.GPGConfig in integrating code. type GPGConfig struct { Key string `yaml:"key"` // Key ID or fingerprint, supports $ENV } // MacOSConfig holds macOS codesign configuration. +// Usage example: declare a value of type signing.MacOSConfig in integrating code. type MacOSConfig struct { Identity string `yaml:"identity"` // Developer ID Application: ... Notarize bool `yaml:"notarize"` // Submit to Apple for notarization @@ -42,28 +45,31 @@ type MacOSConfig struct { } // WindowsConfig holds Windows signtool configuration (placeholder). +// Usage example: declare a value of type signing.WindowsConfig in integrating code. type WindowsConfig struct { Certificate string `yaml:"certificate"` // Path to .pfx Password string `yaml:"password"` // Certificate password } // DefaultSignConfig returns sensible defaults. +// Usage example: call signing.DefaultSignConfig(...) from integrating code. func DefaultSignConfig() SignConfig { return SignConfig{ Enabled: true, GPG: GPGConfig{ - Key: os.Getenv("GPG_KEY_ID"), + Key: core.Env("GPG_KEY_ID"), }, MacOS: MacOSConfig{ - Identity: os.Getenv("CODESIGN_IDENTITY"), - AppleID: os.Getenv("APPLE_ID"), - TeamID: os.Getenv("APPLE_TEAM_ID"), - AppPassword: os.Getenv("APPLE_APP_PASSWORD"), + Identity: core.Env("CODESIGN_IDENTITY"), + AppleID: core.Env("APPLE_ID"), + TeamID: core.Env("APPLE_TEAM_ID"), + AppPassword: core.Env("APPLE_APP_PASSWORD"), }, } } // ExpandEnv expands environment variables in config values. +// Usage example: call value.ExpandEnv(...) from integrating code. func (c *SignConfig) ExpandEnv() { c.GPG.Key = expandEnv(c.GPG.Key) c.MacOS.Identity = expandEnv(c.MacOS.Identity) @@ -76,8 +82,47 @@ func (c *SignConfig) ExpandEnv() { // expandEnv expands $VAR or ${VAR} in a string. func expandEnv(s string) string { - if strings.HasPrefix(s, "$") { - return os.ExpandEnv(s) + if !core.Contains(s, "$") { + return s } - return s + + buf := core.NewBuilder() + for i := 0; i < len(s); { + if s[i] != '$' { + buf.WriteByte(s[i]) + i++ + continue + } + + if i+1 < len(s) && s[i+1] == '{' { + j := i + 2 + for j < len(s) && s[j] != '}' { + j++ + } + if j < len(s) { + buf.WriteString(core.Env(s[i+2 : j])) + i = j + 1 + continue + } + } + + j := i + 1 + for j < len(s) { + c := s[j] + if c != '_' && (c < '0' || c > '9') && (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') { + break + } + j++ + } + if j > i+1 { + buf.WriteString(core.Env(s[i+1 : j])) + i = j + continue + } + + buf.WriteByte(s[i]) + i++ + } + + return buf.String() } diff --git a/pkg/build/signing/signing_test.go b/pkg/build/signing/signing_test.go index 93a938c..c420afd 100644 --- a/pkg/build/signing/signing_test.go +++ b/pkg/build/signing/signing_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestSignBinaries_Good_SkipsNonDarwin(t *testing.T) { +func TestSigning_SignBinariesSkipsNonDarwin_Good(t *testing.T) { ctx := context.Background() fs := io.Local cfg := SignConfig{ @@ -31,7 +31,7 @@ func TestSignBinaries_Good_SkipsNonDarwin(t *testing.T) { } } -func TestSignBinaries_Good_DisabledConfig(t *testing.T) { +func TestSigning_SignBinariesDisabledConfig_Good(t *testing.T) { ctx := context.Background() fs := io.Local cfg := SignConfig{ @@ -48,7 +48,7 @@ func TestSignBinaries_Good_DisabledConfig(t *testing.T) { } } -func TestSignBinaries_Good_SkipsOnNonMacOS(t *testing.T) { +func TestSigning_SignBinariesSkipsOnNonMacOS_Good(t *testing.T) { if runtime.GOOS == "darwin" { t.Skip("Skipping on macOS - this tests non-macOS behavior") } @@ -72,7 +72,7 @@ func TestSignBinaries_Good_SkipsOnNonMacOS(t *testing.T) { } } -func TestNotarizeBinaries_Good_DisabledConfig(t *testing.T) { +func TestSigning_NotarizeBinariesDisabledConfig_Good(t *testing.T) { ctx := context.Background() fs := io.Local cfg := SignConfig{ @@ -89,7 +89,7 @@ func TestNotarizeBinaries_Good_DisabledConfig(t *testing.T) { } } -func TestNotarizeBinaries_Good_NotarizeDisabled(t *testing.T) { +func TestSigning_NotarizeBinariesNotarizeDisabled_Good(t *testing.T) { ctx := context.Background() fs := io.Local cfg := SignConfig{ @@ -109,7 +109,7 @@ func TestNotarizeBinaries_Good_NotarizeDisabled(t *testing.T) { } } -func TestSignChecksums_Good_SkipsNoKey(t *testing.T) { +func TestSigning_SignChecksumsSkipsNoKey_Good(t *testing.T) { ctx := context.Background() fs := io.Local cfg := SignConfig{ @@ -126,7 +126,7 @@ func TestSignChecksums_Good_SkipsNoKey(t *testing.T) { } } -func TestSignChecksums_Good_Disabled(t *testing.T) { +func TestSigning_SignChecksumsDisabled_Good(t *testing.T) { ctx := context.Background() fs := io.Local cfg := SignConfig{ @@ -139,12 +139,12 @@ func TestSignChecksums_Good_Disabled(t *testing.T) { } } -func TestDefaultSignConfig(t *testing.T) { +func TestSigning_DefaultSignConfig_Good(t *testing.T) { cfg := DefaultSignConfig() assert.True(t, cfg.Enabled) } -func TestSignConfig_ExpandEnv(t *testing.T) { +func TestSigning_SignConfigExpandEnv_Good(t *testing.T) { t.Setenv("TEST_KEY", "ABC") cfg := SignConfig{ GPG: GPGConfig{Key: "$TEST_KEY"}, @@ -153,7 +153,7 @@ func TestSignConfig_ExpandEnv(t *testing.T) { assert.Equal(t, "ABC", cfg.GPG.Key) } -func TestWindowsSigner_Good(t *testing.T) { +func TestSigning_WindowsSigner_Good(t *testing.T) { fs := io.Local s := NewWindowsSigner(WindowsConfig{}) assert.Equal(t, "signtool", s.Name()) @@ -163,10 +163,10 @@ func TestWindowsSigner_Good(t *testing.T) { // mockSigner is a test double that records calls to Sign. type mockSigner struct { - name string - available bool + name string + available bool signedPaths []string - signError error + signError error } func (m *mockSigner) Name() string { @@ -185,7 +185,7 @@ func (m *mockSigner) Sign(ctx context.Context, fs io.Medium, path string) error // Verify mockSigner implements Signer var _ Signer = (*mockSigner)(nil) -func TestSignBinaries_Good_MockSigner(t *testing.T) { +func TestSigning_SignBinariesMockSigner_Good(t *testing.T) { t.Run("signs only darwin artifacts", func(t *testing.T) { artifacts := []Artifact{ {Path: "/dist/linux_amd64/myapp", OS: "linux", Arch: "amd64"}, @@ -230,7 +230,7 @@ func TestSignBinaries_Good_MockSigner(t *testing.T) { }) } -func TestSignChecksums_Good_MockSigner(t *testing.T) { +func TestSigning_SignChecksumsMockSigner_Good(t *testing.T) { t.Run("skips when GPG key is empty", func(t *testing.T) { cfg := SignConfig{ Enabled: true, @@ -252,7 +252,7 @@ func TestSignChecksums_Good_MockSigner(t *testing.T) { }) } -func TestNotarizeBinaries_Good_MockSigner(t *testing.T) { +func TestSigning_NotarizeBinariesMockSigner_Good(t *testing.T) { t.Run("skips when notarize is false", func(t *testing.T) { cfg := SignConfig{ Enabled: true, @@ -292,7 +292,7 @@ func TestNotarizeBinaries_Good_MockSigner(t *testing.T) { }) } -func TestExpandEnv_Good(t *testing.T) { +func TestSigning_ExpandEnv_Good(t *testing.T) { t.Run("expands all config fields", func(t *testing.T) { t.Setenv("TEST_GPG_KEY", "GPG123") t.Setenv("TEST_IDENTITY", "Developer ID Application: Test") diff --git a/pkg/build/signing/signtool.go b/pkg/build/signing/signtool.go index 1bff21b..1e96ece 100644 --- a/pkg/build/signing/signtool.go +++ b/pkg/build/signing/signtool.go @@ -7,6 +7,7 @@ import ( ) // WindowsSigner signs binaries using Windows signtool (placeholder). +// Usage example: declare a value of type signing.WindowsSigner in integrating code. type WindowsSigner struct { config WindowsConfig } @@ -15,21 +16,25 @@ type WindowsSigner struct { var _ Signer = (*WindowsSigner)(nil) // NewWindowsSigner creates a new Windows signer. +// Usage example: call signing.NewWindowsSigner(...) from integrating code. func NewWindowsSigner(cfg WindowsConfig) *WindowsSigner { return &WindowsSigner{config: cfg} } // Name returns "signtool". +// Usage example: call value.Name(...) from integrating code. func (s *WindowsSigner) Name() string { return "signtool" } // Available returns false (not yet implemented). +// Usage example: call value.Available(...) from integrating code. func (s *WindowsSigner) Available() bool { return false } // Sign is a placeholder that does nothing. +// Usage example: call value.Sign(...) from integrating code. func (s *WindowsSigner) Sign(ctx context.Context, fs io.Medium, binary string) error { // TODO: Implement Windows signing return nil diff --git a/pkg/release/changelog.go b/pkg/release/changelog.go index 04b07a8..8059650 100644 --- a/pkg/release/changelog.go +++ b/pkg/release/changelog.go @@ -4,18 +4,19 @@ package release import ( "bufio" "bytes" - "fmt" - "os/exec" + "context" "regexp" - "slices" - "strings" + "sort" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" coreerr "dappco.re/go/core/log" "golang.org/x/text/cases" "golang.org/x/text/language" ) // ConventionalCommit represents a parsed conventional commit. +// Usage example: declare a value of type release.ConventionalCommit in integrating code. type ConventionalCommit struct { Type string // feat, fix, etc. Scope string // optional scope in parentheses @@ -61,6 +62,7 @@ var conventionalCommitRegex = regexp.MustCompile(`^(\w+)(?:\(([^)]+)\))?(!)?:\s* // Generate generates a markdown changelog from git commits between two refs. // If fromRef is empty, it uses the previous tag or initial commit. // If toRef is empty, it uses HEAD. +// Usage example: call release.Generate(...) from integrating code. func Generate(dir, fromRef, toRef string) (string, error) { if toRef == "" { toRef = "HEAD" @@ -97,6 +99,7 @@ func Generate(dir, fromRef, toRef string) (string, error) { } // GenerateWithConfig generates a changelog with filtering based on config. +// Usage example: call release.GenerateWithConfig(...) from integrating code. func GenerateWithConfig(dir, fromRef, toRef string, cfg *ChangelogConfig) (string, error) { if toRef == "" { toRef = "HEAD" @@ -152,13 +155,11 @@ func GenerateWithConfig(dir, fromRef, toRef string, cfg *ChangelogConfig) (strin // getPreviousTag returns the tag before the given ref. func getPreviousTag(dir, ref string) (string, error) { - cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", ref+"^") - cmd.Dir = dir - output, err := cmd.Output() + output, err := ax.RunDir(context.Background(), dir, "git", "describe", "--tags", "--abbrev=0", ref+"^") if err != nil { return "", err } - return strings.TrimSpace(string(output)), nil + return core.Trim(output), nil } // getCommits returns a slice of commit strings between two refs. @@ -173,15 +174,13 @@ func getCommits(dir, fromRef, toRef string) ([]string, error) { args = []string{"log", "--oneline", "--no-merges", fromRef + ".." + toRef} } - cmd := exec.Command("git", args...) - cmd.Dir = dir - output, err := cmd.Output() + output, err := ax.RunDir(context.Background(), dir, "git", args...) if err != nil { return nil, err } var commits []string - scanner := bufio.NewScanner(bytes.NewReader(output)) + scanner := bufio.NewScanner(bytes.NewReader([]byte(output))) for scanner.Scan() { line := scanner.Text() if line != "" { @@ -196,7 +195,7 @@ func getCommits(dir, fromRef, toRef string) ([]string, error) { // Returns nil if the commit doesn't follow conventional commit format. func parseConventionalCommit(commitLine string) *ConventionalCommit { // Split hash and subject - parts := strings.SplitN(commitLine, " ", 2) + parts := core.SplitN(commitLine, " ", 2) if len(parts) != 2 { return nil } @@ -211,7 +210,7 @@ func parseConventionalCommit(commitLine string) *ConventionalCommit { } return &ConventionalCommit{ - Type: strings.ToLower(matches[1]), + Type: core.Lower(matches[1]), Scope: matches[2], Breaking: matches[3] == "!", Description: matches[4], @@ -222,7 +221,7 @@ func parseConventionalCommit(commitLine string) *ConventionalCommit { // formatChangelog formats parsed commits into markdown. func formatChangelog(commits []ConventionalCommit, version string) string { if len(commits) == 0 { - return fmt.Sprintf("## %s\n\nNo notable changes.", version) + return core.Sprintf("## %s\n\nNo notable changes.", version) } // Group commits by type @@ -236,8 +235,8 @@ func formatChangelog(commits []ConventionalCommit, version string) string { grouped[commit.Type] = append(grouped[commit.Type], commit) } - var buf strings.Builder - buf.WriteString(fmt.Sprintf("## %s\n\n", version)) + buf := core.NewBuilder() + buf.WriteString(core.Sprintf("## %s\n\n", version)) // Breaking changes first if len(breaking) > 0 { @@ -260,7 +259,7 @@ func formatChangelog(commits []ConventionalCommit, version string) string { label = cases.Title(language.English).String(commitType) } - buf.WriteString(fmt.Sprintf("### %s\n\n", label)) + buf.WriteString(core.Sprintf("### %s\n\n", label)) for _, commit := range commits { buf.WriteString(formatCommitLine(commit)) } @@ -270,46 +269,56 @@ func formatChangelog(commits []ConventionalCommit, version string) string { // Any remaining types not in the order list var remainingTypes []string for commitType := range grouped { - if !slices.Contains(commitTypeOrder, commitType) { + if !containsCommitType(commitTypeOrder, commitType) { remainingTypes = append(remainingTypes, commitType) } } - slices.Sort(remainingTypes) + sort.Strings(remainingTypes) for _, commitType := range remainingTypes { commits := grouped[commitType] label := cases.Title(language.English).String(commitType) - buf.WriteString(fmt.Sprintf("### %s\n\n", label)) + buf.WriteString(core.Sprintf("### %s\n\n", label)) for _, commit := range commits { buf.WriteString(formatCommitLine(commit)) } buf.WriteString("\n") } - return strings.TrimSuffix(buf.String(), "\n") + return core.TrimSuffix(buf.String(), "\n") } // formatCommitLine formats a single commit as a changelog line. func formatCommitLine(commit ConventionalCommit) string { - var buf strings.Builder + buf := core.NewBuilder() buf.WriteString("- ") if commit.Scope != "" { - buf.WriteString(fmt.Sprintf("**%s**: ", commit.Scope)) + buf.WriteString(core.Sprintf("**%s**: ", commit.Scope)) } buf.WriteString(commit.Description) - buf.WriteString(fmt.Sprintf(" (%s)\n", commit.Hash)) + buf.WriteString(core.Sprintf(" (%s)\n", commit.Hash)) return buf.String() } // ParseCommitType extracts the type from a conventional commit subject. // Returns empty string if not a conventional commit. +// Usage example: call release.ParseCommitType(...) from integrating code. func ParseCommitType(subject string) string { matches := conventionalCommitRegex.FindStringSubmatch(subject) if matches == nil { return "" } - return strings.ToLower(matches[1]) + return core.Lower(matches[1]) +} + +func containsCommitType(types []string, target string) bool { + for _, item := range types { + if item == target { + return true + } + } + return false } diff --git a/pkg/release/changelog_test.go b/pkg/release/changelog_test.go index ac7d4de..60f7b88 100644 --- a/pkg/release/changelog_test.go +++ b/pkg/release/changelog_test.go @@ -1,16 +1,15 @@ package release import ( - "os" - "os/exec" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestParseConventionalCommit_Good(t *testing.T) { +func TestChangelog_ParseConventionalCommit_Good(t *testing.T) { tests := []struct { name string input string @@ -108,7 +107,7 @@ func TestParseConventionalCommit_Good(t *testing.T) { } } -func TestParseConventionalCommit_Bad(t *testing.T) { +func TestChangelog_ParseConventionalCommit_Bad(t *testing.T) { tests := []struct { name string input string @@ -143,7 +142,7 @@ func TestParseConventionalCommit_Bad(t *testing.T) { } } -func TestFormatChangelog_Good(t *testing.T) { +func TestChangelog_FormatChangelog_Good(t *testing.T) { t.Run("formats commits by type", func(t *testing.T) { commits := []ConventionalCommit{ {Type: "feat", Description: "add feature A", Hash: "abc1234"}, @@ -194,7 +193,7 @@ func TestFormatChangelog_Good(t *testing.T) { }) } -func TestParseCommitType_Good(t *testing.T) { +func TestChangelog_ParseCommitType_Good(t *testing.T) { tests := []struct { input string expected string @@ -213,7 +212,7 @@ func TestParseCommitType_Good(t *testing.T) { } } -func TestParseCommitType_Bad(t *testing.T) { +func TestChangelog_ParseCommitType_Bad(t *testing.T) { tests := []struct { input string }{ @@ -230,7 +229,7 @@ func TestParseCommitType_Bad(t *testing.T) { } } -func TestGenerateWithConfig_ConfigValues(t *testing.T) { +func TestChangelog_GenerateWithConfigConfigValues_Good(t *testing.T) { t.Run("config filters are parsed correctly", func(t *testing.T) { cfg := &ChangelogConfig{ Include: []string{"feat", "fix"}, @@ -261,18 +260,11 @@ func setupChangelogGitRepo(t *testing.T) string { dir := t.TempDir() // Initialize git repo - cmd := exec.Command("git", "init") - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "init") // Configure git user for commits - cmd = exec.Command("git", "config", "user.email", "test@example.com") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "config", "user.name", "Test User") - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test User") return dir } @@ -282,30 +274,23 @@ func createChangelogCommit(t *testing.T, dir, message string) { t.Helper() // Create or modify a file - filePath := filepath.Join(dir, "changelog_test.txt") - content, _ := os.ReadFile(filePath) + filePath := ax.Join(dir, "changelog_test.txt") + content, _ := ax.ReadFile(filePath) content = append(content, []byte(message+"\n")...) - require.NoError(t, os.WriteFile(filePath, content, 0644)) + require.NoError(t, ax.WriteFile(filePath, content, 0644)) // Stage and commit - cmd := exec.Command("git", "add", ".") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "commit", "-m", message) - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", message) } // createChangelogTag creates a tag in the given directory. func createChangelogTag(t *testing.T, dir, tag string) { t.Helper() - cmd := exec.Command("git", "tag", tag) - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "tag", tag) } -func TestGenerate_Good(t *testing.T) { +func TestChangelog_Generate_Good(t *testing.T) { t.Run("generates changelog from commits", func(t *testing.T) { dir := setupChangelogGitRepo(t) createChangelogCommit(t, dir, "feat: add new feature") @@ -386,7 +371,7 @@ func TestGenerate_Good(t *testing.T) { }) } -func TestGenerate_Bad(t *testing.T) { +func TestChangelog_Generate_Bad(t *testing.T) { t.Run("returns error for non-git directory", func(t *testing.T) { dir := t.TempDir() @@ -395,7 +380,7 @@ func TestGenerate_Bad(t *testing.T) { }) } -func TestGenerateWithConfig_Good(t *testing.T) { +func TestChangelog_GenerateWithConfig_Good(t *testing.T) { t.Run("filters commits by include list", func(t *testing.T) { dir := setupChangelogGitRepo(t) createChangelogCommit(t, dir, "feat: new feature") @@ -452,7 +437,7 @@ func TestGenerateWithConfig_Good(t *testing.T) { }) } -func TestGetCommits_Good(t *testing.T) { +func TestChangelog_GetCommits_Good(t *testing.T) { t.Run("returns all commits when fromRef is empty", func(t *testing.T) { dir := setupChangelogGitRepo(t) createChangelogCommit(t, dir, "feat: first") @@ -503,7 +488,7 @@ func TestGetCommits_Good(t *testing.T) { }) } -func TestGetCommits_Bad(t *testing.T) { +func TestChangelog_GetCommits_Bad(t *testing.T) { t.Run("returns error for invalid ref", func(t *testing.T) { dir := setupChangelogGitRepo(t) createChangelogCommit(t, dir, "feat: commit") @@ -520,7 +505,7 @@ func TestGetCommits_Bad(t *testing.T) { }) } -func TestGetPreviousTag_Good(t *testing.T) { +func TestChangelog_GetPreviousTag_Good(t *testing.T) { t.Run("returns previous tag", func(t *testing.T) { dir := setupChangelogGitRepo(t) createChangelogCommit(t, dir, "feat: first") @@ -545,7 +530,7 @@ func TestGetPreviousTag_Good(t *testing.T) { }) } -func TestGetPreviousTag_Bad(t *testing.T) { +func TestChangelog_GetPreviousTag_Bad(t *testing.T) { t.Run("returns error when no previous tag exists", func(t *testing.T) { dir := setupChangelogGitRepo(t) createChangelogCommit(t, dir, "feat: first") @@ -565,7 +550,7 @@ func TestGetPreviousTag_Bad(t *testing.T) { }) } -func TestFormatCommitLine_Good(t *testing.T) { +func TestChangelog_FormatCommitLine_Good(t *testing.T) { t.Run("formats commit without scope", func(t *testing.T) { commit := ConventionalCommit{ Type: "feat", @@ -590,7 +575,7 @@ func TestFormatCommitLine_Good(t *testing.T) { }) } -func TestFormatChangelog_Ugly(t *testing.T) { +func TestChangelog_FormatChangelog_Ugly(t *testing.T) { t.Run("handles custom commit type not in order", func(t *testing.T) { commits := []ConventionalCommit{ {Type: "custom", Description: "custom type", Hash: "abc1234"}, @@ -616,7 +601,7 @@ func TestFormatChangelog_Ugly(t *testing.T) { }) } -func TestGenerateWithConfig_Bad(t *testing.T) { +func TestChangelog_GenerateWithConfig_Bad(t *testing.T) { t.Run("returns error for non-git directory", func(t *testing.T) { dir := t.TempDir() cfg := &ChangelogConfig{ @@ -628,7 +613,7 @@ func TestGenerateWithConfig_Bad(t *testing.T) { }) } -func TestGenerateWithConfig_EdgeCases(t *testing.T) { +func TestChangelog_GenerateWithConfigEdgeCases_Ugly(t *testing.T) { t.Run("uses HEAD when toRef is empty", func(t *testing.T) { dir := setupChangelogGitRepo(t) createChangelogCommit(t, dir, "feat: new feature") diff --git a/pkg/release/config.go b/pkg/release/config.go index 8d48d5b..b5fabde 100644 --- a/pkg/release/config.go +++ b/pkg/release/config.go @@ -3,21 +3,23 @@ package release import ( "iter" - "os" - "path/filepath" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" "gopkg.in/yaml.v3" ) // ConfigFileName is the name of the release configuration file. +// Usage example: reference release.ConfigFileName from package consumers. const ConfigFileName = "release.yaml" // ConfigDir is the directory where release configuration is stored. +// Usage example: reference release.ConfigDir from package consumers. const ConfigDir = ".core" // Config holds the complete release configuration loaded from .core/release.yaml. +// Usage example: declare a value of type release.Config in integrating code. type Config struct { // Version is the config file format version. Version int `yaml:"version"` @@ -38,6 +40,7 @@ type Config struct { } // ProjectConfig holds project metadata for releases. +// Usage example: declare a value of type release.ProjectConfig in integrating code. type ProjectConfig struct { // Name is the project name. Name string `yaml:"name"` @@ -46,12 +49,14 @@ type ProjectConfig struct { } // BuildConfig holds build settings for releases. +// Usage example: declare a value of type release.BuildConfig in integrating code. type BuildConfig struct { // Targets defines the build targets. Targets []TargetConfig `yaml:"targets"` } // TargetConfig defines a build target. +// Usage example: declare a value of type release.TargetConfig in integrating code. type TargetConfig struct { // OS is the target operating system (e.g., "linux", "darwin", "windows"). OS string `yaml:"os"` @@ -60,6 +65,7 @@ type TargetConfig struct { } // PublisherConfig holds configuration for a publisher. +// Usage example: declare a value of type release.PublisherConfig in integrating code. type PublisherConfig struct { // Type is the publisher type (e.g., "github", "linuxkit", "docker"). Type string `yaml:"type"` @@ -118,6 +124,7 @@ type PublisherConfig struct { } // OfficialConfig holds configuration for generating files for official repo PRs. +// Usage example: declare a value of type release.OfficialConfig in integrating code. type OfficialConfig struct { // Enabled determines whether to generate files for official repos. Enabled bool `yaml:"enabled"` @@ -126,6 +133,7 @@ type OfficialConfig struct { } // SDKConfig holds SDK generation configuration. +// Usage example: declare a value of type release.SDKConfig in integrating code. type SDKConfig struct { // Spec is the path to the OpenAPI spec file. Spec string `yaml:"spec,omitempty"` @@ -142,24 +150,28 @@ type SDKConfig struct { } // SDKPackageConfig holds package naming configuration. +// Usage example: declare a value of type release.SDKPackageConfig in integrating code. type SDKPackageConfig struct { Name string `yaml:"name,omitempty"` Version string `yaml:"version,omitempty"` } // SDKDiffConfig holds diff configuration. +// Usage example: declare a value of type release.SDKDiffConfig in integrating code. type SDKDiffConfig struct { Enabled bool `yaml:"enabled,omitempty"` FailOnBreaking bool `yaml:"fail_on_breaking,omitempty"` } // SDKPublishConfig holds monorepo publish configuration. +// Usage example: declare a value of type release.SDKPublishConfig in integrating code. type SDKPublishConfig struct { Repo string `yaml:"repo,omitempty"` Path string `yaml:"path,omitempty"` } // ChangelogConfig holds changelog generation settings. +// Usage example: declare a value of type release.ChangelogConfig in integrating code. type ChangelogConfig struct { // Include specifies commit types to include in the changelog. Include []string `yaml:"include"` @@ -168,6 +180,7 @@ type ChangelogConfig struct { } // PublishersIter returns an iterator for the publishers. +// Usage example: call value.PublishersIter(...) from integrating code. func (c *Config) PublishersIter() iter.Seq[PublisherConfig] { return func(yield func(PublisherConfig) bool) { for _, p := range c.Publishers { @@ -181,18 +194,19 @@ func (c *Config) PublishersIter() iter.Seq[PublisherConfig] { // LoadConfig loads release configuration from the .core/release.yaml file in the given directory. // If the config file does not exist, it returns DefaultConfig(). // Returns an error if the file exists but cannot be parsed. +// Usage example: call release.LoadConfig(...) from integrating code. func LoadConfig(dir string) (*Config, error) { - configPath := filepath.Join(dir, ConfigDir, ConfigFileName) + configPath := ax.Join(dir, ConfigDir, ConfigFileName) // Convert to absolute path for io.Local - absPath, err := filepath.Abs(configPath) + absPath, err := ax.Abs(configPath) if err != nil { return nil, coreerr.E("release.LoadConfig", "failed to resolve path", err) } content, err := io.Local.Read(absPath) if err != nil { - if os.IsNotExist(err) { + if !io.Local.IsFile(absPath) { cfg := DefaultConfig() cfg.projectDir = dir return cfg, nil @@ -213,6 +227,7 @@ func LoadConfig(dir string) (*Config, error) { } // DefaultConfig returns sensible defaults for release configuration. +// Usage example: call release.DefaultConfig(...) from integrating code. func DefaultConfig() *Config { return &Config{ Version: 1, @@ -265,24 +280,28 @@ func applyDefaults(cfg *Config) { } // SetProjectDir sets the project directory on the config. +// Usage example: call value.SetProjectDir(...) from integrating code. func (c *Config) SetProjectDir(dir string) { c.projectDir = dir } // SetVersion sets the version override on the config. +// Usage example: call value.SetVersion(...) from integrating code. func (c *Config) SetVersion(version string) { c.version = version } // ConfigPath returns the path to the release config file for a given directory. +// Usage example: call release.ConfigPath(...) from integrating code. func ConfigPath(dir string) string { - return filepath.Join(dir, ConfigDir, ConfigFileName) + return ax.Join(dir, ConfigDir, ConfigFileName) } // ConfigExists checks if a release config file exists in the given directory. +// Usage example: call release.ConfigExists(...) from integrating code. func ConfigExists(dir string) bool { configPath := ConfigPath(dir) - absPath, err := filepath.Abs(configPath) + absPath, err := ax.Abs(configPath) if err != nil { return false } @@ -290,27 +309,30 @@ func ConfigExists(dir string) bool { } // GetRepository returns the repository from the config. +// Usage example: call value.GetRepository(...) from integrating code. func (c *Config) GetRepository() string { return c.Project.Repository } // GetProjectName returns the project name from the config. +// Usage example: call value.GetProjectName(...) from integrating code. func (c *Config) GetProjectName() string { return c.Project.Name } // WriteConfig writes the config to the .core/release.yaml file. +// Usage example: call release.WriteConfig(...) from integrating code. func WriteConfig(cfg *Config, dir string) error { configPath := ConfigPath(dir) // Convert to absolute path for io.Local - absPath, err := filepath.Abs(configPath) + absPath, err := ax.Abs(configPath) if err != nil { return coreerr.E("release.WriteConfig", "failed to resolve path", err) } // Ensure directory exists - configDir := filepath.Dir(absPath) + configDir := ax.Dir(absPath) if err := io.Local.EnsureDir(configDir); err != nil { return coreerr.E("release.WriteConfig", "failed to create directory", err) } diff --git a/pkg/release/config_test.go b/pkg/release/config_test.go index 44f65c0..e1a9bad 100644 --- a/pkg/release/config_test.go +++ b/pkg/release/config_test.go @@ -1,10 +1,9 @@ package release import ( - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -15,19 +14,19 @@ func setupConfigTestDir(t *testing.T, configContent string) string { dir := t.TempDir() if configContent != "" { - coreDir := filepath.Join(dir, ConfigDir) - err := os.MkdirAll(coreDir, 0755) + coreDir := ax.Join(dir, ConfigDir) + err := ax.MkdirAll(coreDir, 0755) require.NoError(t, err) - configPath := filepath.Join(coreDir, ConfigFileName) - err = os.WriteFile(configPath, []byte(configContent), 0644) + configPath := ax.Join(coreDir, ConfigFileName) + err = ax.WriteFile(configPath, []byte(configContent), 0644) require.NoError(t, err) } return dir } -func TestLoadConfig_Good(t *testing.T) { +func TestConfig_LoadConfig_Good(t *testing.T) { t.Run("loads valid config", func(t *testing.T) { content := ` version: 1 @@ -119,7 +118,7 @@ project: }) } -func TestLoadConfig_Bad(t *testing.T) { +func TestConfig_LoadConfig_Bad(t *testing.T) { t.Run("returns error for invalid YAML", func(t *testing.T) { content := ` version: 1 @@ -134,25 +133,26 @@ project: assert.Contains(t, err.Error(), "failed to parse config file") }) - t.Run("returns error for unreadable file", func(t *testing.T) { + t.Run("returns default config when config path is a directory", func(t *testing.T) { dir := t.TempDir() - coreDir := filepath.Join(dir, ConfigDir) - err := os.MkdirAll(coreDir, 0755) + coreDir := ax.Join(dir, ConfigDir) + err := ax.MkdirAll(coreDir, 0755) require.NoError(t, err) // Create config as a directory instead of file - configPath := filepath.Join(coreDir, ConfigFileName) - err = os.Mkdir(configPath, 0755) + configPath := ax.Join(coreDir, ConfigFileName) + err = ax.Mkdir(configPath, 0755) require.NoError(t, err) cfg, err := LoadConfig(dir) - assert.Error(t, err) - assert.Nil(t, cfg) - assert.Contains(t, err.Error(), "failed to read config file") + assert.NoError(t, err) + require.NotNil(t, cfg) + assert.Equal(t, 1, cfg.Version) + assert.Equal(t, dir, cfg.projectDir) }) } -func TestDefaultConfig_Good(t *testing.T) { +func TestConfig_DefaultConfig_Good(t *testing.T) { t.Run("returns sensible defaults", func(t *testing.T) { cfg := DefaultConfig() @@ -194,14 +194,14 @@ func TestDefaultConfig_Good(t *testing.T) { }) } -func TestConfigPath_Good(t *testing.T) { +func TestConfig_ConfigPath_Good(t *testing.T) { t.Run("returns correct path", func(t *testing.T) { path := ConfigPath("/project/root") assert.Equal(t, "/project/root/.core/release.yaml", path) }) } -func TestConfigExists_Good(t *testing.T) { +func TestConfig_ConfigExists_Good(t *testing.T) { t.Run("returns true when config exists", func(t *testing.T) { dir := setupConfigTestDir(t, "version: 1") assert.True(t, ConfigExists(dir)) @@ -218,7 +218,7 @@ func TestConfigExists_Good(t *testing.T) { }) } -func TestWriteConfig_Good(t *testing.T) { +func TestConfig_WriteConfig_Good(t *testing.T) { t.Run("writes config to file", func(t *testing.T) { dir := t.TempDir() @@ -247,8 +247,8 @@ func TestWriteConfig_Good(t *testing.T) { require.NoError(t, err) // Check directory was created - coreDir := filepath.Join(dir, ConfigDir) - info, err := os.Stat(coreDir) + coreDir := ax.Join(dir, ConfigDir) + info, err := ax.Stat(coreDir) require.NoError(t, err) assert.True(t, info.IsDir()) }) @@ -302,22 +302,22 @@ func TestConfig_SetProjectDir_Good(t *testing.T) { }) } -func TestWriteConfig_Bad(t *testing.T) { +func TestConfig_WriteConfig_Bad(t *testing.T) { t.Run("returns error for unwritable directory", func(t *testing.T) { - if os.Geteuid() == 0 { + if ax.Geteuid() == 0 { t.Skip("root can write to any directory") } dir := t.TempDir() // Create .core directory and make it unwritable - coreDir := filepath.Join(dir, ConfigDir) - err := os.MkdirAll(coreDir, 0755) + coreDir := ax.Join(dir, ConfigDir) + err := ax.MkdirAll(coreDir, 0755) require.NoError(t, err) // Make directory read-only - err = os.Chmod(coreDir, 0555) + err = ax.Chmod(coreDir, 0555) require.NoError(t, err) - defer func() { _ = os.Chmod(coreDir, 0755) }() + defer func() { _ = ax.Chmod(coreDir, 0755) }() cfg := DefaultConfig() err = WriteConfig(cfg, dir) @@ -326,7 +326,7 @@ func TestWriteConfig_Bad(t *testing.T) { }) t.Run("returns error when directory creation fails", func(t *testing.T) { - if os.Geteuid() == 0 { + if ax.Geteuid() == 0 { t.Skip("root can create directories anywhere") } // Use a path that doesn't exist and can't be created @@ -336,7 +336,7 @@ func TestWriteConfig_Bad(t *testing.T) { }) } -func TestApplyDefaults_Good(t *testing.T) { +func TestConfig_ApplyDefaults_Good(t *testing.T) { t.Run("applies version default when zero", func(t *testing.T) { cfg := &Config{Version: 0} applyDefaults(cfg) diff --git a/pkg/release/publishers/aur.go b/pkg/release/publishers/aur.go index ee039f3..1e62758 100644 --- a/pkg/release/publishers/aur.go +++ b/pkg/release/publishers/aur.go @@ -5,13 +5,10 @@ import ( "bytes" "context" "embed" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" "text/template" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" @@ -21,6 +18,7 @@ import ( var aurTemplates embed.FS // AURConfig holds AUR-specific configuration. +// Usage example: declare a value of type publishers.AURConfig in integrating code. type AURConfig struct { // Package is the AUR package name. Package string @@ -31,19 +29,23 @@ type AURConfig struct { } // AURPublisher publishes releases to AUR. +// Usage example: declare a value of type publishers.AURPublisher in integrating code. type AURPublisher struct{} // NewAURPublisher creates a new AUR publisher. +// Usage example: call publishers.NewAURPublisher(...) from integrating code. func NewAURPublisher() *AURPublisher { return &AURPublisher{} } // Name returns the publisher's identifier. +// Usage example: call value.Name(...) from integrating code. func (p *AURPublisher) Name() string { return "aur" } // Publish publishes the release to AUR. +// Usage example: call value.Publish(...) from integrating code. func (p *AURPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { cfg := p.parseConfig(pubCfg, relCfg) @@ -68,7 +70,7 @@ func (p *AURPublisher) Publish(ctx context.Context, release *Release, pubCfg Pub projectName = relCfg.GetProjectName() } if projectName == "" { - parts := strings.Split(repo, "/") + parts := core.Split(repo, "/") projectName = parts[len(parts)-1] } @@ -77,12 +79,12 @@ func (p *AURPublisher) Publish(ctx context.Context, release *Release, pubCfg Pub packageName = projectName } - version := strings.TrimPrefix(release.Version, "v") + version := core.TrimPrefix(release.Version, "v") checksums := buildChecksumMap(release.Artifacts) data := aurTemplateData{ PackageName: packageName, - Description: fmt.Sprintf("%s CLI", projectName), + Description: core.Sprintf("%s CLI", projectName), Repository: repo, Version: version, License: "MIT", @@ -134,38 +136,38 @@ func (p *AURPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfig) } func (p *AURPublisher) dryRunPublish(m coreio.Medium, data aurTemplateData, cfg AURConfig) error { - fmt.Println() - fmt.Println("=== DRY RUN: AUR Publish ===") - fmt.Println() - fmt.Printf("Package: %s-bin\n", data.PackageName) - fmt.Printf("Version: %s\n", data.Version) - fmt.Printf("Maintainer: %s\n", data.Maintainer) - fmt.Printf("Repository: %s\n", data.Repository) - fmt.Println() + publisherPrintln() + publisherPrintln("=== DRY RUN: AUR Publish ===") + publisherPrintln() + publisherPrint("Package: %s-bin", data.PackageName) + publisherPrint("Version: %s", data.Version) + publisherPrint("Maintainer: %s", data.Maintainer) + publisherPrint("Repository: %s", data.Repository) + publisherPrintln() pkgbuild, err := p.renderTemplate(m, "templates/aur/PKGBUILD.tmpl", data) if err != nil { return coreerr.E("aur.dryRunPublish", "failed to render PKGBUILD template", err) } - fmt.Println("Generated PKGBUILD:") - fmt.Println("---") - fmt.Println(pkgbuild) - fmt.Println("---") - fmt.Println() + publisherPrintln("Generated PKGBUILD:") + publisherPrintln("---") + publisherPrintln(pkgbuild) + publisherPrintln("---") + publisherPrintln() srcinfo, err := p.renderTemplate(m, "templates/aur/.SRCINFO.tmpl", data) if err != nil { return coreerr.E("aur.dryRunPublish", "failed to render .SRCINFO template", err) } - fmt.Println("Generated .SRCINFO:") - fmt.Println("---") - fmt.Println(srcinfo) - fmt.Println("---") - fmt.Println() + publisherPrintln("Generated .SRCINFO:") + publisherPrintln("---") + publisherPrintln(srcinfo) + publisherPrintln("---") + publisherPrintln() - fmt.Printf("Would push to AUR: ssh://aur@aur.archlinux.org/%s-bin.git\n", data.PackageName) - fmt.Println() - fmt.Println("=== END DRY RUN ===") + publisherPrint("Would push to AUR: ssh://aur@aur.archlinux.org/%s-bin.git", data.PackageName) + publisherPrintln() + publisherPrintln("=== END DRY RUN ===") return nil } @@ -185,25 +187,25 @@ func (p *AURPublisher) executePublish(ctx context.Context, projectDir string, da if cfg.Official != nil && cfg.Official.Enabled { output := cfg.Official.Output if output == "" { - output = filepath.Join(projectDir, "dist", "aur") - } else if !filepath.IsAbs(output) { - output = filepath.Join(projectDir, output) + output = ax.Join(projectDir, "dist", "aur") + } else if !ax.IsAbs(output) { + output = ax.Join(projectDir, output) } if err := release.FS.EnsureDir(output); err != nil { return coreerr.E("aur.Publish", "failed to create output directory", err) } - pkgbuildPath := filepath.Join(output, "PKGBUILD") + pkgbuildPath := ax.Join(output, "PKGBUILD") if err := release.FS.Write(pkgbuildPath, pkgbuild); err != nil { return coreerr.E("aur.Publish", "failed to write PKGBUILD", err) } - srcinfoPath := filepath.Join(output, ".SRCINFO") + srcinfoPath := ax.Join(output, ".SRCINFO") if err := release.FS.Write(srcinfoPath, srcinfo); err != nil { return coreerr.E("aur.Publish", "failed to write .SRCINFO", err) } - fmt.Printf("Wrote AUR files: %s\n", output) + publisherPrint("Wrote AUR files: %s", output) } // Push to AUR if not in official-only mode @@ -217,62 +219,49 @@ func (p *AURPublisher) executePublish(ctx context.Context, projectDir string, da } func (p *AURPublisher) pushToAUR(ctx context.Context, data aurTemplateData, pkgbuild, srcinfo string) error { - aurURL := fmt.Sprintf("ssh://aur@aur.archlinux.org/%s-bin.git", data.PackageName) + aurURL := core.Sprintf("ssh://aur@aur.archlinux.org/%s-bin.git", data.PackageName) - tmpDir, err := os.MkdirTemp("", "aur-package-*") + tmpDir, err := ax.TempDir("aur-package-*") if err != nil { return coreerr.E("aur.pushToAUR", "failed to create temp directory", err) } - defer func() { _ = coreio.Local.DeleteAll(tmpDir) }() + defer func() { _ = ax.RemoveAll(tmpDir) }() // Clone existing AUR repo (or initialise new one) - fmt.Printf("Cloning AUR package %s-bin...\n", data.PackageName) - cmd := exec.CommandContext(ctx, "git", "clone", aurURL, tmpDir) - if err := cmd.Run(); err != nil { + publisherPrint("Cloning AUR package %s-bin...", data.PackageName) + if err := ax.Exec(ctx, "git", "clone", aurURL, tmpDir); err != nil { // If clone fails, init a new repo - cmd = exec.CommandContext(ctx, "git", "init", tmpDir) - if err := cmd.Run(); err != nil { + if err := ax.Exec(ctx, "git", "init", tmpDir); err != nil { return coreerr.E("aur.pushToAUR", "failed to initialise repo", err) } - cmd = exec.CommandContext(ctx, "git", "-C", tmpDir, "remote", "add", "origin", aurURL) - if err := cmd.Run(); err != nil { + if err := ax.Exec(ctx, "git", "-C", tmpDir, "remote", "add", "origin", aurURL); err != nil { return coreerr.E("aur.pushToAUR", "failed to add remote", err) } } // Write files - if err := coreio.Local.Write(filepath.Join(tmpDir, "PKGBUILD"), pkgbuild); err != nil { + if err := coreio.Local.Write(ax.Join(tmpDir, "PKGBUILD"), pkgbuild); err != nil { return coreerr.E("aur.pushToAUR", "failed to write PKGBUILD", err) } - if err := coreio.Local.Write(filepath.Join(tmpDir, ".SRCINFO"), srcinfo); err != nil { + if err := coreio.Local.Write(ax.Join(tmpDir, ".SRCINFO"), srcinfo); err != nil { return coreerr.E("aur.pushToAUR", "failed to write .SRCINFO", err) } - commitMsg := fmt.Sprintf("Update to %s", data.Version) + commitMsg := core.Sprintf("Update to %s", data.Version) - cmd = exec.CommandContext(ctx, "git", "add", ".") - cmd.Dir = tmpDir - if err := cmd.Run(); err != nil { + if err := ax.ExecDir(ctx, tmpDir, "git", "add", "."); err != nil { return coreerr.E("aur.pushToAUR", "git add failed", err) } - cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg) - cmd.Dir = tmpDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, tmpDir, nil, "git", "commit", "-m", commitMsg); err != nil { return coreerr.E("aur.pushToAUR", "git commit failed", err) } - cmd = exec.CommandContext(ctx, "git", "push", "origin", "master") - cmd.Dir = tmpDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, tmpDir, nil, "git", "push", "origin", "master"); err != nil { return coreerr.E("aur.pushToAUR", "git push failed", err) } - fmt.Printf("Published to AUR: https://aur.archlinux.org/packages/%s-bin\n", data.PackageName) + publisherPrint("Published to AUR: https://aur.archlinux.org/packages/%s-bin", data.PackageName) return nil } @@ -281,7 +270,7 @@ func (p *AURPublisher) renderTemplate(m coreio.Medium, name string, data aurTemp var err error // Try custom template from medium - customPath := filepath.Join(".core", name) + customPath := ax.Join(".core", name) if m != nil && m.IsFile(customPath) { customContent, err := m.Read(customPath) if err == nil { @@ -297,7 +286,7 @@ func (p *AURPublisher) renderTemplate(m coreio.Medium, name string, data aurTemp } } - tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + tmpl, err := template.New(ax.Base(name)).Parse(string(content)) if err != nil { return "", coreerr.E("aur.renderTemplate", "failed to parse template "+name, err) } diff --git a/pkg/release/publishers/aur_test.go b/pkg/release/publishers/aur_test.go index 0402963..06b365b 100644 --- a/pkg/release/publishers/aur_test.go +++ b/pkg/release/publishers/aur_test.go @@ -1,9 +1,7 @@ package publishers import ( - "bytes" "context" - "os" "testing" "dappco.re/go/core/io" @@ -11,14 +9,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestAURPublisher_Name_Good(t *testing.T) { +func TestAUR_AURPublisherName_Good(t *testing.T) { t.Run("returns aur", func(t *testing.T) { p := NewAURPublisher() assert.Equal(t, "aur", p.Name()) }) } -func TestAURPublisher_ParseConfig_Good(t *testing.T) { +func TestAUR_AURPublisherParseConfig_Good(t *testing.T) { p := NewAURPublisher() t.Run("uses defaults when no extended config", func(t *testing.T) { @@ -80,7 +78,7 @@ func TestAURPublisher_ParseConfig_Good(t *testing.T) { }) } -func TestAURPublisher_RenderTemplate_Good(t *testing.T) { +func TestAUR_AURPublisherRenderTemplate_Good(t *testing.T) { p := NewAURPublisher() t.Run("renders PKGBUILD template with data", func(t *testing.T) { @@ -140,7 +138,7 @@ func TestAURPublisher_RenderTemplate_Good(t *testing.T) { }) } -func TestAURPublisher_RenderTemplate_Bad(t *testing.T) { +func TestAUR_AURPublisherRenderTemplate_Bad(t *testing.T) { p := NewAURPublisher() t.Run("returns error for non-existent template", func(t *testing.T) { @@ -151,14 +149,10 @@ func TestAURPublisher_RenderTemplate_Bad(t *testing.T) { }) } -func TestAURPublisher_DryRunPublish_Good(t *testing.T) { +func TestAUR_AURPublisherDryRunPublish_Good(t *testing.T) { p := NewAURPublisher() t.Run("outputs expected dry run information", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := aurTemplateData{ PackageName: "myapp", Version: "1.0.0", @@ -171,15 +165,11 @@ func TestAURPublisher_DryRunPublish_Good(t *testing.T) { Maintainer: "John Doe ", } - err := p.dryRunPublish(io.Local, data, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: AUR Publish") assert.Contains(t, output, "Package: myapp-bin") @@ -193,7 +183,7 @@ func TestAURPublisher_DryRunPublish_Good(t *testing.T) { }) } -func TestAURPublisher_Publish_Bad(t *testing.T) { +func TestAUR_AURPublisherPublish_Bad(t *testing.T) { p := NewAURPublisher() t.Run("fails when maintainer not configured", func(t *testing.T) { @@ -211,7 +201,7 @@ func TestAURPublisher_Publish_Bad(t *testing.T) { }) } -func TestAURConfig_Defaults_Good(t *testing.T) { +func TestAUR_AURConfigDefaults_Good(t *testing.T) { t.Run("has sensible defaults", func(t *testing.T) { p := NewAURPublisher() pubCfg := PublisherConfig{Type: "aur"} diff --git a/pkg/release/publishers/chocolatey.go b/pkg/release/publishers/chocolatey.go index 6fd9f4f..46d19fd 100644 --- a/pkg/release/publishers/chocolatey.go +++ b/pkg/release/publishers/chocolatey.go @@ -5,13 +5,10 @@ import ( "bytes" "context" "embed" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" "text/template" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/i18n" "dappco.re/go/core/io" @@ -22,6 +19,7 @@ import ( var chocolateyTemplates embed.FS // ChocolateyConfig holds Chocolatey-specific configuration. +// Usage example: declare a value of type publishers.ChocolateyConfig in integrating code. type ChocolateyConfig struct { // Package is the Chocolatey package name. Package string @@ -32,19 +30,23 @@ type ChocolateyConfig struct { } // ChocolateyPublisher publishes releases to Chocolatey. +// Usage example: declare a value of type publishers.ChocolateyPublisher in integrating code. type ChocolateyPublisher struct{} // NewChocolateyPublisher creates a new Chocolatey publisher. +// Usage example: call publishers.NewChocolateyPublisher(...) from integrating code. func NewChocolateyPublisher() *ChocolateyPublisher { return &ChocolateyPublisher{} } // Name returns the publisher's identifier. +// Usage example: call value.Name(...) from integrating code. func (p *ChocolateyPublisher) Name() string { return "chocolatey" } // Publish publishes the release to Chocolatey. +// Usage example: call value.Publish(...) from integrating code. func (p *ChocolateyPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { cfg := p.parseConfig(pubCfg, relCfg) @@ -65,7 +67,7 @@ func (p *ChocolateyPublisher) Publish(ctx context.Context, release *Release, pub projectName = relCfg.GetProjectName() } if projectName == "" { - parts := strings.Split(repo, "/") + parts := core.Split(repo, "/") projectName = parts[len(parts)-1] } @@ -74,22 +76,22 @@ func (p *ChocolateyPublisher) Publish(ctx context.Context, release *Release, pub packageName = projectName } - version := strings.TrimPrefix(release.Version, "v") + version := core.TrimPrefix(release.Version, "v") checksums := buildChecksumMap(release.Artifacts) // Extract authors from repository - authors := strings.Split(repo, "/")[0] + authors := core.Split(repo, "/")[0] data := chocolateyTemplateData{ PackageName: packageName, - Title: fmt.Sprintf("%s CLI", i18n.Title(projectName)), - Description: fmt.Sprintf("%s CLI", projectName), + Title: core.Sprintf("%s CLI", i18n.Title(projectName)), + Description: core.Sprintf("%s CLI", projectName), Repository: repo, Version: version, License: "MIT", BinaryName: projectName, Authors: authors, - Tags: fmt.Sprintf("cli %s", projectName), + Tags: core.Sprintf("cli %s", projectName), Checksums: checksums, } @@ -140,42 +142,42 @@ func (p *ChocolateyPublisher) parseConfig(pubCfg PublisherConfig, relCfg Release } func (p *ChocolateyPublisher) dryRunPublish(m io.Medium, data chocolateyTemplateData, cfg ChocolateyConfig) error { - fmt.Println() - fmt.Println("=== DRY RUN: Chocolatey Publish ===") - fmt.Println() - fmt.Printf("Package: %s\n", data.PackageName) - fmt.Printf("Version: %s\n", data.Version) - fmt.Printf("Push: %t\n", cfg.Push) - fmt.Printf("Repository: %s\n", data.Repository) - fmt.Println() + publisherPrintln() + publisherPrintln("=== DRY RUN: Chocolatey Publish ===") + publisherPrintln() + publisherPrint("Package: %s", data.PackageName) + publisherPrint("Version: %s", data.Version) + publisherPrint("Push: %t", cfg.Push) + publisherPrint("Repository: %s", data.Repository) + publisherPrintln() nuspec, err := p.renderTemplate(m, "templates/chocolatey/package.nuspec.tmpl", data) if err != nil { return coreerr.E("chocolatey.dryRunPublish", "failed to render nuspec", err) } - fmt.Println("Generated package.nuspec:") - fmt.Println("---") - fmt.Println(nuspec) - fmt.Println("---") - fmt.Println() + publisherPrintln("Generated package.nuspec:") + publisherPrintln("---") + publisherPrintln(nuspec) + publisherPrintln("---") + publisherPrintln() install, err := p.renderTemplate(m, "templates/chocolatey/tools/chocolateyinstall.ps1.tmpl", data) if err != nil { return coreerr.E("chocolatey.dryRunPublish", "failed to render install script", err) } - fmt.Println("Generated chocolateyinstall.ps1:") - fmt.Println("---") - fmt.Println(install) - fmt.Println("---") - fmt.Println() + publisherPrintln("Generated chocolateyinstall.ps1:") + publisherPrintln("---") + publisherPrintln(install) + publisherPrintln("---") + publisherPrintln() if cfg.Push { - fmt.Println("Would push to Chocolatey community repo") + publisherPrintln("Would push to Chocolatey community repo") } else { - fmt.Println("Would generate package files only (push=false)") + publisherPrintln("Would generate package files only (push=false)") } - fmt.Println() - fmt.Println("=== END DRY RUN ===") + publisherPrintln() + publisherPrintln("=== END DRY RUN ===") return nil } @@ -192,31 +194,31 @@ func (p *ChocolateyPublisher) executePublish(ctx context.Context, projectDir str } // Create package directory - output := filepath.Join(projectDir, "dist", "chocolatey") + output := ax.Join(projectDir, "dist", "chocolatey") if cfg.Official != nil && cfg.Official.Enabled && cfg.Official.Output != "" { output = cfg.Official.Output - if !filepath.IsAbs(output) { - output = filepath.Join(projectDir, output) + if !ax.IsAbs(output) { + output = ax.Join(projectDir, output) } } - toolsDir := filepath.Join(output, "tools") + toolsDir := ax.Join(output, "tools") if err := release.FS.EnsureDir(toolsDir); err != nil { return coreerr.E("chocolatey.Publish", "failed to create output directory", err) } // Write files - nuspecPath := filepath.Join(output, fmt.Sprintf("%s.nuspec", data.PackageName)) + nuspecPath := ax.Join(output, core.Sprintf("%s.nuspec", data.PackageName)) if err := release.FS.Write(nuspecPath, nuspec); err != nil { return coreerr.E("chocolatey.Publish", "failed to write nuspec", err) } - installPath := filepath.Join(toolsDir, "chocolateyinstall.ps1") + installPath := ax.Join(toolsDir, "chocolateyinstall.ps1") if err := release.FS.Write(installPath, install); err != nil { return coreerr.E("chocolatey.Publish", "failed to write install script", err) } - fmt.Printf("Wrote Chocolatey package files: %s\n", output) + publisherPrint("Wrote Chocolatey package files: %s", output) // Push to Chocolatey if configured if cfg.Push { @@ -230,31 +232,24 @@ func (p *ChocolateyPublisher) executePublish(ctx context.Context, projectDir str func (p *ChocolateyPublisher) pushToChocolatey(ctx context.Context, packageDir string, data chocolateyTemplateData) error { // Check for CHOCOLATEY_API_KEY - apiKey := os.Getenv("CHOCOLATEY_API_KEY") + apiKey := core.Env("CHOCOLATEY_API_KEY") if apiKey == "" { return coreerr.E("chocolatey.Publish", "CHOCOLATEY_API_KEY environment variable is required for push", nil) } // Pack the package - nupkgPath := filepath.Join(packageDir, fmt.Sprintf("%s.%s.nupkg", data.PackageName, data.Version)) + nupkgPath := ax.Join(packageDir, core.Sprintf("%s.%s.nupkg", data.PackageName, data.Version)) - cmd := exec.CommandContext(ctx, "choco", "pack", filepath.Join(packageDir, fmt.Sprintf("%s.nuspec", data.PackageName)), "-OutputDirectory", packageDir) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, "", nil, "choco", "pack", ax.Join(packageDir, core.Sprintf("%s.nuspec", data.PackageName)), "-OutputDirectory", packageDir); err != nil { return coreerr.E("chocolatey.Publish", "choco pack failed", err) } // Push the package — pass API key via environment variable to avoid exposing it in process listings - cmd = exec.CommandContext(ctx, "choco", "push", nupkgPath, "--source", "https://push.chocolatey.org/") - cmd.Env = append(os.Environ(), "chocolateyApiKey="+apiKey) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, "", []string{"chocolateyApiKey=" + apiKey}, "choco", "push", nupkgPath, "--source", "https://push.chocolatey.org/"); err != nil { return coreerr.E("chocolatey.Publish", "choco push failed", err) } - fmt.Printf("Published to Chocolatey: https://community.chocolatey.org/packages/%s\n", data.PackageName) + publisherPrint("Published to Chocolatey: https://community.chocolatey.org/packages/%s", data.PackageName) return nil } @@ -263,7 +258,7 @@ func (p *ChocolateyPublisher) renderTemplate(m io.Medium, name string, data choc var err error // Try custom template from medium - customPath := filepath.Join(".core", name) + customPath := ax.Join(".core", name) if m != nil && m.IsFile(customPath) { customContent, err := m.Read(customPath) if err == nil { @@ -279,7 +274,7 @@ func (p *ChocolateyPublisher) renderTemplate(m io.Medium, name string, data choc } } - tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + tmpl, err := template.New(ax.Base(name)).Parse(string(content)) if err != nil { return "", coreerr.E("chocolatey.renderTemplate", "failed to parse template "+name, err) } diff --git a/pkg/release/publishers/chocolatey_test.go b/pkg/release/publishers/chocolatey_test.go index 0932dd5..9bf2a88 100644 --- a/pkg/release/publishers/chocolatey_test.go +++ b/pkg/release/publishers/chocolatey_test.go @@ -1,25 +1,24 @@ package publishers import ( - "bytes" "context" - "os" "testing" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestChocolateyPublisher_Name_Good(t *testing.T) { +func TestChocolatey_ChocolateyPublisherName_Good(t *testing.T) { t.Run("returns chocolatey", func(t *testing.T) { p := NewChocolateyPublisher() assert.Equal(t, "chocolatey", p.Name()) }) } -func TestChocolateyPublisher_ParseConfig_Good(t *testing.T) { +func TestChocolatey_ChocolateyPublisherParseConfig_Good(t *testing.T) { p := NewChocolateyPublisher() t.Run("uses defaults when no extended config", func(t *testing.T) { @@ -107,7 +106,7 @@ func TestChocolateyPublisher_ParseConfig_Good(t *testing.T) { }) } -func TestChocolateyPublisher_RenderTemplate_Good(t *testing.T) { +func TestChocolatey_ChocolateyPublisherRenderTemplate_Good(t *testing.T) { p := NewChocolateyPublisher() t.Run("renders nuspec template with data", func(t *testing.T) { @@ -160,7 +159,7 @@ func TestChocolateyPublisher_RenderTemplate_Good(t *testing.T) { }) } -func TestChocolateyPublisher_RenderTemplate_Bad(t *testing.T) { +func TestChocolatey_ChocolateyPublisherRenderTemplate_Bad(t *testing.T) { p := NewChocolateyPublisher() t.Run("returns error for non-existent template", func(t *testing.T) { @@ -171,14 +170,10 @@ func TestChocolateyPublisher_RenderTemplate_Bad(t *testing.T) { }) } -func TestChocolateyPublisher_DryRunPublish_Good(t *testing.T) { +func TestChocolatey_ChocolateyPublisherDryRunPublish_Good(t *testing.T) { p := NewChocolateyPublisher() t.Run("outputs expected dry run information", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := chocolateyTemplateData{ PackageName: "myapp", Version: "1.0.0", @@ -192,15 +187,11 @@ func TestChocolateyPublisher_DryRunPublish_Good(t *testing.T) { Push: false, } - err := p.dryRunPublish(io.Local, data, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: Chocolatey Publish") assert.Contains(t, output, "Package: myapp") @@ -214,10 +205,6 @@ func TestChocolateyPublisher_DryRunPublish_Good(t *testing.T) { }) t.Run("shows push message when push is enabled", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := chocolateyTemplateData{ PackageName: "myapp", Version: "1.0.0", @@ -230,37 +217,25 @@ func TestChocolateyPublisher_DryRunPublish_Good(t *testing.T) { Push: true, } - err := p.dryRunPublish(io.Local, data, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Push: true") assert.Contains(t, output, "Would push to Chocolatey community repo") }) } -func TestChocolateyPublisher_ExecutePublish_Bad(t *testing.T) { +func TestChocolatey_ChocolateyPublisherExecutePublish_Bad(t *testing.T) { p := NewChocolateyPublisher() t.Run("fails when CHOCOLATEY_API_KEY not set for push", func(t *testing.T) { - // Ensure CHOCOLATEY_API_KEY is not set - oldKey := os.Getenv("CHOCOLATEY_API_KEY") - _ = os.Unsetenv("CHOCOLATEY_API_KEY") - defer func() { - if oldKey != "" { - _ = os.Setenv("CHOCOLATEY_API_KEY", oldKey) - } - }() + t.Setenv("CHOCOLATEY_API_KEY", "") // Create a temp directory for the test - tmpDir, err := os.MkdirTemp("", "choco-test-*") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() + require.True(t, ax.IsDir(tmpDir)) data := chocolateyTemplateData{ PackageName: "testpkg", @@ -272,13 +247,13 @@ func TestChocolateyPublisher_ExecutePublish_Bad(t *testing.T) { Checksums: ChecksumMap{}, } - err = p.pushToChocolatey(context.TODO(), tmpDir, data) + err := p.pushToChocolatey(context.TODO(), tmpDir, data) assert.Error(t, err) assert.Contains(t, err.Error(), "CHOCOLATEY_API_KEY environment variable is required") }) } -func TestChocolateyConfig_Defaults_Good(t *testing.T) { +func TestChocolatey_ChocolateyConfigDefaults_Good(t *testing.T) { t.Run("has sensible defaults", func(t *testing.T) { p := NewChocolateyPublisher() pubCfg := PublisherConfig{Type: "chocolatey"} @@ -292,7 +267,7 @@ func TestChocolateyConfig_Defaults_Good(t *testing.T) { }) } -func TestChocolateyTemplateData_Good(t *testing.T) { +func TestChocolatey_ChocolateyTemplateData_Good(t *testing.T) { t.Run("struct has all expected fields", func(t *testing.T) { data := chocolateyTemplateData{ PackageName: "myapp", diff --git a/pkg/release/publishers/docker.go b/pkg/release/publishers/docker.go index 926ee9a..96559b2 100644 --- a/pkg/release/publishers/docker.go +++ b/pkg/release/publishers/docker.go @@ -3,16 +3,14 @@ package publishers import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" coreerr "dappco.re/go/core/log" ) // DockerConfig holds configuration for the Docker publisher. +// Usage example: declare a value of type publishers.DockerConfig in integrating code. type DockerConfig struct { // Registry is the container registry (default: ghcr.io). Registry string `yaml:"registry"` @@ -29,19 +27,23 @@ type DockerConfig struct { } // DockerPublisher builds and publishes Docker images. +// Usage example: declare a value of type publishers.DockerPublisher in integrating code. type DockerPublisher struct{} // NewDockerPublisher creates a new Docker publisher. +// Usage example: call publishers.NewDockerPublisher(...) from integrating code. func NewDockerPublisher() *DockerPublisher { return &DockerPublisher{} } // Name returns the publisher's identifier. +// Usage example: call value.Name(...) from integrating code. func (p *DockerPublisher) Name() string { return "docker" } // Publish builds and pushes Docker images. +// Usage example: call value.Publish(...) from integrating code. func (p *DockerPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { // Validate docker CLI is available if err := validateDockerCli(); err != nil { @@ -68,7 +70,7 @@ func (p *DockerPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConf cfg := DockerConfig{ Registry: "ghcr.io", Image: "", - Dockerfile: filepath.Join(projectDir, "Dockerfile"), + Dockerfile: ax.Join(projectDir, "Dockerfile"), Platforms: []string{"linux/amd64", "linux/arm64"}, Tags: []string{"latest", "{{.Version}}"}, BuildArgs: make(map[string]string), @@ -88,10 +90,10 @@ func (p *DockerPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConf cfg.Image = image } if dockerfile, ok := ext["dockerfile"].(string); ok && dockerfile != "" { - if filepath.IsAbs(dockerfile) { + if ax.IsAbs(dockerfile) { cfg.Dockerfile = dockerfile } else { - cfg.Dockerfile = filepath.Join(projectDir, dockerfile) + cfg.Dockerfile = ax.Join(projectDir, dockerfile) } } if platforms, ok := ext["platforms"].([]any); ok && len(platforms) > 0 { @@ -124,39 +126,39 @@ func (p *DockerPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConf // dryRunPublish shows what would be done without actually building. func (p *DockerPublisher) dryRunPublish(release *Release, cfg DockerConfig) error { - fmt.Println() - fmt.Println("=== DRY RUN: Docker Build & Push ===") - fmt.Println() - fmt.Printf("Version: %s\n", release.Version) - fmt.Printf("Registry: %s\n", cfg.Registry) - fmt.Printf("Image: %s\n", cfg.Image) - fmt.Printf("Dockerfile: %s\n", cfg.Dockerfile) - fmt.Printf("Platforms: %s\n", strings.Join(cfg.Platforms, ", ")) - fmt.Println() + publisherPrintln() + publisherPrintln("=== DRY RUN: Docker Build & Push ===") + publisherPrintln() + publisherPrint("Version: %s", release.Version) + publisherPrint("Registry: %s", cfg.Registry) + publisherPrint("Image: %s", cfg.Image) + publisherPrint("Dockerfile: %s", cfg.Dockerfile) + publisherPrint("Platforms: %s", core.Join(", ", cfg.Platforms...)) + publisherPrintln() // Resolve tags tags := p.resolveTags(cfg.Tags, release.Version) - fmt.Println("Tags to be applied:") + publisherPrintln("Tags to be applied:") for _, tag := range tags { fullTag := p.buildFullTag(cfg.Registry, cfg.Image, tag) - fmt.Printf(" - %s\n", fullTag) + publisherPrint(" - %s", fullTag) } - fmt.Println() + publisherPrintln() - fmt.Println("Would execute command:") + publisherPrintln("Would execute command:") args := p.buildBuildxArgs(cfg, tags, release.Version) - fmt.Printf(" docker %s\n", strings.Join(args, " ")) + publisherPrint(" docker %s", core.Join(" ", args...)) if len(cfg.BuildArgs) > 0 { - fmt.Println() - fmt.Println("Build arguments:") + publisherPrintln() + publisherPrintln("Build arguments:") for k, v := range cfg.BuildArgs { - fmt.Printf(" %s=%s\n", k, v) + publisherPrint(" %s=%s", k, v) } } - fmt.Println() - fmt.Println("=== END DRY RUN ===") + publisherPrintln() + publisherPrintln("=== END DRY RUN ===") return nil } @@ -174,13 +176,8 @@ func (p *DockerPublisher) executePublish(ctx context.Context, release *Release, // Build the docker buildx command args := p.buildBuildxArgs(cfg, tags, release.Version) - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Dir = release.ProjectDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - fmt.Printf("Building and pushing Docker image: %s\n", cfg.Image) - if err := cmd.Run(); err != nil { + publisherPrint("Building and pushing Docker image: %s", cfg.Image) + if err := publisherRun(ctx, release.ProjectDir, nil, "docker", args...); err != nil { return coreerr.E("docker.Publish", "buildx build failed", err) } @@ -192,9 +189,9 @@ func (p *DockerPublisher) resolveTags(tags []string, version string) []string { resolved := make([]string, 0, len(tags)) for _, tag := range tags { // Replace {{.Version}} with actual version - resolvedTag := strings.ReplaceAll(tag, "{{.Version}}", version) + resolvedTag := core.Replace(tag, "{{.Version}}", version) // Also support simpler {{Version}} syntax - resolvedTag = strings.ReplaceAll(resolvedTag, "{{Version}}", version) + resolvedTag = core.Replace(resolvedTag, "{{Version}}", version) resolved = append(resolved, resolvedTag) } return resolved @@ -203,9 +200,9 @@ func (p *DockerPublisher) resolveTags(tags []string, version string) []string { // buildFullTag builds the full image tag including registry. func (p *DockerPublisher) buildFullTag(registry, image, tag string) string { if registry != "" { - return fmt.Sprintf("%s/%s:%s", registry, image, tag) + return core.Sprintf("%s/%s:%s", registry, image, tag) } - return fmt.Sprintf("%s:%s", image, tag) + return core.Sprintf("%s:%s", image, tag) } // buildBuildxArgs builds the arguments for docker buildx build command. @@ -214,7 +211,7 @@ func (p *DockerPublisher) buildBuildxArgs(cfg DockerConfig, tags []string, versi // Multi-platform support if len(cfg.Platforms) > 0 { - args = append(args, "--platform", strings.Join(cfg.Platforms, ",")) + args = append(args, "--platform", core.Join(",", cfg.Platforms...)) } // Add all tags @@ -230,13 +227,13 @@ func (p *DockerPublisher) buildBuildxArgs(cfg DockerConfig, tags []string, versi // Build arguments for k, v := range cfg.BuildArgs { // Expand version in build args - expandedValue := strings.ReplaceAll(v, "{{.Version}}", version) - expandedValue = strings.ReplaceAll(expandedValue, "{{Version}}", version) - args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, expandedValue)) + expandedValue := core.Replace(v, "{{.Version}}", version) + expandedValue = core.Replace(expandedValue, "{{Version}}", version) + args = append(args, "--build-arg", core.Sprintf("%s=%s", k, expandedValue)) } // Always add VERSION build arg - args = append(args, "--build-arg", fmt.Sprintf("VERSION=%s", version)) + args = append(args, "--build-arg", core.Sprintf("VERSION=%s", version)) // Push the image args = append(args, "--push") @@ -250,19 +247,14 @@ func (p *DockerPublisher) buildBuildxArgs(cfg DockerConfig, tags []string, versi // ensureBuildx ensures docker buildx is available and has a builder. func (p *DockerPublisher) ensureBuildx(ctx context.Context) error { // Check if buildx is available - cmd := exec.CommandContext(ctx, "docker", "buildx", "version") - if err := cmd.Run(); err != nil { + if err := ax.Exec(ctx, "docker", "buildx", "version"); err != nil { return coreerr.E("docker.ensureBuildx", "buildx is not available. Install it from https://docs.docker.com/buildx/working-with-buildx/", nil) } // Check if we have a builder, create one if not - cmd = exec.CommandContext(ctx, "docker", "buildx", "inspect", "--bootstrap") - if err := cmd.Run(); err != nil { + if err := ax.Exec(ctx, "docker", "buildx", "inspect", "--bootstrap"); err != nil { // Try to create a builder - cmd = exec.CommandContext(ctx, "docker", "buildx", "create", "--use", "--bootstrap") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, "", nil, "docker", "buildx", "create", "--use", "--bootstrap"); err != nil { return coreerr.E("docker.ensureBuildx", "failed to create buildx builder", err) } } @@ -272,8 +264,7 @@ func (p *DockerPublisher) ensureBuildx(ctx context.Context) error { // validateDockerCli checks if the docker CLI is available. func validateDockerCli() error { - cmd := exec.Command("docker", "--version") - if err := cmd.Run(); err != nil { + if _, err := ax.LookPath("docker"); err != nil { return coreerr.E("docker.validateDockerCli", "docker CLI not found. Install it from https://docs.docker.com/get-docker/", nil) } return nil diff --git a/pkg/release/publishers/docker_test.go b/pkg/release/publishers/docker_test.go index cbad8f7..9a1dfee 100644 --- a/pkg/release/publishers/docker_test.go +++ b/pkg/release/publishers/docker_test.go @@ -1,25 +1,23 @@ package publishers import ( - "bytes" "context" - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestDockerPublisher_Name_Good(t *testing.T) { +func TestDocker_DockerPublisherName_Good(t *testing.T) { t.Run("returns docker", func(t *testing.T) { p := NewDockerPublisher() assert.Equal(t, "docker", p.Name()) }) } -func TestDockerPublisher_ParseConfig_Good(t *testing.T) { +func TestDocker_DockerPublisherParseConfig_Good(t *testing.T) { p := NewDockerPublisher() t.Run("uses defaults when no extended config", func(t *testing.T) { @@ -73,7 +71,7 @@ func TestDockerPublisher_ParseConfig_Good(t *testing.T) { }) } -func TestDockerPublisher_ResolveTags_Good(t *testing.T) { +func TestDocker_DockerPublisherResolveTags_Good(t *testing.T) { p := NewDockerPublisher() t.Run("resolves version template", func(t *testing.T) { @@ -95,7 +93,7 @@ func TestDockerPublisher_ResolveTags_Good(t *testing.T) { }) } -func TestDockerPublisher_BuildFullTag_Good(t *testing.T) { +func TestDocker_DockerPublisherBuildFullTag_Good(t *testing.T) { p := NewDockerPublisher() tests := []struct { @@ -136,7 +134,7 @@ func TestDockerPublisher_BuildFullTag_Good(t *testing.T) { } } -func TestDockerPublisher_BuildBuildxArgs_Good(t *testing.T) { +func TestDocker_DockerPublisherBuildBuildxArgs_Good(t *testing.T) { p := NewDockerPublisher() t.Run("builds basic args", func(t *testing.T) { @@ -228,7 +226,7 @@ func TestDockerPublisher_BuildBuildxArgs_Good(t *testing.T) { }) } -func TestDockerPublisher_Publish_Bad(t *testing.T) { +func TestDocker_DockerPublisherPublish_Bad(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } @@ -255,7 +253,7 @@ func TestDockerPublisher_Publish_Bad(t *testing.T) { }) } -func TestDockerConfig_Defaults_Good(t *testing.T) { +func TestDocker_DockerConfigDefaults_Good(t *testing.T) { t.Run("has sensible defaults", func(t *testing.T) { p := NewDockerPublisher() pubCfg := PublisherConfig{Type: "docker"} @@ -273,14 +271,10 @@ func TestDockerConfig_Defaults_Good(t *testing.T) { }) } -func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { +func TestDocker_DockerPublisherDryRunPublish_Good(t *testing.T) { p := NewDockerPublisher() t.Run("outputs expected dry run information", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: "/project", @@ -295,15 +289,11 @@ func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { BuildArgs: make(map[string]string), } - err := p.dryRunPublish(release, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: Docker Build & Push") assert.Contains(t, output, "Version: v1.0.0") @@ -320,10 +310,6 @@ func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { }) t.Run("shows build args when present", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: "/project", @@ -341,15 +327,11 @@ func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { }, } - err := p.dryRunPublish(release, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Build arguments:") assert.Contains(t, output, "GO_VERSION=1.21") @@ -357,10 +339,6 @@ func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { }) t.Run("handles single platform", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v2.0.0", ProjectDir: "/project", @@ -375,22 +353,18 @@ func TestDockerPublisher_DryRunPublish_Good(t *testing.T) { BuildArgs: make(map[string]string), } - err := p.dryRunPublish(release, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Platforms: linux/amd64") assert.Contains(t, output, "ghcr.io/owner/repo:stable") }) } -func TestDockerPublisher_ParseConfig_EdgeCases_Good(t *testing.T) { +func TestDocker_DockerPublisherParseConfigEdgeCases_Good(t *testing.T) { p := NewDockerPublisher() t.Run("handles nil release config", func(t *testing.T) { @@ -455,7 +429,7 @@ func TestDockerPublisher_ParseConfig_EdgeCases_Good(t *testing.T) { }) } -func TestDockerPublisher_ResolveTags_EdgeCases_Good(t *testing.T) { +func TestDocker_DockerPublisherResolveTagsEdgeCases_Good(t *testing.T) { p := NewDockerPublisher() t.Run("handles empty tags", func(t *testing.T) { @@ -474,7 +448,7 @@ func TestDockerPublisher_ResolveTags_EdgeCases_Good(t *testing.T) { }) } -func TestDockerPublisher_BuildBuildxArgs_EdgeCases_Good(t *testing.T) { +func TestDocker_DockerPublisherBuildBuildxArgsEdgeCases_Good(t *testing.T) { p := NewDockerPublisher() t.Run("handles empty platforms", func(t *testing.T) { @@ -563,7 +537,7 @@ func TestDockerPublisher_BuildBuildxArgs_EdgeCases_Good(t *testing.T) { }) } -func TestDockerPublisher_Publish_DryRun_Good(t *testing.T) { +func TestDocker_DockerPublisherPublishDryRun_Good(t *testing.T) { // Skip if docker CLI is not available - dry run still validates docker is installed if err := validateDockerCli(); err != nil { t.Skip("skipping test: docker CLI not available") @@ -573,17 +547,8 @@ func TestDockerPublisher_Publish_DryRun_Good(t *testing.T) { t.Run("dry run succeeds with valid Dockerfile", func(t *testing.T) { // Create temp directory with Dockerfile - tmpDir, err := os.MkdirTemp("", "docker-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() - - dockerfilePath := filepath.Join(tmpDir, "Dockerfile") - err = os.WriteFile(dockerfilePath, []byte("FROM alpine:latest\n"), 0644) - require.NoError(t, err) - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + tmpDir := t.TempDir() + require.NoError(t, ax.WriteFile(ax.Join(tmpDir, "Dockerfile"), []byte("FROM alpine:latest\n"), 0o644)) release := &Release{ Version: "v1.0.0", @@ -593,35 +558,20 @@ func TestDockerPublisher_Publish_DryRun_Good(t *testing.T) { pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: Docker Build & Push") }) t.Run("dry run uses custom dockerfile path", func(t *testing.T) { // Create temp directory with custom Dockerfile - tmpDir, err := os.MkdirTemp("", "docker-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() - - customDir := filepath.Join(tmpDir, "docker") - err = os.MkdirAll(customDir, 0755) - require.NoError(t, err) - - dockerfilePath := filepath.Join(customDir, "Dockerfile.prod") - err = os.WriteFile(dockerfilePath, []byte("FROM alpine:latest\n"), 0644) - require.NoError(t, err) - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + tmpDir := t.TempDir() + customDir := ax.Join(tmpDir, "docker") + require.NoError(t, ax.MkdirAll(customDir, 0o755)) + require.NoError(t, ax.WriteFile(ax.Join(customDir, "Dockerfile.prod"), []byte("FROM alpine:latest\n"), 0o644)) release := &Release{ Version: "v1.0.0", @@ -636,20 +586,16 @@ func TestDockerPublisher_Publish_DryRun_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "owner/repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Dockerfile.prod") }) } -func TestDockerPublisher_Publish_Validation_Bad(t *testing.T) { +func TestDocker_DockerPublisherPublishValidation_Bad(t *testing.T) { p := NewDockerPublisher() t.Run("fails when Dockerfile not found with docker installed", func(t *testing.T) { @@ -689,7 +635,7 @@ func TestDockerPublisher_Publish_Validation_Bad(t *testing.T) { }) } -func TestValidateDockerCli_Good(t *testing.T) { +func TestDocker_ValidateDockerCli_Good(t *testing.T) { t.Run("returns nil when docker is installed", func(t *testing.T) { err := validateDockerCli() if err != nil { @@ -700,7 +646,7 @@ func TestValidateDockerCli_Good(t *testing.T) { }) } -func TestDockerPublisher_Publish_WithCLI_Good(t *testing.T) { +func TestDocker_DockerPublisherPublishWithCLI_Good(t *testing.T) { // These tests run only when docker CLI is available if err := validateDockerCli(); err != nil { t.Skip("skipping test: docker CLI not available") @@ -709,17 +655,8 @@ func TestDockerPublisher_Publish_WithCLI_Good(t *testing.T) { p := NewDockerPublisher() t.Run("dry run succeeds with all config options", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "docker-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() - - dockerfilePath := filepath.Join(tmpDir, "Dockerfile") - err = os.WriteFile(dockerfilePath, []byte("FROM alpine:latest\n"), 0644) - require.NoError(t, err) - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + tmpDir := t.TempDir() + require.NoError(t, ax.WriteFile(ax.Join(tmpDir, "Dockerfile"), []byte("FROM alpine:latest\n"), 0o644)) release := &Release{ Version: "v1.0.0", @@ -738,32 +675,19 @@ func TestDockerPublisher_Publish_WithCLI_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "owner/repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: Docker Build & Push") assert.Contains(t, output, "docker.io") assert.Contains(t, output, "myorg/myapp") }) t.Run("dry run with nil relCfg uses extended image", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "docker-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() - - dockerfilePath := filepath.Join(tmpDir, "Dockerfile") - err = os.WriteFile(dockerfilePath, []byte("FROM alpine:latest\n"), 0644) - require.NoError(t, err) - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + tmpDir := t.TempDir() + require.NoError(t, ax.WriteFile(ax.Join(tmpDir, "Dockerfile"), []byte("FROM alpine:latest\n"), 0o644)) release := &Release{ Version: "v1.0.0", @@ -777,22 +701,16 @@ func TestDockerPublisher_Publish_WithCLI_Good(t *testing.T) { }, } - err = p.Publish(context.TODO(), release, pubCfg, nil, true) // nil relCfg - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, nil, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "standalone/image") }) t.Run("fails with non-existent Dockerfile in non-dry-run", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "docker-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() // Don't create a Dockerfile release := &Release{ @@ -803,7 +721,7 @@ func TestDockerPublisher_Publish_WithCLI_Good(t *testing.T) { pubCfg := PublisherConfig{Type: "docker"} relCfg := &mockReleaseConfig{repository: "owner/repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, false) + err := p.Publish(context.TODO(), release, pubCfg, relCfg, false) assert.Error(t, err) assert.Contains(t, err.Error(), "Dockerfile not found") }) diff --git a/pkg/release/publishers/github.go b/pkg/release/publishers/github.go index b80001a..d060c67 100644 --- a/pkg/release/publishers/github.go +++ b/pkg/release/publishers/github.go @@ -3,30 +3,31 @@ package publishers import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" coreerr "dappco.re/go/core/log" ) // GitHubPublisher publishes releases to GitHub using the gh CLI. +// Usage example: declare a value of type publishers.GitHubPublisher in integrating code. type GitHubPublisher struct{} // NewGitHubPublisher creates a new GitHub publisher. +// Usage example: call publishers.NewGitHubPublisher(...) from integrating code. func NewGitHubPublisher() *GitHubPublisher { return &GitHubPublisher{} } // Name returns the publisher's identifier. +// Usage example: call value.Name(...) from integrating code. func (p *GitHubPublisher) Name() string { return "github" } // Publish publishes the release to GitHub. // Uses the gh CLI for creating releases and uploading assets. +// Usage example: call value.Publish(...) from integrating code. func (p *GitHubPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { // Determine repository repo := "" @@ -56,34 +57,34 @@ func (p *GitHubPublisher) Publish(ctx context.Context, release *Release, pubCfg // dryRunPublish shows what would be done without actually publishing. func (p *GitHubPublisher) dryRunPublish(release *Release, pubCfg PublisherConfig, repo string) error { - fmt.Println() - fmt.Println("=== DRY RUN: GitHub Release ===") - fmt.Println() - fmt.Printf("Repository: %s\n", repo) - fmt.Printf("Version: %s\n", release.Version) - fmt.Printf("Draft: %t\n", pubCfg.Draft) - fmt.Printf("Prerelease: %t\n", pubCfg.Prerelease) - fmt.Println() + publisherPrintln() + publisherPrintln("=== DRY RUN: GitHub Release ===") + publisherPrintln() + publisherPrint("Repository: %s", repo) + publisherPrint("Version: %s", release.Version) + publisherPrint("Draft: %t", pubCfg.Draft) + publisherPrint("Prerelease: %t", pubCfg.Prerelease) + publisherPrintln() - fmt.Println("Would create release with command:") + publisherPrintln("Would create release with command:") args := p.buildCreateArgs(release, pubCfg, repo) - fmt.Printf(" gh %s\n", strings.Join(args, " ")) - fmt.Println() + publisherPrint(" gh %s", core.Join(" ", args...)) + publisherPrintln() if len(release.Artifacts) > 0 { - fmt.Println("Would upload artifacts:") + publisherPrintln("Would upload artifacts:") for _, artifact := range release.Artifacts { - fmt.Printf(" - %s\n", filepath.Base(artifact.Path)) + publisherPrint(" - %s", ax.Base(artifact.Path)) } } - fmt.Println() - fmt.Println("Changelog:") - fmt.Println("---") - fmt.Println(release.Changelog) - fmt.Println("---") - fmt.Println() - fmt.Println("=== END DRY RUN ===") + publisherPrintln() + publisherPrintln("Changelog:") + publisherPrintln("---") + publisherPrintln(release.Changelog) + publisherPrintln("---") + publisherPrintln() + publisherPrintln("=== END DRY RUN ===") return nil } @@ -99,12 +100,7 @@ func (p *GitHubPublisher) executePublish(ctx context.Context, release *Release, } // Execute gh release create - cmd := exec.CommandContext(ctx, "gh", args...) - cmd.Dir = release.ProjectDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, release.ProjectDir, nil, "gh", args...); err != nil { return coreerr.E("github.Publish", "gh release create failed", err) } @@ -145,20 +141,16 @@ func (p *GitHubPublisher) buildCreateArgs(release *Release, pubCfg PublisherConf // validateGhCli checks if the gh CLI is available and authenticated. func validateGhCli() error { - // Check if gh is installed - cmd := exec.Command("gh", "--version") - if err := cmd.Run(); err != nil { + if _, err := ax.LookPath("gh"); err != nil { return coreerr.E("github.validateGhCli", "gh CLI not found. Install it from https://cli.github.com", err) } - // Check if authenticated - cmd = exec.Command("gh", "auth", "status") - output, err := cmd.CombinedOutput() + output, err := ax.CombinedOutput(context.Background(), "", nil, "gh", "auth", "status") if err != nil { return coreerr.E("github.validateGhCli", "not authenticated with gh CLI. Run 'gh auth login' first", err) } - if !strings.Contains(string(output), "Logged in") { + if !core.Contains(output, "Logged in") { return coreerr.E("github.validateGhCli", "not authenticated with gh CLI. Run 'gh auth login' first", nil) } @@ -167,15 +159,12 @@ func validateGhCli() error { // detectRepository detects the GitHub repository from git remote. func detectRepository(dir string) (string, error) { - cmd := exec.Command("git", "remote", "get-url", "origin") - cmd.Dir = dir - output, err := cmd.Output() + output, err := ax.RunDir(context.Background(), dir, "git", "remote", "get-url", "origin") if err != nil { return "", coreerr.E("github.detectRepository", "failed to get git remote", err) } - url := strings.TrimSpace(string(output)) - return parseGitHubRepo(url) + return parseGitHubRepo(core.Trim(output)) } // parseGitHubRepo extracts owner/repo from a GitHub URL. @@ -185,16 +174,16 @@ func detectRepository(dir string) (string, error) { // - https://github.com/owner/repo func parseGitHubRepo(url string) (string, error) { // SSH format - if strings.HasPrefix(url, "git@github.com:") { - repo := strings.TrimPrefix(url, "git@github.com:") - repo = strings.TrimSuffix(repo, ".git") + if core.HasPrefix(url, "git@github.com:") { + repo := core.TrimPrefix(url, "git@github.com:") + repo = core.TrimSuffix(repo, ".git") return repo, nil } // HTTPS format - if strings.HasPrefix(url, "https://github.com/") { - repo := strings.TrimPrefix(url, "https://github.com/") - repo = strings.TrimSuffix(repo, ".git") + if core.HasPrefix(url, "https://github.com/") { + repo := core.TrimPrefix(url, "https://github.com/") + repo = core.TrimSuffix(repo, ".git") return repo, nil } @@ -203,12 +192,9 @@ func parseGitHubRepo(url string) (string, error) { // UploadArtifact uploads a single artifact to an existing release. // This can be used to add artifacts to a release after creation. +// Usage example: call publishers.UploadArtifact(...) from integrating code. func UploadArtifact(ctx context.Context, repo, version, artifactPath string) error { - cmd := exec.CommandContext(ctx, "gh", "release", "upload", version, artifactPath, "--repo", repo) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, "", nil, "gh", "release", "upload", version, artifactPath, "--repo", repo); err != nil { return coreerr.E("github.UploadArtifact", "failed to upload "+artifactPath, err) } @@ -216,12 +202,9 @@ func UploadArtifact(ctx context.Context, repo, version, artifactPath string) err } // DeleteRelease deletes a release by tag name. +// Usage example: call publishers.DeleteRelease(...) from integrating code. func DeleteRelease(ctx context.Context, repo, version string) error { - cmd := exec.CommandContext(ctx, "gh", "release", "delete", version, "--repo", repo, "--yes") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, "", nil, "gh", "release", "delete", version, "--repo", repo, "--yes"); err != nil { return coreerr.E("github.DeleteRelease", "failed to delete "+version, err) } @@ -229,7 +212,7 @@ func DeleteRelease(ctx context.Context, repo, version string) error { } // ReleaseExists checks if a release exists for the given version. +// Usage example: call publishers.ReleaseExists(...) from integrating code. func ReleaseExists(ctx context.Context, repo, version string) bool { - cmd := exec.CommandContext(ctx, "gh", "release", "view", version, "--repo", repo) - return cmd.Run() == nil + return ax.Exec(ctx, "gh", "release", "view", version, "--repo", repo) == nil } diff --git a/pkg/release/publishers/github_test.go b/pkg/release/publishers/github_test.go index 2cb87a5..2733223 100644 --- a/pkg/release/publishers/github_test.go +++ b/pkg/release/publishers/github_test.go @@ -1,20 +1,17 @@ package publishers import ( - "bytes" "context" - "os" - "os/exec" - "strings" "testing" + "dappco.re/go/core" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestParseGitHubRepo_Good(t *testing.T) { +func TestGitHub_ParseGitHubRepo_Good(t *testing.T) { tests := []struct { name string input string @@ -51,7 +48,7 @@ func TestParseGitHubRepo_Good(t *testing.T) { } } -func TestParseGitHubRepo_Bad(t *testing.T) { +func TestGitHub_ParseGitHubRepo_Bad(t *testing.T) { tests := []struct { name string input string @@ -82,14 +79,14 @@ func TestParseGitHubRepo_Bad(t *testing.T) { } } -func TestGitHubPublisher_Name_Good(t *testing.T) { +func TestGitHub_GitHubPublisherName_Good(t *testing.T) { t.Run("returns github", func(t *testing.T) { p := NewGitHubPublisher() assert.Equal(t, "github", p.Name()) }) } -func TestNewRelease_Good(t *testing.T) { +func TestGitHub_NewRelease_Good(t *testing.T) { t.Run("creates release struct", func(t *testing.T) { r := NewRelease("v1.0.0", nil, "changelog", "/project", io.Local) assert.Equal(t, "v1.0.0", r.Version) @@ -99,7 +96,7 @@ func TestNewRelease_Good(t *testing.T) { }) } -func TestNewPublisherConfig_Good(t *testing.T) { +func TestGitHub_NewPublisherConfig_Good(t *testing.T) { t.Run("creates config struct", func(t *testing.T) { cfg := NewPublisherConfig("github", true, false, nil) assert.Equal(t, "github", cfg.Type) @@ -116,7 +113,7 @@ func TestNewPublisherConfig_Good(t *testing.T) { }) } -func TestBuildCreateArgs_Good(t *testing.T) { +func TestGitHub_BuildCreateArgs_Good(t *testing.T) { p := NewGitHubPublisher() t.Run("basic args", func(t *testing.T) { @@ -221,14 +218,10 @@ func TestBuildCreateArgs_Good(t *testing.T) { }) } -func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { +func TestGitHub_GitHubPublisherDryRunPublish_Good(t *testing.T) { p := NewGitHubPublisher() t.Run("outputs expected dry run information", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", Changelog: "## Changes\n\n- Feature A\n- Bug fix B", @@ -241,15 +234,11 @@ func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { Prerelease: false, } - err := p.dryRunPublish(release, cfg, "owner/repo") - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, cfg, "owner/repo") + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: GitHub Release") assert.Contains(t, output, "Repository: owner/repo") @@ -264,10 +253,6 @@ func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { }) t.Run("shows artifacts when present", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", Changelog: "Changes", @@ -280,15 +265,11 @@ func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { } cfg := PublisherConfig{Type: "github"} - err := p.dryRunPublish(release, cfg, "owner/repo") - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, cfg, "owner/repo") + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Would upload artifacts:") assert.Contains(t, output, "myapp-darwin-amd64.tar.gz") @@ -296,10 +277,6 @@ func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { }) t.Run("shows draft and prerelease flags", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0-beta", Changelog: "Beta release", @@ -312,15 +289,11 @@ func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { Prerelease: true, } - err := p.dryRunPublish(release, cfg, "owner/repo") - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, cfg, "owner/repo") + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Draft: true") assert.Contains(t, output, "Prerelease: true") @@ -329,14 +302,10 @@ func TestGitHubPublisher_DryRunPublish_Good(t *testing.T) { }) } -func TestGitHubPublisher_Publish_Good(t *testing.T) { +func TestGitHub_GitHubPublisherPublish_Good(t *testing.T) { p := NewGitHubPublisher() t.Run("dry run uses repository from config", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", Changelog: "Changes", @@ -347,20 +316,16 @@ func TestGitHubPublisher_Publish_Good(t *testing.T) { relCfg := &mockReleaseConfig{repository: "custom/repo"} // Dry run should succeed without needing gh CLI - err := p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Repository: custom/repo") }) } -func TestGitHubPublisher_Publish_Bad(t *testing.T) { +func TestGitHub_GitHubPublisherPublish_Bad(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } @@ -387,9 +352,7 @@ func TestGitHubPublisher_Publish_Bad(t *testing.T) { t.Run("fails when repository cannot be detected", func(t *testing.T) { // Create a temp directory that is NOT a git repo - tmpDir, err := os.MkdirTemp("", "github-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() release := &Release{ Version: "v1.0.0", @@ -400,7 +363,7 @@ func TestGitHubPublisher_Publish_Bad(t *testing.T) { pubCfg := PublisherConfig{Type: "github"} relCfg := &mockReleaseConfig{repository: ""} // Empty repository - err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + err := p.Publish(context.Background(), release, pubCfg, relCfg, true) // Should fail because detectRepository will fail on non-git dir assert.Error(t, err) @@ -408,21 +371,14 @@ func TestGitHubPublisher_Publish_Bad(t *testing.T) { }) } -func TestDetectRepository_Good(t *testing.T) { +func TestGitHub_DetectRepository_Good(t *testing.T) { t.Run("detects repository from git remote", func(t *testing.T) { // Create a temp git repo - tmpDir, err := os.MkdirTemp("", "git-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() // Initialize git repo and set remote - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "remote", "add", "origin", "git@github.com:test-owner/test-repo.git") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) + runPublisherCommand(t, tmpDir, "git", "init") + runPublisherCommand(t, tmpDir, "git", "remote", "add", "origin", "git@github.com:test-owner/test-repo.git") repo, err := detectRepository(tmpDir) require.NoError(t, err) @@ -430,17 +386,9 @@ func TestDetectRepository_Good(t *testing.T) { }) t.Run("detects repository from HTTPS remote", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "git-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() - - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "remote", "add", "origin", "https://github.com/another-owner/another-repo.git") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) + tmpDir := t.TempDir() + runPublisherCommand(t, tmpDir, "git", "init") + runPublisherCommand(t, tmpDir, "git", "remote", "add", "origin", "https://github.com/another-owner/another-repo.git") repo, err := detectRepository(tmpDir) require.NoError(t, err) @@ -448,13 +396,11 @@ func TestDetectRepository_Good(t *testing.T) { }) } -func TestDetectRepository_Bad(t *testing.T) { +func TestGitHub_DetectRepository_Bad(t *testing.T) { t.Run("fails when not a git repository", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "no-git-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() - _, err = detectRepository(tmpDir) + _, err := detectRepository(tmpDir) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to get git remote") }) @@ -465,25 +411,17 @@ func TestDetectRepository_Bad(t *testing.T) { }) t.Run("fails when remote is not GitHub", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "git-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() + runPublisherCommand(t, tmpDir, "git", "init") + runPublisherCommand(t, tmpDir, "git", "remote", "add", "origin", "git@gitlab.com:owner/repo.git") - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "remote", "add", "origin", "git@gitlab.com:owner/repo.git") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - _, err = detectRepository(tmpDir) + _, err := detectRepository(tmpDir) assert.Error(t, err) assert.Contains(t, err.Error(), "not a GitHub URL") }) } -func TestValidateGhCli_Bad(t *testing.T) { +func TestGitHub_ValidateGhCli_Bad(t *testing.T) { // This test verifies the error messages from validateGhCli // We can't easily mock exec.Command, but we can at least // verify the function exists and returns expected error types @@ -494,15 +432,15 @@ func TestValidateGhCli_Bad(t *testing.T) { if err != nil { // Either gh is not installed or not authenticated assert.True(t, - strings.Contains(err.Error(), "gh CLI not found") || - strings.Contains(err.Error(), "not authenticated"), + core.Contains(err.Error(), "gh CLI not found") || + core.Contains(err.Error(), "not authenticated"), "unexpected error: %s", err.Error()) } // If err is nil, gh is installed and authenticated - that's OK too }) } -func TestGitHubPublisher_ExecutePublish_Good(t *testing.T) { +func TestGitHub_GitHubPublisherExecutePublish_Good(t *testing.T) { // These tests run only when gh CLI is available and authenticated if err := validateGhCli(); err != nil { t.Skip("skipping test: gh CLI not available or not authenticated") @@ -535,7 +473,7 @@ func TestGitHubPublisher_ExecutePublish_Good(t *testing.T) { }) } -func TestReleaseExists_Good(t *testing.T) { +func TestGitHub_ReleaseExists_Good(t *testing.T) { // These tests run only when gh CLI is available if err := validateGhCli(); err != nil { t.Skip("skipping test: gh CLI not available or not authenticated") diff --git a/pkg/release/publishers/homebrew.go b/pkg/release/publishers/homebrew.go index d36340a..fa26077 100644 --- a/pkg/release/publishers/homebrew.go +++ b/pkg/release/publishers/homebrew.go @@ -5,13 +5,10 @@ import ( "bytes" "context" "embed" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" "text/template" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" @@ -21,6 +18,7 @@ import ( var homebrewTemplates embed.FS // HomebrewConfig holds Homebrew-specific configuration. +// Usage example: declare a value of type publishers.HomebrewConfig in integrating code. type HomebrewConfig struct { // Tap is the Homebrew tap repository (e.g., "host-uk/homebrew-tap"). Tap string @@ -31,6 +29,7 @@ type HomebrewConfig struct { } // OfficialConfig holds configuration for generating files for official repo PRs. +// Usage example: declare a value of type publishers.OfficialConfig in integrating code. type OfficialConfig struct { // Enabled determines whether to generate files for official repos. Enabled bool @@ -39,19 +38,23 @@ type OfficialConfig struct { } // HomebrewPublisher publishes releases to Homebrew. +// Usage example: declare a value of type publishers.HomebrewPublisher in integrating code. type HomebrewPublisher struct{} // NewHomebrewPublisher creates a new Homebrew publisher. +// Usage example: call publishers.NewHomebrewPublisher(...) from integrating code. func NewHomebrewPublisher() *HomebrewPublisher { return &HomebrewPublisher{} } // Name returns the publisher's identifier. +// Usage example: call value.Name(...) from integrating code. func (p *HomebrewPublisher) Name() string { return "homebrew" } // Publish publishes the release to Homebrew. +// Usage example: call value.Publish(...) from integrating code. func (p *HomebrewPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { // Parse config cfg := p.parseConfig(pubCfg, relCfg) @@ -79,7 +82,7 @@ func (p *HomebrewPublisher) Publish(ctx context.Context, release *Release, pubCf projectName = relCfg.GetProjectName() } if projectName == "" { - parts := strings.Split(repo, "/") + parts := core.Split(repo, "/") projectName = parts[len(parts)-1] } @@ -89,7 +92,7 @@ func (p *HomebrewPublisher) Publish(ctx context.Context, release *Release, pubCf } // Strip leading 'v' from version - version := strings.TrimPrefix(release.Version, "v") + version := core.TrimPrefix(release.Version, "v") // Build checksums map from artifacts checksums := buildChecksumMap(release.Artifacts) @@ -97,7 +100,7 @@ func (p *HomebrewPublisher) Publish(ctx context.Context, release *Release, pubCf // Template data data := homebrewTemplateData{ FormulaClass: toFormulaClass(formulaName), - Description: fmt.Sprintf("%s CLI", projectName), + Description: core.Sprintf("%s CLI", projectName), Repository: repo, Version: version, License: "MIT", @@ -124,6 +127,7 @@ type homebrewTemplateData struct { } // ChecksumMap holds checksums for different platform/arch combinations. +// Usage example: declare a value of type publishers.ChecksumMap in integrating code. type ChecksumMap struct { DarwinAmd64 string DarwinArm64 string @@ -163,38 +167,38 @@ func (p *HomebrewPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseCo // dryRunPublish shows what would be done. func (p *HomebrewPublisher) dryRunPublish(m coreio.Medium, data homebrewTemplateData, cfg HomebrewConfig) error { - fmt.Println() - fmt.Println("=== DRY RUN: Homebrew Publish ===") - fmt.Println() - fmt.Printf("Formula: %s\n", data.FormulaClass) - fmt.Printf("Version: %s\n", data.Version) - fmt.Printf("Tap: %s\n", cfg.Tap) - fmt.Printf("Repository: %s\n", data.Repository) - fmt.Println() + publisherPrintln() + publisherPrintln("=== DRY RUN: Homebrew Publish ===") + publisherPrintln() + publisherPrint("Formula: %s", data.FormulaClass) + publisherPrint("Version: %s", data.Version) + publisherPrint("Tap: %s", cfg.Tap) + publisherPrint("Repository: %s", data.Repository) + publisherPrintln() // Generate and show formula formula, err := p.renderTemplate(m, "templates/homebrew/formula.rb.tmpl", data) if err != nil { return coreerr.E("homebrew.dryRunPublish", "failed to render template", err) } - fmt.Println("Generated formula.rb:") - fmt.Println("---") - fmt.Println(formula) - fmt.Println("---") - fmt.Println() + publisherPrintln("Generated formula.rb:") + publisherPrintln("---") + publisherPrintln(formula) + publisherPrintln("---") + publisherPrintln() if cfg.Tap != "" { - fmt.Printf("Would commit to tap: %s\n", cfg.Tap) + publisherPrint("Would commit to tap: %s", cfg.Tap) } if cfg.Official != nil && cfg.Official.Enabled { output := cfg.Official.Output if output == "" { output = "dist/homebrew" } - fmt.Printf("Would write files for official PR to: %s\n", output) + publisherPrint("Would write files for official PR to: %s", output) } - fmt.Println() - fmt.Println("=== END DRY RUN ===") + publisherPrintln() + publisherPrintln("=== END DRY RUN ===") return nil } @@ -211,20 +215,20 @@ func (p *HomebrewPublisher) executePublish(ctx context.Context, projectDir strin if cfg.Official != nil && cfg.Official.Enabled { output := cfg.Official.Output if output == "" { - output = filepath.Join(projectDir, "dist", "homebrew") - } else if !filepath.IsAbs(output) { - output = filepath.Join(projectDir, output) + output = ax.Join(projectDir, "dist", "homebrew") + } else if !ax.IsAbs(output) { + output = ax.Join(projectDir, output) } if err := release.FS.EnsureDir(output); err != nil { return coreerr.E("homebrew.Publish", "failed to create output directory", err) } - formulaPath := filepath.Join(output, fmt.Sprintf("%s.rb", strings.ToLower(data.FormulaClass))) + formulaPath := ax.Join(output, core.Sprintf("%s.rb", core.Lower(data.FormulaClass))) if err := release.FS.Write(formulaPath, formula); err != nil { return coreerr.E("homebrew.Publish", "failed to write formula", err) } - fmt.Printf("Wrote Homebrew formula for official PR: %s\n", formulaPath) + publisherPrint("Wrote Homebrew formula for official PR: %s", formulaPath) } // If tap is configured, commit to it @@ -240,59 +244,46 @@ func (p *HomebrewPublisher) executePublish(ctx context.Context, projectDir strin // commitToTap commits the formula to the tap repository. func (p *HomebrewPublisher) commitToTap(ctx context.Context, tap string, data homebrewTemplateData, formula string) error { // Clone tap repo to temp directory - tmpDir, err := os.MkdirTemp("", "homebrew-tap-*") + tmpDir, err := ax.TempDir("homebrew-tap-*") if err != nil { return coreerr.E("homebrew.commitToTap", "failed to create temp directory", err) } - defer func() { _ = coreio.Local.DeleteAll(tmpDir) }() + defer func() { _ = ax.RemoveAll(tmpDir) }() // Clone the tap - fmt.Printf("Cloning tap %s...\n", tap) - cmd := exec.CommandContext(ctx, "gh", "repo", "clone", tap, tmpDir, "--", "--depth=1") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + publisherPrint("Cloning tap %s...", tap) + if err := publisherRun(ctx, "", nil, "gh", "repo", "clone", tap, tmpDir, "--", "--depth=1"); err != nil { return coreerr.E("homebrew.commitToTap", "failed to clone tap", err) } // Ensure Formula directory exists - formulaDir := filepath.Join(tmpDir, "Formula") + formulaDir := ax.Join(tmpDir, "Formula") if err := coreio.Local.EnsureDir(formulaDir); err != nil { return coreerr.E("homebrew.commitToTap", "failed to create Formula directory", err) } // Write formula - formulaPath := filepath.Join(formulaDir, fmt.Sprintf("%s.rb", strings.ToLower(data.FormulaClass))) + formulaPath := ax.Join(formulaDir, core.Sprintf("%s.rb", core.Lower(data.FormulaClass))) if err := coreio.Local.Write(formulaPath, formula); err != nil { return coreerr.E("homebrew.commitToTap", "failed to write formula", err) } // Git add, commit, push - commitMsg := fmt.Sprintf("Update %s to %s", data.FormulaClass, data.Version) + commitMsg := core.Sprintf("Update %s to %s", data.FormulaClass, data.Version) - cmd = exec.CommandContext(ctx, "git", "add", ".") - cmd.Dir = tmpDir - if err := cmd.Run(); err != nil { + if err := ax.ExecDir(ctx, tmpDir, "git", "add", "."); err != nil { return coreerr.E("homebrew.commitToTap", "git add failed", err) } - cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg) - cmd.Dir = tmpDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, tmpDir, nil, "git", "commit", "-m", commitMsg); err != nil { return coreerr.E("homebrew.commitToTap", "git commit failed", err) } - cmd = exec.CommandContext(ctx, "git", "push") - cmd.Dir = tmpDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, tmpDir, nil, "git", "push"); err != nil { return coreerr.E("homebrew.commitToTap", "git push failed", err) } - fmt.Printf("Updated Homebrew tap: %s\n", tap) + publisherPrint("Updated Homebrew tap: %s", tap) return nil } @@ -302,7 +293,7 @@ func (p *HomebrewPublisher) renderTemplate(m coreio.Medium, name string, data ho var err error // Try custom template from medium - customPath := filepath.Join(".core", name) + customPath := ax.Join(".core", name) if m != nil && m.IsFile(customPath) { customContent, err := m.Read(customPath) if err == nil { @@ -318,7 +309,7 @@ func (p *HomebrewPublisher) renderTemplate(m coreio.Medium, name string, data ho } } - tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + tmpl, err := template.New(ax.Base(name)).Parse(string(content)) if err != nil { return "", coreerr.E("homebrew.renderTemplate", "failed to parse template "+name, err) } @@ -334,13 +325,13 @@ func (p *HomebrewPublisher) renderTemplate(m coreio.Medium, name string, data ho // toFormulaClass converts a package name to a Ruby class name. func toFormulaClass(name string) string { // Convert kebab-case to PascalCase - parts := strings.Split(name, "-") + parts := core.Split(name, "-") for i, part := range parts { if len(part) > 0 { - parts[i] = strings.ToUpper(part[:1]) + part[1:] + parts[i] = core.Upper(part[:1]) + part[1:] } } - return strings.Join(parts, "") + return core.Join("", parts...) } // buildChecksumMap extracts checksums from artifacts into a structured map. @@ -349,21 +340,21 @@ func buildChecksumMap(artifacts []build.Artifact) ChecksumMap { for _, a := range artifacts { // Parse artifact name to determine platform - name := filepath.Base(a.Path) + name := ax.Base(a.Path) checksum := a.Checksum switch { - case strings.Contains(name, "darwin-amd64"): + case core.Contains(name, "darwin-amd64"): checksums.DarwinAmd64 = checksum - case strings.Contains(name, "darwin-arm64"): + case core.Contains(name, "darwin-arm64"): checksums.DarwinArm64 = checksum - case strings.Contains(name, "linux-amd64"): + case core.Contains(name, "linux-amd64"): checksums.LinuxAmd64 = checksum - case strings.Contains(name, "linux-arm64"): + case core.Contains(name, "linux-arm64"): checksums.LinuxArm64 = checksum - case strings.Contains(name, "windows-amd64"): + case core.Contains(name, "windows-amd64"): checksums.WindowsAmd64 = checksum - case strings.Contains(name, "windows-arm64"): + case core.Contains(name, "windows-arm64"): checksums.WindowsArm64 = checksum } } diff --git a/pkg/release/publishers/homebrew_test.go b/pkg/release/publishers/homebrew_test.go index 74094e2..c0bb662 100644 --- a/pkg/release/publishers/homebrew_test.go +++ b/pkg/release/publishers/homebrew_test.go @@ -1,9 +1,7 @@ package publishers import ( - "bytes" "context" - "os" "testing" "dappco.re/go/core/build/pkg/build" @@ -12,14 +10,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestHomebrewPublisher_Name_Good(t *testing.T) { +func TestHomebrew_HomebrewPublisherName_Good(t *testing.T) { t.Run("returns homebrew", func(t *testing.T) { p := NewHomebrewPublisher() assert.Equal(t, "homebrew", p.Name()) }) } -func TestHomebrewPublisher_ParseConfig_Good(t *testing.T) { +func TestHomebrew_HomebrewPublisherParseConfig_Good(t *testing.T) { p := NewHomebrewPublisher() t.Run("uses defaults when no extended config", func(t *testing.T) { @@ -81,7 +79,7 @@ func TestHomebrewPublisher_ParseConfig_Good(t *testing.T) { }) } -func TestHomebrewPublisher_ToFormulaClass_Good(t *testing.T) { +func TestHomebrew_HomebrewPublisherToFormulaClass_Good(t *testing.T) { tests := []struct { name string input string @@ -122,7 +120,7 @@ func TestHomebrewPublisher_ToFormulaClass_Good(t *testing.T) { } } -func TestHomebrewPublisher_BuildChecksumMap_Good(t *testing.T) { +func TestHomebrew_HomebrewPublisherBuildChecksumMap_Good(t *testing.T) { t.Run("maps artifacts to checksums by platform", func(t *testing.T) { artifacts := []build.Artifact{ {Path: "/dist/myapp-darwin-amd64.tar.gz", OS: "darwin", Arch: "amd64", Checksum: "abc123"}, @@ -167,7 +165,7 @@ func TestHomebrewPublisher_BuildChecksumMap_Good(t *testing.T) { }) } -func TestHomebrewPublisher_RenderTemplate_Good(t *testing.T) { +func TestHomebrew_HomebrewPublisherRenderTemplate_Good(t *testing.T) { p := NewHomebrewPublisher() t.Run("renders formula template with data", func(t *testing.T) { @@ -202,7 +200,7 @@ func TestHomebrewPublisher_RenderTemplate_Good(t *testing.T) { }) } -func TestHomebrewPublisher_RenderTemplate_Bad(t *testing.T) { +func TestHomebrew_HomebrewPublisherRenderTemplate_Bad(t *testing.T) { p := NewHomebrewPublisher() t.Run("returns error for non-existent template", func(t *testing.T) { @@ -213,15 +211,10 @@ func TestHomebrewPublisher_RenderTemplate_Bad(t *testing.T) { }) } -func TestHomebrewPublisher_DryRunPublish_Good(t *testing.T) { +func TestHomebrew_HomebrewPublisherDryRunPublish_Good(t *testing.T) { p := NewHomebrewPublisher() t.Run("outputs expected dry run information", func(t *testing.T) { - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := homebrewTemplateData{ FormulaClass: "MyApp", Description: "My CLI", @@ -235,15 +228,11 @@ func TestHomebrewPublisher_DryRunPublish_Good(t *testing.T) { Tap: "owner/homebrew-tap", } - err := p.dryRunPublish(io.Local, data, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: Homebrew Publish") assert.Contains(t, output, "Formula: MyApp") @@ -255,10 +244,6 @@ func TestHomebrewPublisher_DryRunPublish_Good(t *testing.T) { }) t.Run("shows official output path when enabled", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := homebrewTemplateData{ FormulaClass: "MyApp", Version: "1.0.0", @@ -272,23 +257,15 @@ func TestHomebrewPublisher_DryRunPublish_Good(t *testing.T) { }, } - err := p.dryRunPublish(io.Local, data, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Would write files for official PR to: custom/path") }) t.Run("uses default official output path when not specified", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := homebrewTemplateData{ FormulaClass: "MyApp", Version: "1.0.0", @@ -301,20 +278,16 @@ func TestHomebrewPublisher_DryRunPublish_Good(t *testing.T) { }, } - err := p.dryRunPublish(io.Local, data, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Would write files for official PR to: dist/homebrew") }) } -func TestHomebrewPublisher_Publish_Bad(t *testing.T) { +func TestHomebrew_HomebrewPublisherPublish_Bad(t *testing.T) { p := NewHomebrewPublisher() t.Run("fails when tap not configured and not official mode", func(t *testing.T) { @@ -332,7 +305,7 @@ func TestHomebrewPublisher_Publish_Bad(t *testing.T) { }) } -func TestHomebrewConfig_Defaults_Good(t *testing.T) { +func TestHomebrew_HomebrewConfigDefaults_Good(t *testing.T) { t.Run("has sensible defaults", func(t *testing.T) { p := NewHomebrewPublisher() pubCfg := PublisherConfig{Type: "homebrew"} diff --git a/pkg/release/publishers/integration_test.go b/pkg/release/publishers/integration_test.go index 3e653cf..fae64ba 100644 --- a/pkg/release/publishers/integration_test.go +++ b/pkg/release/publishers/integration_test.go @@ -1,13 +1,10 @@ package publishers import ( - "bytes" "context" - "os" - "os/exec" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" @@ -16,7 +13,7 @@ import ( // --- GitHub Publisher Integration Tests --- -func TestGitHubPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { +func TestIntegration_GitHubPublisherIntegrationDryRunNoSideEffects_Good(t *testing.T) { p := NewGitHubPublisher() t.Run("dry run creates no files on disk", func(t *testing.T) { @@ -27,9 +24,9 @@ func TestGitHubPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { ProjectDir: tmpDir, FS: io.Local, Artifacts: []build.Artifact{ - {Path: filepath.Join(tmpDir, "app-linux-amd64.tar.gz")}, - {Path: filepath.Join(tmpDir, "app-darwin-arm64.tar.gz")}, - {Path: filepath.Join(tmpDir, "CHECKSUMS.txt")}, + {Path: ax.Join(tmpDir, "app-linux-amd64.tar.gz")}, + {Path: ax.Join(tmpDir, "app-darwin-arm64.tar.gz")}, + {Path: ax.Join(tmpDir, "CHECKSUMS.txt")}, }, } pubCfg := PublisherConfig{ @@ -39,20 +36,11 @@ func TestGitHubPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "test-org/test-repo", projectName: "testapp"} - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() // Verify dry run output contains expected information assert.Contains(t, output, "DRY RUN: GitHub Release") @@ -69,7 +57,7 @@ func TestGitHubPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { assert.Contains(t, output, "--prerelease") // Verify no files were created in the temp directory - entries, err := os.ReadDir(tmpDir) + entries, err := ax.ReadDir(tmpDir) require.NoError(t, err) assert.Empty(t, entries, "dry run should not create any files") }) @@ -131,14 +119,10 @@ func TestGitHubPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { }) } -func TestGitHubPublisher_Integration_RepositoryDetection_Good(t *testing.T) { +func TestIntegration_GitHubPublisherIntegrationRepositoryDetection_Good(t *testing.T) { p := NewGitHubPublisher() t.Run("uses relCfg repository when provided", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", Changelog: "Changes", @@ -148,31 +132,19 @@ func TestGitHubPublisher_Integration_RepositoryDetection_Good(t *testing.T) { pubCfg := PublisherConfig{Type: "github"} relCfg := &mockReleaseConfig{repository: "explicit/repo"} - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - assert.Contains(t, buf.String(), "Repository: explicit/repo") + assert.Contains(t, output, "Repository: explicit/repo") }) t.Run("detects repository from git remote when relCfg empty", func(t *testing.T) { tmpDir := t.TempDir() - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "remote", "add", "origin", "https://github.com/detected/from-git.git") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + runPublisherCommand(t, tmpDir, "git", "init") + runPublisherCommand(t, tmpDir, "git", "remote", "add", "origin", "https://github.com/detected/from-git.git") release := &Release{ Version: "v1.0.0", @@ -183,15 +155,12 @@ func TestGitHubPublisher_Integration_RepositoryDetection_Good(t *testing.T) { pubCfg := PublisherConfig{Type: "github"} relCfg := &mockReleaseConfig{repository: ""} - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - assert.Contains(t, buf.String(), "Repository: detected/from-git") + assert.Contains(t, output, "Repository: detected/from-git") }) t.Run("fails when no repository available", func(t *testing.T) { @@ -212,14 +181,10 @@ func TestGitHubPublisher_Integration_RepositoryDetection_Good(t *testing.T) { }) } -func TestGitHubPublisher_Integration_ArtifactUpload_Good(t *testing.T) { +func TestIntegration_GitHubPublisherIntegrationArtifactUpload_Good(t *testing.T) { p := NewGitHubPublisher() t.Run("dry run lists all artifact types", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", Changelog: "Release notes", @@ -235,15 +200,11 @@ func TestGitHubPublisher_Integration_ArtifactUpload_Good(t *testing.T) { } pubCfg := PublisherConfig{Type: "github"} - err := p.dryRunPublish(release, pubCfg, "owner/repo") - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, pubCfg, "owner/repo") + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Would upload artifacts:") assert.Contains(t, output, "app-linux-amd64.tar.gz") @@ -281,7 +242,7 @@ func TestGitHubPublisher_Integration_ArtifactUpload_Good(t *testing.T) { // --- Docker Publisher Integration Tests --- -func TestDockerPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { +func TestIntegration_DockerPublisherIntegrationDryRunNoSideEffects_Good(t *testing.T) { if err := validateDockerCli(); err != nil { t.Skip("skipping: docker CLI not available") } @@ -292,13 +253,9 @@ func TestDockerPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { tmpDir := t.TempDir() // Create a Dockerfile - err := os.WriteFile(filepath.Join(tmpDir, "Dockerfile"), []byte("FROM alpine:latest\n"), 0644) + err := ax.WriteFile(ax.Join(tmpDir, "Dockerfile"), []byte("FROM alpine:latest\n"), 0o644) require.NoError(t, err) - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.2.3", ProjectDir: tmpDir, @@ -319,15 +276,10 @@ func TestDockerPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "test-org/test-app"} - err = p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() // Verify dry run output assert.Contains(t, output, "DRY RUN: Docker Build & Push") @@ -399,7 +351,7 @@ func TestDockerPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { }) } -func TestDockerPublisher_Integration_ConfigParsing_Good(t *testing.T) { +func TestIntegration_DockerPublisherIntegrationConfigParsing_Good(t *testing.T) { p := NewDockerPublisher() t.Run("full config round-trip from PublisherConfig to DockerConfig", func(t *testing.T) { @@ -437,16 +389,12 @@ func TestDockerPublisher_Integration_ConfigParsing_Good(t *testing.T) { // --- Homebrew Publisher Integration Tests --- -func TestHomebrewPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { +func TestIntegration_HomebrewPublisherIntegrationDryRunNoSideEffects_Good(t *testing.T) { p := NewHomebrewPublisher() t.Run("dry run generates formula without writing files", func(t *testing.T) { tmpDir := t.TempDir() - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v2.1.0", ProjectDir: tmpDir, @@ -467,15 +415,11 @@ func TestHomebrewPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "test-org/my-cli", projectName: "my-cli"} - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() // Verify dry run output assert.Contains(t, output, "DRY RUN: Homebrew Publish") @@ -495,16 +439,12 @@ func TestHomebrewPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { assert.Contains(t, output, "Would commit to tap: test-org/homebrew-tap") // Verify no files created - entries, err := os.ReadDir(tmpDir) + entries, err := ax.ReadDir(tmpDir) require.NoError(t, err) assert.Empty(t, entries, "dry run should not create any files") }) t.Run("dry run with official config shows output path", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: "/project", @@ -522,20 +462,16 @@ func TestHomebrewPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "owner/repo", projectName: "repo"} - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Would write files for official PR to: dist/homebrew-official") }) } -func TestHomebrewPublisher_Integration_FormulaGeneration_Good(t *testing.T) { +func TestIntegration_HomebrewPublisherIntegrationFormulaGeneration_Good(t *testing.T) { p := NewHomebrewPublisher() t.Run("generated formula contains correct Ruby class structure", func(t *testing.T) { @@ -599,16 +535,12 @@ func TestHomebrewPublisher_Integration_FormulaGeneration_Good(t *testing.T) { // --- Scoop Publisher Integration Tests --- -func TestScoopPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { +func TestIntegration_ScoopPublisherIntegrationDryRunNoSideEffects_Good(t *testing.T) { p := NewScoopPublisher() t.Run("dry run generates manifest without writing files", func(t *testing.T) { tmpDir := t.TempDir() - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.5.0", ProjectDir: tmpDir, @@ -626,15 +558,11 @@ func TestScoopPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "test-org/myapp", projectName: "myapp"} - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: Scoop Publish") assert.Contains(t, output, "Package: myapp") @@ -645,7 +573,7 @@ func TestScoopPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { assert.Contains(t, output, "Would commit to bucket: test-org/scoop-bucket") // Verify no files created - entries, err := os.ReadDir(tmpDir) + entries, err := ax.ReadDir(tmpDir) require.NoError(t, err) assert.Empty(t, entries) }) @@ -653,16 +581,12 @@ func TestScoopPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { // --- AUR Publisher Integration Tests --- -func TestAURPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { +func TestIntegration_AURPublisherIntegrationDryRunNoSideEffects_Good(t *testing.T) { p := NewAURPublisher() t.Run("dry run generates PKGBUILD and SRCINFO without writing files", func(t *testing.T) { tmpDir := t.TempDir() - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v2.0.0", ProjectDir: tmpDir, @@ -680,15 +604,11 @@ func TestAURPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "test-org/myapp", projectName: "myapp"} - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: AUR Publish") assert.Contains(t, output, "Package: myapp-bin") @@ -702,7 +622,7 @@ func TestAURPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { assert.Contains(t, output, "Would push to AUR:") // Verify no files created - entries, err := os.ReadDir(tmpDir) + entries, err := ax.ReadDir(tmpDir) require.NoError(t, err) assert.Empty(t, entries) }) @@ -710,16 +630,12 @@ func TestAURPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { // --- Chocolatey Publisher Integration Tests --- -func TestChocolateyPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { +func TestIntegration_ChocolateyPublisherIntegrationDryRunNoSideEffects_Good(t *testing.T) { p := NewChocolateyPublisher() t.Run("dry run generates nuspec and install script without side effects", func(t *testing.T) { tmpDir := t.TempDir() - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, @@ -737,15 +653,11 @@ func TestChocolateyPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) } relCfg := &mockReleaseConfig{repository: "owner/my-cli-tool", projectName: "my-cli-tool"} - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: Chocolatey Publish") assert.Contains(t, output, "Package: my-cli-tool") @@ -757,7 +669,7 @@ func TestChocolateyPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) assert.Contains(t, output, "Would generate package files only") // Verify no files created - entries, err := os.ReadDir(tmpDir) + entries, err := ax.ReadDir(tmpDir) require.NoError(t, err) assert.Empty(t, entries) }) @@ -765,16 +677,12 @@ func TestChocolateyPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) // --- npm Publisher Integration Tests --- -func TestNpmPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { +func TestIntegration_NpmPublisherIntegrationDryRunNoSideEffects_Good(t *testing.T) { p := NewNpmPublisher() t.Run("dry run generates package.json without writing files or publishing", func(t *testing.T) { tmpDir := t.TempDir() - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v3.0.0", ProjectDir: tmpDir, @@ -789,15 +697,11 @@ func TestNpmPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "test-org/my-cli", projectName: "my-cli"} - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: npm Publish") assert.Contains(t, output, "Package: @test-org/my-cli") @@ -809,7 +713,7 @@ func TestNpmPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { assert.Contains(t, output, "Would run: npm publish --access public") // Verify no files created - entries, err := os.ReadDir(tmpDir) + entries, err := ax.ReadDir(tmpDir) require.NoError(t, err) assert.Empty(t, entries) }) @@ -817,7 +721,7 @@ func TestNpmPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { // --- LinuxKit Publisher Integration Tests --- -func TestLinuxKitPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { +func TestIntegration_LinuxKitPublisherIntegrationDryRunNoSideEffects_Good(t *testing.T) { if err := validateLinuxKitCli(); err != nil { t.Skip("skipping: linuxkit CLI not available") } @@ -828,13 +732,9 @@ func TestLinuxKitPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { tmpDir := t.TempDir() // Create config file - configDir := filepath.Join(tmpDir, ".core", "linuxkit") - require.NoError(t, os.MkdirAll(configDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(configDir, "server.yml"), []byte("kernel:\n image: test\n"), 0644)) - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + configDir := ax.Join(tmpDir, ".core", "linuxkit") + require.NoError(t, ax.MkdirAll(configDir, 0o755)) + require.NoError(t, ax.WriteFile(ax.Join(configDir, "server.yml"), []byte("kernel:\n image: test\n"), 0o644)) release := &Release{ Version: "v1.0.0", @@ -850,15 +750,11 @@ func TestLinuxKitPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "test-org/my-os"} - err := p.Publish(context.Background(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.Publish(context.Background(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: LinuxKit Build & Publish") assert.Contains(t, output, "Formats: iso, qcow2, docker") @@ -876,15 +772,14 @@ func TestLinuxKitPublisher_Integration_DryRunNoSideEffects_Good(t *testing.T) { assert.Contains(t, output, "docker load") // Verify no files created in dist - distDir := filepath.Join(tmpDir, "dist") - _, err = os.Stat(distDir) - assert.True(t, os.IsNotExist(err), "dry run should not create dist directory") + distDir := ax.Join(tmpDir, "dist") + assert.False(t, ax.Exists(distDir), "dry run should not create dist directory") }) } // --- Cross-Publisher Integration Tests --- -func TestAllPublishers_Integration_NameUniqueness_Good(t *testing.T) { +func TestIntegration_AllPublishersIntegrationNameUniqueness_Good(t *testing.T) { t.Run("all publishers have unique names", func(t *testing.T) { publishers := []Publisher{ NewGitHubPublisher(), @@ -909,21 +804,12 @@ func TestAllPublishers_Integration_NameUniqueness_Good(t *testing.T) { }) } -func TestAllPublishers_Integration_NilRelCfg_Good(t *testing.T) { +func TestIntegration_AllPublishersIntegrationNilRelCfg_Good(t *testing.T) { t.Run("github handles nil relCfg with git repo", func(t *testing.T) { tmpDir := t.TempDir() - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "remote", "add", "origin", "git@github.com:niltest/repo.git") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + runPublisherCommand(t, tmpDir, "git", "init") + runPublisherCommand(t, tmpDir, "git", "remote", "add", "origin", "git@github.com:niltest/repo.git") release := &Release{ Version: "v1.0.0", @@ -933,19 +819,16 @@ func TestAllPublishers_Integration_NilRelCfg_Good(t *testing.T) { } pubCfg := PublisherConfig{Type: "github"} - err := NewGitHubPublisher().Publish(context.Background(), release, pubCfg, nil, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = NewGitHubPublisher().Publish(context.Background(), release, pubCfg, nil, true) + }) require.NoError(t, err) - assert.Contains(t, buf.String(), "niltest/repo") + assert.Contains(t, output, "niltest/repo") }) } -func TestBuildChecksumMap_Integration_Good(t *testing.T) { +func TestIntegration_BuildChecksumMapIntegration_Good(t *testing.T) { t.Run("maps all platforms correctly from realistic artifacts", func(t *testing.T) { artifacts := []build.Artifact{ {Path: "/dist/core-v1.0.0-darwin-amd64.tar.gz", Checksum: "da64"}, diff --git a/pkg/release/publishers/linuxkit.go b/pkg/release/publishers/linuxkit.go index a71a446..add478e 100644 --- a/pkg/release/publishers/linuxkit.go +++ b/pkg/release/publishers/linuxkit.go @@ -3,16 +3,14 @@ package publishers import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" coreerr "dappco.re/go/core/log" ) // LinuxKitConfig holds configuration for the LinuxKit publisher. +// Usage example: declare a value of type publishers.LinuxKitConfig in integrating code. type LinuxKitConfig struct { // Config is the path to the LinuxKit YAML configuration file. Config string `yaml:"config"` @@ -26,19 +24,23 @@ type LinuxKitConfig struct { } // LinuxKitPublisher builds and publishes LinuxKit images. +// Usage example: declare a value of type publishers.LinuxKitPublisher in integrating code. type LinuxKitPublisher struct{} // NewLinuxKitPublisher creates a new LinuxKit publisher. +// Usage example: call publishers.NewLinuxKitPublisher(...) from integrating code. func NewLinuxKitPublisher() *LinuxKitPublisher { return &LinuxKitPublisher{} } // Name returns the publisher's identifier. +// Usage example: call value.Name(...) from integrating code. func (p *LinuxKitPublisher) Name() string { return "linuxkit" } // Publish builds LinuxKit images and uploads them to the GitHub release. +// Usage example: call value.Publish(...) from integrating code. func (p *LinuxKitPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { // Validate linuxkit CLI is available if err := validateLinuxKitCli(); err != nil { @@ -79,7 +81,7 @@ func (p *LinuxKitPublisher) Publish(ctx context.Context, release *Release, pubCf // parseConfig extracts LinuxKit-specific configuration. func (p *LinuxKitPublisher) parseConfig(pubCfg PublisherConfig, projectDir string) LinuxKitConfig { cfg := LinuxKitConfig{ - Config: filepath.Join(projectDir, ".core", "linuxkit", "server.yml"), + Config: ax.Join(projectDir, ".core", "linuxkit", "server.yml"), Formats: []string{"iso"}, Platforms: []string{"linux/amd64"}, } @@ -87,10 +89,10 @@ func (p *LinuxKitPublisher) parseConfig(pubCfg PublisherConfig, projectDir strin // Override from extended config if present if ext, ok := pubCfg.Extended.(map[string]any); ok { if configPath, ok := ext["config"].(string); ok && configPath != "" { - if filepath.IsAbs(configPath) { + if ax.IsAbs(configPath) { cfg.Config = configPath } else { - cfg.Config = filepath.Join(projectDir, configPath) + cfg.Config = ax.Join(projectDir, configPath) } } if formats, ok := ext["formats"].([]any); ok && len(formats) > 0 { @@ -116,62 +118,62 @@ func (p *LinuxKitPublisher) parseConfig(pubCfg PublisherConfig, projectDir strin // dryRunPublish shows what would be done without actually building. func (p *LinuxKitPublisher) dryRunPublish(release *Release, cfg LinuxKitConfig, repo string) error { - fmt.Println() - fmt.Println("=== DRY RUN: LinuxKit Build & Publish ===") - fmt.Println() - fmt.Printf("Repository: %s\n", repo) - fmt.Printf("Version: %s\n", release.Version) - fmt.Printf("Config: %s\n", cfg.Config) - fmt.Printf("Formats: %s\n", strings.Join(cfg.Formats, ", ")) - fmt.Printf("Platforms: %s\n", strings.Join(cfg.Platforms, ", ")) - fmt.Println() + publisherPrintln() + publisherPrintln("=== DRY RUN: LinuxKit Build & Publish ===") + publisherPrintln() + publisherPrint("Repository: %s", repo) + publisherPrint("Version: %s", release.Version) + publisherPrint("Config: %s", cfg.Config) + publisherPrint("Formats: %s", core.Join(", ", cfg.Formats...)) + publisherPrint("Platforms: %s", core.Join(", ", cfg.Platforms...)) + publisherPrintln() - outputDir := filepath.Join(release.ProjectDir, "dist", "linuxkit") + outputDir := ax.Join(release.ProjectDir, "dist", "linuxkit") baseName := p.buildBaseName(release.Version) - fmt.Println("Would execute commands:") + publisherPrintln("Would execute commands:") for _, platform := range cfg.Platforms { - parts := strings.Split(platform, "/") + parts := core.Split(platform, "/") arch := "amd64" if len(parts) == 2 { arch = parts[1] } for _, format := range cfg.Formats { - outputName := fmt.Sprintf("%s-%s", baseName, arch) + outputName := core.Sprintf("%s-%s", baseName, arch) args := p.buildLinuxKitArgs(cfg.Config, format, outputName, outputDir, arch) - fmt.Printf(" linuxkit %s\n", strings.Join(args, " ")) + publisherPrint(" linuxkit %s", core.Join(" ", args...)) } } - fmt.Println() + publisherPrintln() - fmt.Println("Would upload artifacts to release:") + publisherPrintln("Would upload artifacts to release:") for _, platform := range cfg.Platforms { - parts := strings.Split(platform, "/") + parts := core.Split(platform, "/") arch := "amd64" if len(parts) == 2 { arch = parts[1] } for _, format := range cfg.Formats { - outputName := fmt.Sprintf("%s-%s", baseName, arch) + outputName := core.Sprintf("%s-%s", baseName, arch) artifactPath := p.getArtifactPath(outputDir, outputName, format) - fmt.Printf(" - %s\n", filepath.Base(artifactPath)) + publisherPrint(" - %s", ax.Base(artifactPath)) if format == "docker" { - fmt.Printf(" Usage: docker load < %s\n", filepath.Base(artifactPath)) + publisherPrint(" Usage: docker load < %s", ax.Base(artifactPath)) } } } - fmt.Println() - fmt.Println("=== END DRY RUN ===") + publisherPrintln() + publisherPrintln("=== END DRY RUN ===") return nil } // executePublish builds LinuxKit images and uploads them. func (p *LinuxKitPublisher) executePublish(ctx context.Context, release *Release, cfg LinuxKitConfig, repo string) error { - outputDir := filepath.Join(release.ProjectDir, "dist", "linuxkit") + outputDir := ax.Join(release.ProjectDir, "dist", "linuxkit") // Create output directory if err := release.FS.EnsureDir(outputDir); err != nil { @@ -183,24 +185,19 @@ func (p *LinuxKitPublisher) executePublish(ctx context.Context, release *Release // Build for each platform and format for _, platform := range cfg.Platforms { - parts := strings.Split(platform, "/") + parts := core.Split(platform, "/") arch := "amd64" if len(parts) == 2 { arch = parts[1] } for _, format := range cfg.Formats { - outputName := fmt.Sprintf("%s-%s", baseName, arch) + outputName := core.Sprintf("%s-%s", baseName, arch) // Build the image args := p.buildLinuxKitArgs(cfg.Config, format, outputName, outputDir, arch) - cmd := exec.CommandContext(ctx, "linuxkit", args...) - cmd.Dir = release.ProjectDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - fmt.Printf("Building LinuxKit image: %s (%s)\n", outputName, format) - if err := cmd.Run(); err != nil { + publisherPrint("Building LinuxKit image: %s (%s)", outputName, format) + if err := publisherRun(ctx, release.ProjectDir, nil, "linuxkit", args...); err != nil { return coreerr.E("linuxkit.Publish", "build failed for "+platform+"/"+format, err) } @@ -217,12 +214,12 @@ func (p *LinuxKitPublisher) executePublish(ctx context.Context, release *Release } if err := UploadArtifact(ctx, repo, release.Version, artifactPath); err != nil { - return coreerr.E("linuxkit.Publish", "failed to upload "+filepath.Base(artifactPath), err) + return coreerr.E("linuxkit.Publish", "failed to upload "+ax.Base(artifactPath), err) } // Print helpful usage info for docker format - if strings.HasSuffix(artifactPath, ".docker.tar") { - fmt.Printf(" Load with: docker load < %s\n", filepath.Base(artifactPath)) + if core.HasSuffix(artifactPath, ".docker.tar") { + publisherPrint(" Load with: docker load < %s", ax.Base(artifactPath)) } } @@ -232,8 +229,8 @@ func (p *LinuxKitPublisher) executePublish(ctx context.Context, release *Release // buildBaseName creates the base name for output files. func (p *LinuxKitPublisher) buildBaseName(version string) string { // Strip leading 'v' if present for cleaner filenames - name := strings.TrimPrefix(version, "v") - return fmt.Sprintf("linuxkit-%s", name) + name := core.TrimPrefix(version, "v") + return core.Sprintf("linuxkit-%s", name) } // buildLinuxKitArgs builds the arguments for linuxkit build command. @@ -263,7 +260,7 @@ func (p *LinuxKitPublisher) buildLinuxKitArgs(configPath, format, outputName, ou // getArtifactPath returns the expected path of the built artifact. func (p *LinuxKitPublisher) getArtifactPath(outputDir, outputName, format string) string { ext := p.getFormatExtension(format) - return filepath.Join(outputDir, outputName+ext) + return ax.Join(outputDir, outputName+ext) } // getFormatExtension returns the file extension for a LinuxKit output format. @@ -297,9 +294,8 @@ func (p *LinuxKitPublisher) getFormatExtension(format string) string { // validateLinuxKitCli checks if the linuxkit CLI is available. func validateLinuxKitCli() error { - cmd := exec.Command("linuxkit", "version") - if err := cmd.Run(); err != nil { - return coreerr.E("linuxkit.validateLinuxKitCli", "linuxkit CLI not found. Install it from https://github.com/linuxkit/linuxkit", nil) + if _, err := ax.LookPath("linuxkit"); err != nil { + return coreerr.E("linuxkit.validateLinuxKitCli", "linuxkit CLI not found. Install it from https://github.com/linuxkit/linuxkit", err) } return nil } diff --git a/pkg/release/publishers/linuxkit_test.go b/pkg/release/publishers/linuxkit_test.go index 4fd505a..f714c05 100644 --- a/pkg/release/publishers/linuxkit_test.go +++ b/pkg/release/publishers/linuxkit_test.go @@ -1,26 +1,23 @@ package publishers import ( - "bytes" "context" - "os" - "os/exec" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestLinuxKitPublisher_Name_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherName_Good(t *testing.T) { t.Run("returns linuxkit", func(t *testing.T) { p := NewLinuxKitPublisher() assert.Equal(t, "linuxkit", p.Name()) }) } -func TestLinuxKitPublisher_ParseConfig_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherParseConfig_Good(t *testing.T) { p := NewLinuxKitPublisher() t.Run("uses defaults when no extended config", func(t *testing.T) { @@ -61,7 +58,7 @@ func TestLinuxKitPublisher_ParseConfig_Good(t *testing.T) { }) } -func TestLinuxKitPublisher_BuildLinuxKitArgs_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherBuildLinuxKitArgs_Good(t *testing.T) { p := NewLinuxKitPublisher() t.Run("builds basic args for amd64", func(t *testing.T) { @@ -88,7 +85,7 @@ func TestLinuxKitPublisher_BuildLinuxKitArgs_Good(t *testing.T) { }) } -func TestLinuxKitPublisher_BuildBaseName_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherBuildBaseName_Good(t *testing.T) { p := NewLinuxKitPublisher() t.Run("strips v prefix", func(t *testing.T) { @@ -102,7 +99,7 @@ func TestLinuxKitPublisher_BuildBaseName_Good(t *testing.T) { }) } -func TestLinuxKitPublisher_GetArtifactPath_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherGetArtifactPath_Good(t *testing.T) { p := NewLinuxKitPublisher() tests := []struct { @@ -157,7 +154,7 @@ func TestLinuxKitPublisher_GetArtifactPath_Good(t *testing.T) { } } -func TestLinuxKitPublisher_GetFormatExtension_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherGetFormatExtension_Good(t *testing.T) { p := NewLinuxKitPublisher() tests := []struct { @@ -182,7 +179,7 @@ func TestLinuxKitPublisher_GetFormatExtension_Good(t *testing.T) { } } -func TestLinuxKitPublisher_Publish_Bad(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherPublish_Bad(t *testing.T) { p := NewLinuxKitPublisher() t.Run("fails when config file not found with linuxkit installed", func(t *testing.T) { @@ -232,13 +229,11 @@ func TestLinuxKitPublisher_Publish_Bad(t *testing.T) { } // Create temp directory that is NOT a git repo - tmpDir, err := os.MkdirTemp("", "linuxkit-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() // Create a config file - configPath := filepath.Join(tmpDir, "config.yml") - err = os.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0644) + configPath := ax.Join(tmpDir, "config.yml") + err := ax.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0o644) require.NoError(t, err) release := &Release{ @@ -260,7 +255,7 @@ func TestLinuxKitPublisher_Publish_Bad(t *testing.T) { }) } -func TestValidateLinuxKitCli_Good(t *testing.T) { +func TestLinuxKit_ValidateLinuxKitCli_Good(t *testing.T) { t.Run("returns expected error when linuxkit not installed", func(t *testing.T) { err := validateLinuxKitCli() if err != nil { @@ -271,7 +266,7 @@ func TestValidateLinuxKitCli_Good(t *testing.T) { }) } -func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherPublishWithCLI_Good(t *testing.T) { // These tests run only when linuxkit CLI is available if err := validateLinuxKitCli(); err != nil { t.Skip("skipping test: linuxkit CLI not available") @@ -280,23 +275,17 @@ func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { p := NewLinuxKitPublisher() t.Run("succeeds with dry run and valid config", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "linuxkit-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() // Create config directory and file - configDir := filepath.Join(tmpDir, ".core", "linuxkit") - err = os.MkdirAll(configDir, 0755) + configDir := ax.Join(tmpDir, ".core", "linuxkit") + err := ax.MkdirAll(configDir, 0o755) require.NoError(t, err) - configPath := filepath.Join(configDir, "server.yml") - err = os.WriteFile(configPath, []byte("kernel:\n image: linuxkit/kernel:5.10\n"), 0644) + configPath := ax.Join(configDir, "server.yml") + err = ax.WriteFile(configPath, []byte("kernel:\n image: linuxkit/kernel:5.10\n"), 0o644) require.NoError(t, err) - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, @@ -305,22 +294,15 @@ func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: "owner/repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: LinuxKit Build & Publish") }) t.Run("fails with missing config file", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "linuxkit-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() release := &Release{ Version: "v1.0.0", @@ -330,28 +312,22 @@ func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: "owner/repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, false) + err := p.Publish(context.TODO(), release, pubCfg, relCfg, false) assert.Error(t, err) assert.Contains(t, err.Error(), "config file not found") }) t.Run("uses relCfg repository", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "linuxkit-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() - configDir := filepath.Join(tmpDir, ".core", "linuxkit") - err = os.MkdirAll(configDir, 0755) + configDir := ax.Join(tmpDir, ".core", "linuxkit") + err := ax.MkdirAll(configDir, 0o755) require.NoError(t, err) - configPath := filepath.Join(configDir, "server.yml") - err = os.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0644) + configPath := ax.Join(configDir, "server.yml") + err = ax.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0o644) require.NoError(t, err) - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, @@ -360,44 +336,28 @@ func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: "custom-owner/custom-repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "custom-owner/custom-repo") }) t.Run("detects repository when not provided", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "linuxkit-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() // Create config file - configDir := filepath.Join(tmpDir, ".core", "linuxkit") - err = os.MkdirAll(configDir, 0755) + configDir := ax.Join(tmpDir, ".core", "linuxkit") + err := ax.MkdirAll(configDir, 0o755) require.NoError(t, err) - configPath := filepath.Join(configDir, "server.yml") - err = os.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0644) + configPath := ax.Join(configDir, "server.yml") + err = ax.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0o644) require.NoError(t, err) // Initialize git repo - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "remote", "add", "origin", "git@github.com:detected-owner/detected-repo.git") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + runPublisherCommand(t, tmpDir, "git", "init") + runPublisherCommand(t, tmpDir, "git", "remote", "add", "origin", "git@github.com:detected-owner/detected-repo.git") release := &Release{ Version: "v1.0.0", @@ -407,20 +367,15 @@ func TestLinuxKitPublisher_Publish_WithCLI_Good(t *testing.T) { pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: ""} // Empty to trigger detection - err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "detected-owner/detected-repo") }) } -func TestLinuxKitPublisher_Publish_NilRelCfg_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherPublishNilRelCfg_Good(t *testing.T) { if err := validateLinuxKitCli(); err != nil { t.Skip("skipping test: linuxkit CLI not available") } @@ -428,31 +383,20 @@ func TestLinuxKitPublisher_Publish_NilRelCfg_Good(t *testing.T) { p := NewLinuxKitPublisher() t.Run("handles nil relCfg by detecting repo", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "linuxkit-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() // Create config file - configDir := filepath.Join(tmpDir, ".core", "linuxkit") - err = os.MkdirAll(configDir, 0755) + configDir := ax.Join(tmpDir, ".core", "linuxkit") + err := ax.MkdirAll(configDir, 0o755) require.NoError(t, err) - configPath := filepath.Join(configDir, "server.yml") - err = os.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0644) + configPath := ax.Join(configDir, "server.yml") + err = ax.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0o644) require.NoError(t, err) // Initialize git repo - cmd := exec.Command("git", "init") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "remote", "add", "origin", "git@github.com:nil-owner/nil-repo.git") - cmd.Dir = tmpDir - require.NoError(t, cmd.Run()) - - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + runPublisherCommand(t, tmpDir, "git", "init") + runPublisherCommand(t, tmpDir, "git", "remote", "add", "origin", "git@github.com:nil-owner/nil-repo.git") release := &Release{ Version: "v1.0.0", @@ -461,15 +405,10 @@ func TestLinuxKitPublisher_Publish_NilRelCfg_Good(t *testing.T) { } pubCfg := PublisherConfig{Type: "linuxkit"} - err = p.Publish(context.TODO(), release, pubCfg, nil, true) // nil relCfg - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, nil, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "nil-owner/nil-repo") }) } @@ -488,14 +427,10 @@ func (m *mockReleaseConfig) GetProjectName() string { return m.projectName } -func TestLinuxKitPublisher_DryRunPublish_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherDryRunPublish_Good(t *testing.T) { p := NewLinuxKitPublisher() t.Run("outputs expected dry run information", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: "/project", @@ -507,15 +442,11 @@ func TestLinuxKitPublisher_DryRunPublish_Good(t *testing.T) { Platforms: []string{"linux/amd64", "linux/arm64"}, } - err := p.dryRunPublish(release, cfg, "owner/repo") - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, cfg, "owner/repo") + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: LinuxKit Build & Publish") assert.Contains(t, output, "Repository: owner/repo") @@ -534,10 +465,6 @@ func TestLinuxKitPublisher_DryRunPublish_Good(t *testing.T) { }) t.Run("shows docker format usage hint", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: "/project", @@ -549,25 +476,17 @@ func TestLinuxKitPublisher_DryRunPublish_Good(t *testing.T) { Platforms: []string{"linux/amd64"}, } - err := p.dryRunPublish(release, cfg, "owner/repo") - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, cfg, "owner/repo") + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "linuxkit-1.0.0-amd64.docker.tar") assert.Contains(t, output, "Usage: docker load <") }) t.Run("handles single platform and format", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v2.0.0", ProjectDir: "/project", @@ -579,22 +498,18 @@ func TestLinuxKitPublisher_DryRunPublish_Good(t *testing.T) { Platforms: []string{"linux/amd64"}, } - err := p.dryRunPublish(release, cfg, "owner/repo") - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(release, cfg, "owner/repo") + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "linuxkit-2.0.0-amd64.iso") assert.NotContains(t, output, "arm64") }) } -func TestLinuxKitPublisher_GetFormatExtension_AllFormats_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherGetFormatExtensionAllFormats_Good(t *testing.T) { p := NewLinuxKitPublisher() tests := []struct { @@ -628,7 +543,7 @@ func TestLinuxKitPublisher_GetFormatExtension_AllFormats_Good(t *testing.T) { } } -func TestLinuxKitPublisher_BuildLinuxKitArgs_AllArchitectures_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherBuildLinuxKitArgsAllArchitectures_Good(t *testing.T) { p := NewLinuxKitPublisher() t.Run("amd64 does not include arch flag", func(t *testing.T) { @@ -660,7 +575,7 @@ func TestLinuxKitPublisher_BuildLinuxKitArgs_AllArchitectures_Good(t *testing.T) }) } -func TestLinuxKitPublisher_ParseConfig_EdgeCases_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherParseConfigEdgeCases_Good(t *testing.T) { p := NewLinuxKitPublisher() t.Run("handles nil extended config", func(t *testing.T) { @@ -716,7 +631,7 @@ func TestLinuxKitPublisher_ParseConfig_EdgeCases_Good(t *testing.T) { }) } -func TestLinuxKitPublisher_BuildBaseName_EdgeCases_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherBuildBaseNameEdgeCases_Good(t *testing.T) { p := NewLinuxKitPublisher() tests := []struct { @@ -740,7 +655,7 @@ func TestLinuxKitPublisher_BuildBaseName_EdgeCases_Good(t *testing.T) { } } -func TestLinuxKitPublisher_GetArtifactPath_AllFormats_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherGetArtifactPathAllFormats_Good(t *testing.T) { p := NewLinuxKitPublisher() tests := []struct { @@ -802,7 +717,7 @@ func TestLinuxKitPublisher_GetArtifactPath_AllFormats_Good(t *testing.T) { } } -func TestLinuxKitPublisher_Publish_NilFS_Bad(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherPublishNilFS_Bad(t *testing.T) { if err := validateLinuxKitCli(); err != nil { t.Skip("skipping test: linuxkit CLI not available") } @@ -824,7 +739,7 @@ func TestLinuxKitPublisher_Publish_NilFS_Bad(t *testing.T) { }) } -func TestLinuxKitPublisher_Publish_DryRun_Good(t *testing.T) { +func TestLinuxKit_LinuxKitPublisherPublishDryRun_Good(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } @@ -838,22 +753,15 @@ func TestLinuxKitPublisher_Publish_DryRun_Good(t *testing.T) { t.Run("dry run succeeds with valid config file", func(t *testing.T) { // Create temp directory with config file - tmpDir, err := os.MkdirTemp("", "linuxkit-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() - - configDir := filepath.Join(tmpDir, ".core", "linuxkit") - err = os.MkdirAll(configDir, 0755) + tmpDir := t.TempDir() + configDir := ax.Join(tmpDir, ".core", "linuxkit") + err := ax.MkdirAll(configDir, 0o755) require.NoError(t, err) - configPath := filepath.Join(configDir, "server.yml") - err = os.WriteFile(configPath, []byte("kernel:\n image: linuxkit/kernel:5.10\n"), 0644) + configPath := ax.Join(configDir, "server.yml") + err = ax.WriteFile(configPath, []byte("kernel:\n image: linuxkit/kernel:5.10\n"), 0o644) require.NoError(t, err) - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, @@ -862,31 +770,20 @@ func TestLinuxKitPublisher_Publish_DryRun_Good(t *testing.T) { pubCfg := PublisherConfig{Type: "linuxkit"} relCfg := &mockReleaseConfig{repository: "owner/repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: LinuxKit Build & Publish") }) t.Run("dry run uses custom config path", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "linuxkit-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() - customConfigPath := filepath.Join(tmpDir, "custom-config.yml") - err = os.WriteFile(customConfigPath, []byte("kernel:\n image: custom\n"), 0644) + customConfigPath := ax.Join(tmpDir, "custom-config.yml") + err := ax.WriteFile(customConfigPath, []byte("kernel:\n image: custom\n"), 0o644) require.NoError(t, err) - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v1.0.0", ProjectDir: tmpDir, @@ -900,31 +797,20 @@ func TestLinuxKitPublisher_Publish_DryRun_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "owner/repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "custom-config.yml") }) t.Run("dry run with multiple formats and platforms", func(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "linuxkit-test") - require.NoError(t, err) - defer func() { _ = os.RemoveAll(tmpDir) }() + tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "config.yml") - err = os.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0644) + configPath := ax.Join(tmpDir, "config.yml") + err := ax.WriteFile(configPath, []byte("kernel:\n image: test\n"), 0o644) require.NoError(t, err) - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - release := &Release{ Version: "v2.0.0", ProjectDir: tmpDir, @@ -940,15 +826,10 @@ func TestLinuxKitPublisher_Publish_DryRun_Good(t *testing.T) { } relCfg := &mockReleaseConfig{repository: "owner/repo"} - err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + output := capturePublisherOutput(t, func() { + err = p.Publish(context.TODO(), release, pubCfg, relCfg, true) + }) require.NoError(t, err) - output := buf.String() // Check all format/platform combinations are listed assert.Contains(t, output, "linuxkit-2.0.0-amd64.iso") diff --git a/pkg/release/publishers/npm.go b/pkg/release/publishers/npm.go index b54c497..e617869 100644 --- a/pkg/release/publishers/npm.go +++ b/pkg/release/publishers/npm.go @@ -5,13 +5,10 @@ import ( "bytes" "context" "embed" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" "text/template" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) @@ -20,6 +17,7 @@ import ( var npmTemplates embed.FS // NpmConfig holds npm-specific configuration. +// Usage example: declare a value of type publishers.NpmConfig in integrating code. type NpmConfig struct { // Package is the npm package name (e.g., "@host-uk/core"). Package string @@ -28,20 +26,24 @@ type NpmConfig struct { } // NpmPublisher publishes releases to npm using the binary wrapper pattern. +// Usage example: declare a value of type publishers.NpmPublisher in integrating code. type NpmPublisher struct{} // NewNpmPublisher creates a new npm publisher. +// Usage example: call publishers.NewNpmPublisher(...) from integrating code. func NewNpmPublisher() *NpmPublisher { return &NpmPublisher{} } // Name returns the publisher's identifier. +// Usage example: call value.Name(...) from integrating code. func (p *NpmPublisher) Name() string { return "npm" } // Publish publishes the release to npm. // It generates a binary wrapper package that downloads the correct platform binary on postinstall. +// Usage example: call value.Publish(...) from integrating code. func (p *NpmPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { // Parse npm config npmCfg := p.parseConfig(pubCfg, relCfg) @@ -71,18 +73,18 @@ func (p *NpmPublisher) Publish(ctx context.Context, release *Release, pubCfg Pub } if projectName == "" { // Try to infer from package name - parts := strings.Split(npmCfg.Package, "/") + parts := core.Split(npmCfg.Package, "/") projectName = parts[len(parts)-1] } // Strip leading 'v' from version for npm - version := strings.TrimPrefix(release.Version, "v") + version := core.TrimPrefix(release.Version, "v") // Template data data := npmTemplateData{ Package: npmCfg.Package, Version: version, - Description: fmt.Sprintf("%s CLI", projectName), + Description: core.Sprintf("%s CLI", projectName), License: "MIT", Repository: repo, BinaryName: projectName, @@ -131,30 +133,30 @@ type npmTemplateData struct { // dryRunPublish shows what would be done without actually publishing. func (p *NpmPublisher) dryRunPublish(m coreio.Medium, data npmTemplateData) error { - fmt.Println() - fmt.Println("=== DRY RUN: npm Publish ===") - fmt.Println() - fmt.Printf("Package: %s\n", data.Package) - fmt.Printf("Version: %s\n", data.Version) - fmt.Printf("Access: %s\n", data.Access) - fmt.Printf("Repository: %s\n", data.Repository) - fmt.Printf("Binary: %s\n", data.BinaryName) - fmt.Println() + publisherPrintln() + publisherPrintln("=== DRY RUN: npm Publish ===") + publisherPrintln() + publisherPrint("Package: %s", data.Package) + publisherPrint("Version: %s", data.Version) + publisherPrint("Access: %s", data.Access) + publisherPrint("Repository: %s", data.Repository) + publisherPrint("Binary: %s", data.BinaryName) + publisherPrintln() // Generate and show package.json pkgJSON, err := p.renderTemplate(m, "templates/npm/package.json.tmpl", data) if err != nil { return coreerr.E("npm.dryRunPublish", "failed to render template", err) } - fmt.Println("Generated package.json:") - fmt.Println("---") - fmt.Println(pkgJSON) - fmt.Println("---") - fmt.Println() + publisherPrintln("Generated package.json:") + publisherPrintln("---") + publisherPrintln(pkgJSON) + publisherPrintln("---") + publisherPrintln() - fmt.Println("Would run: npm publish --access", data.Access) - fmt.Println() - fmt.Println("=== END DRY RUN ===") + publisherPrintln("Would run: npm publish --access", data.Access) + publisherPrintln() + publisherPrintln("=== END DRY RUN ===") return nil } @@ -162,19 +164,20 @@ func (p *NpmPublisher) dryRunPublish(m coreio.Medium, data npmTemplateData) erro // executePublish actually creates and publishes the npm package. func (p *NpmPublisher) executePublish(ctx context.Context, m coreio.Medium, data npmTemplateData, cfg *NpmConfig) error { // Check for NPM_TOKEN - if os.Getenv("NPM_TOKEN") == "" { + npmToken := core.Env("NPM_TOKEN") + if npmToken == "" { return coreerr.E("npm.Publish", "NPM_TOKEN environment variable is required", nil) } // Create temp directory for package - tmpDir, err := os.MkdirTemp("", "npm-publish-*") + tmpDir, err := ax.TempDir("npm-publish-*") if err != nil { return coreerr.E("npm.Publish", "failed to create temp directory", err) } - defer func() { _ = coreio.Local.DeleteAll(tmpDir) }() + defer func() { _ = ax.RemoveAll(tmpDir) }() // Create bin directory - binDir := filepath.Join(tmpDir, "bin") + binDir := ax.Join(tmpDir, "bin") if err := coreio.Local.EnsureDir(binDir); err != nil { return coreerr.E("npm.Publish", "failed to create bin directory", err) } @@ -184,7 +187,7 @@ func (p *NpmPublisher) executePublish(ctx context.Context, m coreio.Medium, data if err != nil { return coreerr.E("npm.Publish", "failed to render package.json", err) } - if err := coreio.Local.Write(filepath.Join(tmpDir, "package.json"), pkgJSON); err != nil { + if err := coreio.Local.Write(ax.Join(tmpDir, "package.json"), pkgJSON); err != nil { return coreerr.E("npm.Publish", "failed to write package.json", err) } @@ -193,7 +196,7 @@ func (p *NpmPublisher) executePublish(ctx context.Context, m coreio.Medium, data if err != nil { return coreerr.E("npm.Publish", "failed to render install.js", err) } - if err := coreio.Local.Write(filepath.Join(tmpDir, "install.js"), installJS); err != nil { + if err := coreio.Local.Write(ax.Join(tmpDir, "install.js"), installJS); err != nil { return coreerr.E("npm.Publish", "failed to write install.js", err) } @@ -202,30 +205,24 @@ func (p *NpmPublisher) executePublish(ctx context.Context, m coreio.Medium, data if err != nil { return coreerr.E("npm.Publish", "failed to render run.js", err) } - if err := coreio.Local.Write(filepath.Join(binDir, "run.js"), runJS); err != nil { + if err := coreio.Local.Write(ax.Join(binDir, "run.js"), runJS); err != nil { return coreerr.E("npm.Publish", "failed to write run.js", err) } // Create .npmrc with token npmrc := "//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n" - if err := coreio.Local.Write(filepath.Join(tmpDir, ".npmrc"), npmrc); err != nil { + if err := coreio.Local.Write(ax.Join(tmpDir, ".npmrc"), npmrc); err != nil { return coreerr.E("npm.Publish", "failed to write .npmrc", err) } // Run npm publish - cmd := exec.CommandContext(ctx, "npm", "publish", "--access", data.Access) - cmd.Dir = tmpDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = append(os.Environ(), "NPM_TOKEN="+os.Getenv("NPM_TOKEN")) - - fmt.Printf("Publishing %s@%s to npm...\n", data.Package, data.Version) - if err := cmd.Run(); err != nil { + publisherPrint("Publishing %s@%s to npm...", data.Package, data.Version) + if err := publisherRun(ctx, tmpDir, []string{"NPM_TOKEN=" + npmToken}, "npm", "publish", "--access", data.Access); err != nil { return coreerr.E("npm.Publish", "npm publish failed", err) } - fmt.Printf("Published %s@%s to npm\n", data.Package, data.Version) - fmt.Printf(" https://www.npmjs.com/package/%s\n", data.Package) + publisherPrint("Published %s@%s to npm", data.Package, data.Version) + publisherPrint(" https://www.npmjs.com/package/%s", data.Package) return nil } @@ -236,7 +233,7 @@ func (p *NpmPublisher) renderTemplate(m coreio.Medium, name string, data npmTemp var err error // Try custom template from medium - customPath := filepath.Join(".core", name) + customPath := ax.Join(".core", name) if m != nil && m.IsFile(customPath) { customContent, err := m.Read(customPath) if err == nil { @@ -252,7 +249,7 @@ func (p *NpmPublisher) renderTemplate(m coreio.Medium, name string, data npmTemp } } - tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + tmpl, err := template.New(ax.Base(name)).Parse(string(content)) if err != nil { return "", coreerr.E("npm.renderTemplate", "failed to parse template "+name, err) } diff --git a/pkg/release/publishers/npm_test.go b/pkg/release/publishers/npm_test.go index 919695c..3767947 100644 --- a/pkg/release/publishers/npm_test.go +++ b/pkg/release/publishers/npm_test.go @@ -1,9 +1,7 @@ package publishers import ( - "bytes" "context" - "os" "testing" "dappco.re/go/core/io" @@ -12,14 +10,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestNpmPublisher_Name_Good(t *testing.T) { +func TestNpm_NpmPublisherName_Good(t *testing.T) { t.Run("returns npm", func(t *testing.T) { p := NewNpmPublisher() assert.Equal(t, "npm", p.Name()) }) } -func TestNpmPublisher_ParseConfig_Good(t *testing.T) { +func TestNpm_NpmPublisherParseConfig_Good(t *testing.T) { p := NewNpmPublisher() t.Run("uses defaults when no extended config", func(t *testing.T) { @@ -88,7 +86,7 @@ func TestNpmPublisher_ParseConfig_Good(t *testing.T) { }) } -func TestNpmPublisher_RenderTemplate_Good(t *testing.T) { +func TestNpm_NpmPublisherRenderTemplate_Good(t *testing.T) { p := NewNpmPublisher() t.Run("renders package.json template with data", func(t *testing.T) { @@ -134,7 +132,7 @@ func TestNpmPublisher_RenderTemplate_Good(t *testing.T) { }) } -func TestNpmPublisher_RenderTemplate_Bad(t *testing.T) { +func TestNpm_NpmPublisherRenderTemplate_Bad(t *testing.T) { p := NewNpmPublisher() t.Run("returns error for non-existent template", func(t *testing.T) { @@ -145,14 +143,10 @@ func TestNpmPublisher_RenderTemplate_Bad(t *testing.T) { }) } -func TestNpmPublisher_DryRunPublish_Good(t *testing.T) { +func TestNpm_NpmPublisherDryRunPublish_Good(t *testing.T) { p := NewNpmPublisher() t.Run("outputs expected dry run information", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := npmTemplateData{ Package: "@myorg/mycli", Version: "1.0.0", @@ -161,15 +155,11 @@ func TestNpmPublisher_DryRunPublish_Good(t *testing.T) { BinaryName: "mycli", Description: "My CLI", } - err := p.dryRunPublish(io.Local, data) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: npm Publish") assert.Contains(t, output, "Package: @myorg/mycli") @@ -183,10 +173,6 @@ func TestNpmPublisher_DryRunPublish_Good(t *testing.T) { }) t.Run("shows restricted access correctly", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := npmTemplateData{ Package: "@private/cli", Version: "2.0.0", @@ -195,22 +181,18 @@ func TestNpmPublisher_DryRunPublish_Good(t *testing.T) { BinaryName: "cli", } - err := p.dryRunPublish(io.Local, data) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Access: restricted") assert.Contains(t, output, "Would run: npm publish --access restricted") }) } -func TestNpmPublisher_Publish_Bad(t *testing.T) { +func TestNpm_NpmPublisherPublish_Bad(t *testing.T) { p := NewNpmPublisher() t.Run("fails when package name not configured", func(t *testing.T) { @@ -228,14 +210,7 @@ func TestNpmPublisher_Publish_Bad(t *testing.T) { }) t.Run("fails when NPM_TOKEN not set in non-dry-run", func(t *testing.T) { - // Ensure NPM_TOKEN is not set - oldToken := os.Getenv("NPM_TOKEN") - _ = os.Unsetenv("NPM_TOKEN") - defer func() { - if oldToken != "" { - _ = os.Setenv("NPM_TOKEN", oldToken) - } - }() + t.Setenv("NPM_TOKEN", "") release := &Release{ Version: "v1.0.0", @@ -256,7 +231,7 @@ func TestNpmPublisher_Publish_Bad(t *testing.T) { }) } -func TestNpmConfig_Defaults_Good(t *testing.T) { +func TestNpm_NpmConfigDefaults_Good(t *testing.T) { t.Run("has sensible defaults", func(t *testing.T) { p := NewNpmPublisher() pubCfg := PublisherConfig{Type: "npm"} @@ -269,7 +244,7 @@ func TestNpmConfig_Defaults_Good(t *testing.T) { }) } -func TestNpmTemplateData_Good(t *testing.T) { +func TestNpm_NpmTemplateData_Good(t *testing.T) { t.Run("struct has all expected fields", func(t *testing.T) { data := npmTemplateData{ Package: "@myorg/package", diff --git a/pkg/release/publishers/output.go b/pkg/release/publishers/output.go new file mode 100644 index 0000000..b043571 --- /dev/null +++ b/pkg/release/publishers/output.go @@ -0,0 +1,38 @@ +package publishers + +import ( + "context" + "io" + + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" +) + +var publisherStdout io.Writer +var publisherStderr io.Writer + +func publisherPrint(format string, args ...any) { + core.Print(publisherStdout, format, args...) +} + +func publisherPrintln(args ...any) { + if len(args) == 0 { + publisherPrint("") + return + } + + parts := make([]string, 0, len(args)) + for _, arg := range args { + parts = append(parts, core.Sprintf("%v", arg)) + } + + publisherPrint("%s", core.Join(" ", parts...)) +} + +func publisherRun(ctx context.Context, dir string, env []string, command string, args ...string) error { + output, err := ax.CombinedOutput(ctx, dir, env, command, args...) + if output != "" { + publisherPrint("%s", output) + } + return err +} diff --git a/pkg/release/publishers/publisher.go b/pkg/release/publishers/publisher.go index 4da5bcd..724b5d6 100644 --- a/pkg/release/publishers/publisher.go +++ b/pkg/release/publishers/publisher.go @@ -9,6 +9,7 @@ import ( ) // Release represents a release to be published. +// Usage example: declare a value of type publishers.Release in integrating code. type Release struct { // Version is the semantic version string (e.g., "v1.2.3"). Version string @@ -23,6 +24,7 @@ type Release struct { } // PublisherConfig holds configuration for a publisher. +// Usage example: declare a value of type publishers.PublisherConfig in integrating code. type PublisherConfig struct { // Type is the publisher type (e.g., "github", "linuxkit", "docker"). Type string @@ -35,12 +37,14 @@ type PublisherConfig struct { } // ReleaseConfig holds release configuration needed by publishers. +// Usage example: declare a value of type publishers.ReleaseConfig in integrating code. type ReleaseConfig interface { GetRepository() string GetProjectName() string } // Publisher defines the interface for release publishers. +// Usage example: declare a value of type publishers.Publisher in integrating code. type Publisher interface { // Name returns the publisher's identifier. Name() string @@ -51,6 +55,7 @@ type Publisher interface { // NewRelease creates a Release from the release package's Release type. // This is a helper to convert between packages. +// Usage example: call publishers.NewRelease(...) from integrating code. func NewRelease(version string, artifacts []build.Artifact, changelog, projectDir string, fs io.Medium) *Release { return &Release{ Version: version, @@ -62,6 +67,7 @@ func NewRelease(version string, artifacts []build.Artifact, changelog, projectDi } // NewPublisherConfig creates a PublisherConfig. +// Usage example: call publishers.NewPublisherConfig(...) from integrating code. func NewPublisherConfig(pubType string, prerelease, draft bool, extended any) PublisherConfig { return PublisherConfig{ Type: pubType, diff --git a/pkg/release/publishers/scoop.go b/pkg/release/publishers/scoop.go index 09437e8..c439613 100644 --- a/pkg/release/publishers/scoop.go +++ b/pkg/release/publishers/scoop.go @@ -5,13 +5,10 @@ import ( "bytes" "context" "embed" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" "text/template" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" @@ -21,6 +18,7 @@ import ( var scoopTemplates embed.FS // ScoopConfig holds Scoop-specific configuration. +// Usage example: declare a value of type publishers.ScoopConfig in integrating code. type ScoopConfig struct { // Bucket is the Scoop bucket repository (e.g., "host-uk/scoop-bucket"). Bucket string @@ -29,19 +27,23 @@ type ScoopConfig struct { } // ScoopPublisher publishes releases to Scoop. +// Usage example: declare a value of type publishers.ScoopPublisher in integrating code. type ScoopPublisher struct{} // NewScoopPublisher creates a new Scoop publisher. +// Usage example: call publishers.NewScoopPublisher(...) from integrating code. func NewScoopPublisher() *ScoopPublisher { return &ScoopPublisher{} } // Name returns the publisher's identifier. +// Usage example: call value.Name(...) from integrating code. func (p *ScoopPublisher) Name() string { return "scoop" } // Publish publishes the release to Scoop. +// Usage example: call value.Publish(...) from integrating code. func (p *ScoopPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { cfg := p.parseConfig(pubCfg, relCfg) @@ -66,16 +68,16 @@ func (p *ScoopPublisher) Publish(ctx context.Context, release *Release, pubCfg P projectName = relCfg.GetProjectName() } if projectName == "" { - parts := strings.Split(repo, "/") + parts := core.Split(repo, "/") projectName = parts[len(parts)-1] } - version := strings.TrimPrefix(release.Version, "v") + version := core.TrimPrefix(release.Version, "v") checksums := buildChecksumMap(release.Artifacts) data := scoopTemplateData{ PackageName: projectName, - Description: fmt.Sprintf("%s CLI", projectName), + Description: core.Sprintf("%s CLI", projectName), Repository: repo, Version: version, License: "MIT", @@ -122,37 +124,37 @@ func (p *ScoopPublisher) parseConfig(pubCfg PublisherConfig, relCfg ReleaseConfi } func (p *ScoopPublisher) dryRunPublish(m coreio.Medium, data scoopTemplateData, cfg ScoopConfig) error { - fmt.Println() - fmt.Println("=== DRY RUN: Scoop Publish ===") - fmt.Println() - fmt.Printf("Package: %s\n", data.PackageName) - fmt.Printf("Version: %s\n", data.Version) - fmt.Printf("Bucket: %s\n", cfg.Bucket) - fmt.Printf("Repository: %s\n", data.Repository) - fmt.Println() + publisherPrintln() + publisherPrintln("=== DRY RUN: Scoop Publish ===") + publisherPrintln() + publisherPrint("Package: %s", data.PackageName) + publisherPrint("Version: %s", data.Version) + publisherPrint("Bucket: %s", cfg.Bucket) + publisherPrint("Repository: %s", data.Repository) + publisherPrintln() manifest, err := p.renderTemplate(m, "templates/scoop/manifest.json.tmpl", data) if err != nil { return coreerr.E("scoop.dryRunPublish", "failed to render template", err) } - fmt.Println("Generated manifest.json:") - fmt.Println("---") - fmt.Println(manifest) - fmt.Println("---") - fmt.Println() + publisherPrintln("Generated manifest.json:") + publisherPrintln("---") + publisherPrintln(manifest) + publisherPrintln("---") + publisherPrintln() if cfg.Bucket != "" { - fmt.Printf("Would commit to bucket: %s\n", cfg.Bucket) + publisherPrint("Would commit to bucket: %s", cfg.Bucket) } if cfg.Official != nil && cfg.Official.Enabled { output := cfg.Official.Output if output == "" { output = "dist/scoop" } - fmt.Printf("Would write files for official PR to: %s\n", output) + publisherPrint("Would write files for official PR to: %s", output) } - fmt.Println() - fmt.Println("=== END DRY RUN ===") + publisherPrintln() + publisherPrintln("=== END DRY RUN ===") return nil } @@ -167,20 +169,20 @@ func (p *ScoopPublisher) executePublish(ctx context.Context, projectDir string, if cfg.Official != nil && cfg.Official.Enabled { output := cfg.Official.Output if output == "" { - output = filepath.Join(projectDir, "dist", "scoop") - } else if !filepath.IsAbs(output) { - output = filepath.Join(projectDir, output) + output = ax.Join(projectDir, "dist", "scoop") + } else if !ax.IsAbs(output) { + output = ax.Join(projectDir, output) } if err := release.FS.EnsureDir(output); err != nil { return coreerr.E("scoop.Publish", "failed to create output directory", err) } - manifestPath := filepath.Join(output, fmt.Sprintf("%s.json", data.PackageName)) + manifestPath := ax.Join(output, core.Sprintf("%s.json", data.PackageName)) if err := release.FS.Write(manifestPath, manifest); err != nil { return coreerr.E("scoop.Publish", "failed to write manifest", err) } - fmt.Printf("Wrote Scoop manifest for official PR: %s\n", manifestPath) + publisherPrint("Wrote Scoop manifest for official PR: %s", manifestPath) } // If bucket is configured, commit to it @@ -194,56 +196,43 @@ func (p *ScoopPublisher) executePublish(ctx context.Context, projectDir string, } func (p *ScoopPublisher) commitToBucket(ctx context.Context, bucket string, data scoopTemplateData, manifest string) error { - tmpDir, err := os.MkdirTemp("", "scoop-bucket-*") + tmpDir, err := ax.TempDir("scoop-bucket-*") if err != nil { return coreerr.E("scoop.commitToBucket", "failed to create temp directory", err) } - defer func() { _ = coreio.Local.DeleteAll(tmpDir) }() + defer func() { _ = ax.RemoveAll(tmpDir) }() - fmt.Printf("Cloning bucket %s...\n", bucket) - cmd := exec.CommandContext(ctx, "gh", "repo", "clone", bucket, tmpDir, "--", "--depth=1") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + publisherPrint("Cloning bucket %s...", bucket) + if err := publisherRun(ctx, "", nil, "gh", "repo", "clone", bucket, tmpDir, "--", "--depth=1"); err != nil { return coreerr.E("scoop.commitToBucket", "failed to clone bucket", err) } // Ensure bucket directory exists - bucketDir := filepath.Join(tmpDir, "bucket") + bucketDir := ax.Join(tmpDir, "bucket") if !coreio.Local.IsDir(bucketDir) { bucketDir = tmpDir // Some repos put manifests in root } - manifestPath := filepath.Join(bucketDir, fmt.Sprintf("%s.json", data.PackageName)) + manifestPath := ax.Join(bucketDir, core.Sprintf("%s.json", data.PackageName)) if err := coreio.Local.Write(manifestPath, manifest); err != nil { return coreerr.E("scoop.commitToBucket", "failed to write manifest", err) } - commitMsg := fmt.Sprintf("Update %s to %s", data.PackageName, data.Version) + commitMsg := core.Sprintf("Update %s to %s", data.PackageName, data.Version) - cmd = exec.CommandContext(ctx, "git", "add", ".") - cmd.Dir = tmpDir - if err := cmd.Run(); err != nil { + if err := ax.ExecDir(ctx, tmpDir, "git", "add", "."); err != nil { return coreerr.E("scoop.commitToBucket", "git add failed", err) } - cmd = exec.CommandContext(ctx, "git", "commit", "-m", commitMsg) - cmd.Dir = tmpDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, tmpDir, nil, "git", "commit", "-m", commitMsg); err != nil { return coreerr.E("scoop.commitToBucket", "git commit failed", err) } - cmd = exec.CommandContext(ctx, "git", "push") - cmd.Dir = tmpDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + if err := publisherRun(ctx, tmpDir, nil, "git", "push"); err != nil { return coreerr.E("scoop.commitToBucket", "git push failed", err) } - fmt.Printf("Updated Scoop bucket: %s\n", bucket) + publisherPrint("Updated Scoop bucket: %s", bucket) return nil } @@ -252,7 +241,7 @@ func (p *ScoopPublisher) renderTemplate(m coreio.Medium, name string, data scoop var err error // Try custom template from medium - customPath := filepath.Join(".core", name) + customPath := ax.Join(".core", name) if m != nil && m.IsFile(customPath) { customContent, err := m.Read(customPath) if err != nil { @@ -269,7 +258,7 @@ func (p *ScoopPublisher) renderTemplate(m coreio.Medium, name string, data scoop } } - tmpl, err := template.New(filepath.Base(name)).Parse(string(content)) + tmpl, err := template.New(ax.Base(name)).Parse(string(content)) if err != nil { return "", coreerr.E("scoop.renderTemplate", "failed to parse template "+name, err) } diff --git a/pkg/release/publishers/scoop_test.go b/pkg/release/publishers/scoop_test.go index abee811..5333832 100644 --- a/pkg/release/publishers/scoop_test.go +++ b/pkg/release/publishers/scoop_test.go @@ -1,9 +1,7 @@ package publishers import ( - "bytes" "context" - "os" "testing" "dappco.re/go/core/io" @@ -12,14 +10,14 @@ import ( "github.com/stretchr/testify/require" ) -func TestScoopPublisher_Name_Good(t *testing.T) { +func TestScoop_ScoopPublisherName_Good(t *testing.T) { t.Run("returns scoop", func(t *testing.T) { p := NewScoopPublisher() assert.Equal(t, "scoop", p.Name()) }) } -func TestScoopPublisher_ParseConfig_Good(t *testing.T) { +func TestScoop_ScoopPublisherParseConfig_Good(t *testing.T) { p := NewScoopPublisher() t.Run("uses defaults when no extended config", func(t *testing.T) { @@ -90,7 +88,7 @@ func TestScoopPublisher_ParseConfig_Good(t *testing.T) { }) } -func TestScoopPublisher_RenderTemplate_Good(t *testing.T) { +func TestScoop_ScoopPublisherRenderTemplate_Good(t *testing.T) { p := NewScoopPublisher() t.Run("renders manifest template with data", func(t *testing.T) { @@ -143,7 +141,7 @@ func TestScoopPublisher_RenderTemplate_Good(t *testing.T) { }) } -func TestScoopPublisher_RenderTemplate_Bad(t *testing.T) { +func TestScoop_ScoopPublisherRenderTemplate_Bad(t *testing.T) { p := NewScoopPublisher() t.Run("returns error for non-existent template", func(t *testing.T) { @@ -154,14 +152,10 @@ func TestScoopPublisher_RenderTemplate_Bad(t *testing.T) { }) } -func TestScoopPublisher_DryRunPublish_Good(t *testing.T) { +func TestScoop_ScoopPublisherDryRunPublish_Good(t *testing.T) { p := NewScoopPublisher() t.Run("outputs expected dry run information", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := scoopTemplateData{ PackageName: "myapp", Version: "1.0.0", @@ -173,15 +167,11 @@ func TestScoopPublisher_DryRunPublish_Good(t *testing.T) { Bucket: "owner/scoop-bucket", } - err := p.dryRunPublish(io.Local, data, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "DRY RUN: Scoop Publish") assert.Contains(t, output, "Package: myapp") @@ -194,10 +184,6 @@ func TestScoopPublisher_DryRunPublish_Good(t *testing.T) { }) t.Run("shows official output path when enabled", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := scoopTemplateData{ PackageName: "myapp", Version: "1.0.0", @@ -211,23 +197,15 @@ func TestScoopPublisher_DryRunPublish_Good(t *testing.T) { }, } - err := p.dryRunPublish(io.Local, data, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Would write files for official PR to: custom/scoop/path") }) t.Run("uses default official output path when not specified", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - data := scoopTemplateData{ PackageName: "myapp", Version: "1.0.0", @@ -240,20 +218,16 @@ func TestScoopPublisher_DryRunPublish_Good(t *testing.T) { }, } - err := p.dryRunPublish(io.Local, data, cfg) - - _ = w.Close() - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - os.Stdout = oldStdout - + var err error + output := capturePublisherOutput(t, func() { + err = p.dryRunPublish(io.Local, data, cfg) + }) require.NoError(t, err) - output := buf.String() assert.Contains(t, output, "Would write files for official PR to: dist/scoop") }) } -func TestScoopPublisher_Publish_Bad(t *testing.T) { +func TestScoop_ScoopPublisherPublish_Bad(t *testing.T) { p := NewScoopPublisher() t.Run("fails when bucket not configured and not official mode", func(t *testing.T) { @@ -271,7 +245,7 @@ func TestScoopPublisher_Publish_Bad(t *testing.T) { }) } -func TestScoopConfig_Defaults_Good(t *testing.T) { +func TestScoop_ScoopConfigDefaults_Good(t *testing.T) { t.Run("has sensible defaults", func(t *testing.T) { p := NewScoopPublisher() pubCfg := PublisherConfig{Type: "scoop"} @@ -284,7 +258,7 @@ func TestScoopConfig_Defaults_Good(t *testing.T) { }) } -func TestScoopTemplateData_Good(t *testing.T) { +func TestScoop_ScoopTemplateData_Good(t *testing.T) { t.Run("struct has all expected fields", func(t *testing.T) { data := scoopTemplateData{ PackageName: "myapp", diff --git a/pkg/release/publishers/test_helpers_test.go b/pkg/release/publishers/test_helpers_test.go new file mode 100644 index 0000000..7464cbf --- /dev/null +++ b/pkg/release/publishers/test_helpers_test.go @@ -0,0 +1,32 @@ +package publishers + +import ( + "bytes" + "context" + "testing" + + "dappco.re/go/core/build/internal/ax" + "github.com/stretchr/testify/require" +) + +func capturePublisherOutput(t *testing.T, fn func()) string { + t.Helper() + + var buf bytes.Buffer + oldStdout := publisherStdout + oldStderr := publisherStderr + publisherStdout = &buf + publisherStderr = &buf + defer func() { + publisherStdout = oldStdout + publisherStderr = oldStderr + }() + + fn() + return buf.String() +} + +func runPublisherCommand(t *testing.T, dir, command string, args ...string) { + t.Helper() + require.NoError(t, ax.ExecDir(context.Background(), dir, command, args...)) +} diff --git a/pkg/release/release.go b/pkg/release/release.go index 603a1df..4a57b62 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -5,10 +5,9 @@ package release import ( "context" - "fmt" - "path/filepath" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/build/pkg/build/builders" "dappco.re/go/core/build/pkg/release/publishers" @@ -17,6 +16,7 @@ import ( ) // Release represents a release with its version, artifacts, and changelog. +// Usage example: declare a value of type release.Release in integrating code. type Release struct { // Version is the semantic version string (e.g., "v1.2.3"). Version string @@ -33,6 +33,7 @@ type Release struct { // Publish publishes pre-built artifacts from dist/ to configured targets. // Use this after `core build` to separate build and publish concerns. // If dryRun is true, it will show what would be done without actually publishing. +// Usage example: call release.Publish(...) from integrating code. func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { if cfg == nil { return nil, coreerr.E("release.Publish", "config is nil", nil) @@ -46,7 +47,7 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { } // Resolve to absolute path - absProjectDir, err := filepath.Abs(projectDir) + absProjectDir, err := ax.Abs(projectDir) if err != nil { return nil, coreerr.E("release.Publish", "failed to resolve project directory", err) } @@ -61,7 +62,7 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { } // Step 2: Find pre-built artifacts in dist/ - distDir := filepath.Join(absProjectDir, "dist") + distDir := ax.Join(absProjectDir, "dist") artifacts, err := findArtifacts(m, distDir) if err != nil { return nil, coreerr.E("release.Publish", "failed to find artifacts", err) @@ -75,7 +76,7 @@ func Publish(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { changelog, err := Generate(absProjectDir, "", version) if err != nil { // Non-fatal: continue with empty changelog - changelog = fmt.Sprintf("Release %s", version) + changelog = core.Sprintf("Release %s", version) } release := &Release{ @@ -126,13 +127,13 @@ func findArtifacts(m io.Medium, distDir string) ([]build.Artifact, error) { } name := entry.Name() - path := filepath.Join(distDir, name) + path := ax.Join(distDir, name) // Include archives and checksums - if strings.HasSuffix(name, ".tar.gz") || - strings.HasSuffix(name, ".zip") || - strings.HasSuffix(name, ".txt") || - strings.HasSuffix(name, ".sig") { + if core.HasSuffix(name, ".tar.gz") || + core.HasSuffix(name, ".zip") || + core.HasSuffix(name, ".txt") || + core.HasSuffix(name, ".sig") { artifacts = append(artifacts, build.Artifact{Path: path}) } } @@ -144,6 +145,7 @@ func findArtifacts(m io.Medium, distDir string) ([]build.Artifact, error) { // generate changelog, and publish to configured targets. // For separated concerns, prefer using `core build` then `core ci` (Publish). // If dryRun is true, it will show what would be done without actually publishing. +// Usage example: call release.Run(...) from integrating code. func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { if cfg == nil { return nil, coreerr.E("release.Run", "config is nil", nil) @@ -157,7 +159,7 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { } // Resolve to absolute path - absProjectDir, err := filepath.Abs(projectDir) + absProjectDir, err := ax.Abs(projectDir) if err != nil { return nil, coreerr.E("release.Run", "failed to resolve project directory", err) } @@ -175,7 +177,7 @@ func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { changelog, err := Generate(absProjectDir, "", version) if err != nil { // Non-fatal: continue with empty changelog - changelog = fmt.Sprintf("Release %s", version) + changelog = core.Sprintf("Release %s", version) } // Step 3: Build artifacts @@ -250,11 +252,11 @@ func buildArtifacts(ctx context.Context, fs io.Medium, cfg *Config, projectDir, binaryName = buildCfg.Project.Name } if binaryName == "" { - binaryName = filepath.Base(projectDir) + binaryName = ax.Base(projectDir) } // Determine output directory - outputDir := filepath.Join(projectDir, "dist") + outputDir := ax.Join(projectDir, "dist") // Get builder (detect project type) projectType, err := build.PrimaryType(fs, projectDir) @@ -296,7 +298,7 @@ func buildArtifacts(ctx context.Context, fs io.Medium, cfg *Config, projectDir, } // Write CHECKSUMS.txt - checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") + checksumPath := ax.Join(outputDir, "CHECKSUMS.txt") if err := build.WriteChecksumFile(fs, checksummedArtifacts, checksumPath); err != nil { return nil, coreerr.E("release.buildArtifacts", "failed to write checksums file", err) } diff --git a/pkg/release/release_test.go b/pkg/release/release_test.go index afa1e64..c31ace8 100644 --- a/pkg/release/release_test.go +++ b/pkg/release/release_test.go @@ -2,26 +2,24 @@ package release import ( "context" - "os" - "os/exec" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/build" "dappco.re/go/core/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestFindArtifacts_Good(t *testing.T) { +func TestRelease_FindArtifacts_Good(t *testing.T) { t.Run("finds tar.gz artifacts", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) // Create test artifact files - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-linux-amd64.tar.gz"), []byte("test"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-darwin-arm64.tar.gz"), []byte("test"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app-linux-amd64.tar.gz"), []byte("test"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app-darwin-arm64.tar.gz"), []byte("test"), 0644)) artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) @@ -31,10 +29,10 @@ func TestFindArtifacts_Good(t *testing.T) { t.Run("finds zip artifacts", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-windows-amd64.zip"), []byte("test"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app-windows-amd64.zip"), []byte("test"), 0644)) artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) @@ -45,10 +43,10 @@ func TestFindArtifacts_Good(t *testing.T) { t.Run("finds checksum files", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "CHECKSUMS.txt"), []byte("checksums"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "CHECKSUMS.txt"), []byte("checksums"), 0644)) artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) @@ -59,10 +57,10 @@ func TestFindArtifacts_Good(t *testing.T) { t.Run("finds signature files", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz.sig"), []byte("signature"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz.sig"), []byte("signature"), 0644)) artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) @@ -72,13 +70,13 @@ func TestFindArtifacts_Good(t *testing.T) { t.Run("finds mixed artifact types", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-linux.tar.gz"), []byte("test"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-windows.zip"), []byte("test"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "CHECKSUMS.txt"), []byte("checksums"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.sig"), []byte("sig"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app-linux.tar.gz"), []byte("test"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app-windows.zip"), []byte("test"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "CHECKSUMS.txt"), []byte("checksums"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.sig"), []byte("sig"), 0644)) artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) @@ -88,12 +86,12 @@ func TestFindArtifacts_Good(t *testing.T) { t.Run("ignores non-artifact files", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "README.md"), []byte("readme"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.exe"), []byte("binary"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("artifact"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "README.md"), []byte("readme"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.exe"), []byte("binary"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz"), []byte("artifact"), 0644)) artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) @@ -104,12 +102,12 @@ func TestFindArtifacts_Good(t *testing.T) { t.Run("ignores subdirectories", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.MkdirAll(filepath.Join(distDir, "subdir"), 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) + require.NoError(t, ax.MkdirAll(ax.Join(distDir, "subdir"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("artifact"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "subdir", "nested.tar.gz"), []byte("nested"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz"), []byte("artifact"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "subdir", "nested.tar.gz"), []byte("nested"), 0644)) artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) @@ -120,8 +118,8 @@ func TestFindArtifacts_Good(t *testing.T) { t.Run("returns empty slice for empty dist directory", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) artifacts, err := findArtifacts(io.Local, distDir) require.NoError(t, err) @@ -130,10 +128,10 @@ func TestFindArtifacts_Good(t *testing.T) { }) } -func TestFindArtifacts_Bad(t *testing.T) { +func TestRelease_FindArtifacts_Bad(t *testing.T) { t.Run("returns error when dist directory does not exist", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") + distDir := ax.Join(dir, "dist") _, err := findArtifacts(io.Local, distDir) assert.Error(t, err) @@ -141,17 +139,17 @@ func TestFindArtifacts_Bad(t *testing.T) { }) t.Run("returns error when dist directory is unreadable", func(t *testing.T) { - if os.Geteuid() == 0 { + if ax.Geteuid() == 0 { t.Skip("root can read any directory") } dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) // Create a file that looks like dist but will cause ReadDir to fail // by making the directory unreadable - require.NoError(t, os.Chmod(distDir, 0000)) - defer func() { _ = os.Chmod(distDir, 0755) }() + require.NoError(t, ax.Chmod(distDir, 0000)) + defer func() { _ = ax.Chmod(distDir, 0755) }() _, err := findArtifacts(io.Local, distDir) assert.Error(t, err) @@ -159,7 +157,7 @@ func TestFindArtifacts_Bad(t *testing.T) { }) } -func TestGetBuilder_Good(t *testing.T) { +func TestRelease_GetBuilder_Good(t *testing.T) { t.Run("returns Go builder for go project type", func(t *testing.T) { builder, err := getBuilder(build.ProjectTypeGo) require.NoError(t, err) @@ -175,7 +173,7 @@ func TestGetBuilder_Good(t *testing.T) { }) } -func TestGetBuilder_Bad(t *testing.T) { +func TestRelease_GetBuilder_Bad(t *testing.T) { t.Run("returns error for Node project type", func(t *testing.T) { _, err := getBuilder(build.ProjectTypeNode) assert.Error(t, err) @@ -195,7 +193,7 @@ func TestGetBuilder_Bad(t *testing.T) { }) } -func TestGetPublisher_Good(t *testing.T) { +func TestRelease_GetPublisher_Good(t *testing.T) { tests := []struct { pubType string expectedName string @@ -220,7 +218,7 @@ func TestGetPublisher_Good(t *testing.T) { } } -func TestGetPublisher_Bad(t *testing.T) { +func TestRelease_GetPublisher_Bad(t *testing.T) { t.Run("returns error for unsupported publisher type", func(t *testing.T) { _, err := getPublisher("unsupported") assert.Error(t, err) @@ -234,7 +232,7 @@ func TestGetPublisher_Bad(t *testing.T) { }) } -func TestBuildExtendedConfig_Good(t *testing.T) { +func TestRelease_BuildExtendedConfig_Good(t *testing.T) { t.Run("returns empty map for minimal config", func(t *testing.T) { cfg := PublisherConfig{ Type: "github", @@ -371,7 +369,7 @@ func TestBuildExtendedConfig_Good(t *testing.T) { }) } -func TestToAnySlice_Good(t *testing.T) { +func TestRelease_ToAnySlice_Good(t *testing.T) { t.Run("converts string slice to any slice", func(t *testing.T) { input := []string{"a", "b", "c"} @@ -401,12 +399,12 @@ func TestToAnySlice_Good(t *testing.T) { }) } -func TestPublish_Good(t *testing.T) { +func TestRelease_Publish_Good(t *testing.T) { t.Run("returns release with version from config", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -422,11 +420,11 @@ func TestPublish_Good(t *testing.T) { t.Run("finds artifacts in dist directory", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-linux.tar.gz"), []byte("test"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app-darwin.tar.gz"), []byte("test"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "CHECKSUMS.txt"), []byte("checksums"), 0644)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app-linux.tar.gz"), []byte("test"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app-darwin.tar.gz"), []byte("test"), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "CHECKSUMS.txt"), []byte("checksums"), 0644)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -440,7 +438,7 @@ func TestPublish_Good(t *testing.T) { }) } -func TestPublish_Bad(t *testing.T) { +func TestRelease_Publish_Bad(t *testing.T) { t.Run("returns error when config is nil", func(t *testing.T) { _, err := Publish(context.Background(), nil, true) assert.Error(t, err) @@ -461,8 +459,8 @@ func TestPublish_Bad(t *testing.T) { t.Run("returns error when no artifacts found", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -475,9 +473,9 @@ func TestPublish_Bad(t *testing.T) { t.Run("returns error for unsupported publisher", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -493,9 +491,9 @@ func TestPublish_Bad(t *testing.T) { t.Run("returns error when version determination fails in non-git dir", func(t *testing.T) { dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -510,7 +508,7 @@ func TestPublish_Bad(t *testing.T) { }) } -func TestRun_Good(t *testing.T) { +func TestRelease_Run_Good(t *testing.T) { t.Run("returns release with version from config", func(t *testing.T) { // Create a minimal Go project for testing dir := t.TempDir() @@ -520,14 +518,14 @@ func TestRun_Good(t *testing.T) { go 1.21 ` - require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "go.mod"), []byte(goMod), 0644)) // Create main.go mainGo := `package main func main() {} ` - require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0644)) + require.NoError(t, ax.WriteFile(ax.Join(dir, "main.go"), []byte(mainGo), 0644)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -548,7 +546,7 @@ func main() {} }) } -func TestRun_Bad(t *testing.T) { +func TestRelease_Run_Bad(t *testing.T) { t.Run("returns error when config is nil", func(t *testing.T) { _, err := Run(context.Background(), nil, true) assert.Error(t, err) @@ -556,7 +554,7 @@ func TestRun_Bad(t *testing.T) { }) } -func TestRelease_Structure(t *testing.T) { +func TestRelease_Structure_Good(t *testing.T) { t.Run("Release struct holds expected fields", func(t *testing.T) { release := &Release{ Version: "v1.0.0", @@ -572,16 +570,16 @@ func TestRelease_Structure(t *testing.T) { }) } -func TestPublish_VersionFromGit(t *testing.T) { +func TestRelease_PublishVersionFromGit_Good(t *testing.T) { t.Run("determines version from git when not set", func(t *testing.T) { dir := setupPublishGitRepo(t) createPublishCommit(t, dir, "feat: initial commit") createPublishTag(t, dir, "v1.2.3") // Create dist directory with artifact - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -595,7 +593,7 @@ func TestPublish_VersionFromGit(t *testing.T) { }) } -func TestPublish_ChangelogGeneration(t *testing.T) { +func TestRelease_PublishChangelogGeneration_Good(t *testing.T) { t.Run("generates changelog from git commits when available", func(t *testing.T) { dir := setupPublishGitRepo(t) createPublishCommit(t, dir, "feat: add feature") @@ -604,9 +602,9 @@ func TestPublish_ChangelogGeneration(t *testing.T) { createPublishTag(t, dir, "v1.0.1") // Create dist directory with artifact - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -622,9 +620,9 @@ func TestPublish_ChangelogGeneration(t *testing.T) { t.Run("uses fallback changelog on error", func(t *testing.T) { dir := t.TempDir() // Not a git repo - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -639,13 +637,13 @@ func TestPublish_ChangelogGeneration(t *testing.T) { }) } -func TestPublish_DefaultProjectDir(t *testing.T) { +func TestRelease_PublishDefaultProjectDir_Good(t *testing.T) { t.Run("uses current directory when projectDir is empty", func(t *testing.T) { // Create artifacts in current directory's dist folder dir := t.TempDir() - distDir := filepath.Join(dir, "dist") - require.NoError(t, os.MkdirAll(distDir, 0755)) - require.NoError(t, os.WriteFile(filepath.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) + distDir := ax.Join(dir, "dist") + require.NoError(t, ax.MkdirAll(distDir, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(distDir, "app.tar.gz"), []byte("test"), 0644)) cfg := DefaultConfig() cfg.SetProjectDir(dir) @@ -664,17 +662,9 @@ func setupPublishGitRepo(t *testing.T) string { t.Helper() dir := t.TempDir() - cmd := exec.Command("git", "init") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "config", "user.email", "test@example.com") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "config", "user.name", "Test User") - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "init") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test User") return dir } @@ -682,23 +672,16 @@ func setupPublishGitRepo(t *testing.T) string { func createPublishCommit(t *testing.T, dir, message string) { t.Helper() - filePath := filepath.Join(dir, "publish_test.txt") - content, _ := os.ReadFile(filePath) + filePath := ax.Join(dir, "publish_test.txt") + content, _ := ax.ReadFile(filePath) content = append(content, []byte(message+"\n")...) - require.NoError(t, os.WriteFile(filePath, content, 0644)) + require.NoError(t, ax.WriteFile(filePath, content, 0644)) - cmd := exec.Command("git", "add", ".") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "commit", "-m", message) - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", message) } func createPublishTag(t *testing.T, dir, tag string) { t.Helper() - cmd := exec.Command("git", "tag", tag) - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "tag", tag) } diff --git a/pkg/release/sdk.go b/pkg/release/sdk.go index 26e0aa8..4b91fac 100644 --- a/pkg/release/sdk.go +++ b/pkg/release/sdk.go @@ -3,13 +3,14 @@ package release import ( "context" - "fmt" + "dappco.re/go/core" "dappco.re/go/core/build/pkg/sdk" coreerr "dappco.re/go/core/log" ) // SDKRelease holds the result of an SDK release. +// Usage example: declare a value of type release.SDKRelease in integrating code. type SDKRelease struct { // Version is the SDK version. Version string @@ -21,6 +22,7 @@ type SDKRelease struct { // RunSDK executes SDK-only release: diff check + generate. // If dryRun is true, it shows what would be done without generating. +// Usage example: call release.RunSDK(...) from integrating code. func RunSDK(ctx context.Context, cfg *Config, dryRun bool) (*SDKRelease, error) { if cfg == nil { return nil, coreerr.E("release.RunSDK", "config is nil", nil) @@ -49,12 +51,12 @@ func RunSDK(ctx context.Context, cfg *Config, dryRun bool) (*SDKRelease, error) breaking, err := checkBreakingChanges(projectDir, cfg.SDK) if err != nil { // Non-fatal: warn and continue - fmt.Printf("Warning: diff check failed: %v\n", err) + core.Print(nil, "Warning: diff check failed: %v", err) } else if breaking { if cfg.SDK.Diff.FailOnBreaking { return nil, coreerr.E("release.RunSDK", "breaking API changes detected", nil) } - fmt.Printf("Warning: breaking API changes detected\n") + core.Print(nil, "Warning: breaking API changes detected") } } diff --git a/pkg/release/sdk_test.go b/pkg/release/sdk_test.go index f800beb..14130fa 100644 --- a/pkg/release/sdk_test.go +++ b/pkg/release/sdk_test.go @@ -8,13 +8,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestRunSDK_Bad_NilConfig(t *testing.T) { +func TestSDK_RunSDKNilConfig_Bad(t *testing.T) { _, err := RunSDK(context.Background(), nil, true) assert.Error(t, err) assert.Contains(t, err.Error(), "config is nil") } -func TestRunSDK_Bad_NoSDKConfig(t *testing.T) { +func TestSDK_RunSDKNoSDKConfig_Bad(t *testing.T) { cfg := &Config{ SDK: nil, } @@ -25,7 +25,7 @@ func TestRunSDK_Bad_NoSDKConfig(t *testing.T) { assert.Contains(t, err.Error(), "sdk not configured") } -func TestRunSDK_Good_DryRun(t *testing.T) { +func TestSDK_RunSDKDryRun_Good(t *testing.T) { cfg := &Config{ SDK: &SDKConfig{ Languages: []string{"typescript", "python"}, @@ -45,7 +45,7 @@ func TestRunSDK_Good_DryRun(t *testing.T) { assert.Equal(t, "sdk", result.Output) } -func TestRunSDK_Good_DryRunDefaultOutput(t *testing.T) { +func TestSDK_RunSDKDryRunDefaultOutput_Good(t *testing.T) { cfg := &Config{ SDK: &SDKConfig{ Languages: []string{"go"}, @@ -61,7 +61,7 @@ func TestRunSDK_Good_DryRunDefaultOutput(t *testing.T) { assert.Equal(t, "sdk", result.Output) } -func TestRunSDK_Good_DryRunDefaultProjectDir(t *testing.T) { +func TestSDK_RunSDKDryRunDefaultProjectDir_Good(t *testing.T) { cfg := &Config{ SDK: &SDKConfig{ Languages: []string{"typescript"}, @@ -77,7 +77,7 @@ func TestRunSDK_Good_DryRunDefaultProjectDir(t *testing.T) { assert.Equal(t, "v1.0.0", result.Version) } -func TestRunSDK_Bad_BreakingChangesFailOnBreaking(t *testing.T) { +func TestSDK_RunSDKBreakingChangesFailOnBreaking_Bad(t *testing.T) { // This test verifies that when diff.FailOnBreaking is true and breaking changes // are detected, RunSDK returns an error. However, since we can't easily mock // the diff check, this test verifies the config is correctly processed. @@ -102,7 +102,7 @@ func TestRunSDK_Bad_BreakingChangesFailOnBreaking(t *testing.T) { assert.Equal(t, "v1.0.0", result.Version) } -func TestToSDKConfig_Good(t *testing.T) { +func TestSDK_ToSDKConfig_Good(t *testing.T) { sdkCfg := &SDKConfig{ Spec: "api/openapi.yaml", Languages: []string{"typescript", "go"}, @@ -128,12 +128,12 @@ func TestToSDKConfig_Good(t *testing.T) { assert.True(t, result.Diff.FailOnBreaking) } -func TestToSDKConfig_Good_NilInput(t *testing.T) { +func TestSDK_ToSDKConfigNilInput_Good(t *testing.T) { result := toSDKConfig(nil) assert.Nil(t, result) } -func TestRunSDK_Good_WithDiffEnabledNoFailOnBreaking(t *testing.T) { +func TestSDK_RunSDKWithDiffEnabledNoFailOnBreaking_Good(t *testing.T) { // Tests diff enabled but FailOnBreaking=false (should warn but not fail) cfg := &Config{ SDK: &SDKConfig{ @@ -155,7 +155,7 @@ func TestRunSDK_Good_WithDiffEnabledNoFailOnBreaking(t *testing.T) { assert.Contains(t, result.Languages, "typescript") } -func TestRunSDK_Good_MultipleLanguages(t *testing.T) { +func TestSDK_RunSDKMultipleLanguages_Good(t *testing.T) { // Tests multiple language support cfg := &Config{ SDK: &SDKConfig{ @@ -174,7 +174,7 @@ func TestRunSDK_Good_MultipleLanguages(t *testing.T) { assert.Equal(t, "multi-sdk", result.Output) } -func TestRunSDK_Good_WithPackageConfig(t *testing.T) { +func TestSDK_RunSDKWithPackageConfig_Good(t *testing.T) { // Tests that package config is properly handled cfg := &Config{ SDK: &SDKConfig{ @@ -195,7 +195,7 @@ func TestRunSDK_Good_WithPackageConfig(t *testing.T) { assert.Equal(t, "v1.0.0", result.Version) } -func TestToSDKConfig_Good_EmptyPackageConfig(t *testing.T) { +func TestSDK_ToSDKConfigEmptyPackageConfig_Good(t *testing.T) { // Tests conversion with empty package config sdkCfg := &SDKConfig{ Languages: []string{"go"}, @@ -211,7 +211,7 @@ func TestToSDKConfig_Good_EmptyPackageConfig(t *testing.T) { assert.Empty(t, result.Package.Version) } -func TestToSDKConfig_Good_DiffDisabled(t *testing.T) { +func TestSDK_ToSDKConfigDiffDisabled_Good(t *testing.T) { // Tests conversion with diff disabled sdkCfg := &SDKConfig{ Languages: []string{"typescript"}, diff --git a/pkg/release/test_helpers_test.go b/pkg/release/test_helpers_test.go new file mode 100644 index 0000000..79987c4 --- /dev/null +++ b/pkg/release/test_helpers_test.go @@ -0,0 +1,14 @@ +package release + +import ( + "context" + "testing" + + "dappco.re/go/core/build/internal/ax" + "github.com/stretchr/testify/require" +) + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + require.NoError(t, ax.ExecDir(context.Background(), dir, "git", args...)) +} diff --git a/pkg/release/version.go b/pkg/release/version.go index a6066b9..0b5d601 100644 --- a/pkg/release/version.go +++ b/pkg/release/version.go @@ -2,12 +2,12 @@ package release import ( - "fmt" - "os/exec" + "context" "regexp" "strconv" - "strings" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" coreerr "dappco.re/go/core/log" ) @@ -19,6 +19,8 @@ var semverRegex = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+) // 1. Git tag on HEAD // 2. Most recent tag + increment patch // 3. Default to v0.0.1 if no tags exist +// +// Usage example: call release.DetermineVersion(...) from integrating code. func DetermineVersion(dir string) (string, error) { // Check if HEAD has a tag headTag, err := getTagOnHead(dir) @@ -42,6 +44,8 @@ func DetermineVersion(dir string) (string, error) { // - "v1.2.3" -> "v1.2.4" // - "1.2.3" -> "v1.2.4" // - "v1.2.3-alpha" -> "v1.2.4" (strips prerelease) +// +// Usage example: call release.IncrementVersion(...) from integrating code. func IncrementVersion(current string) string { matches := semverRegex.FindStringSubmatch(current) if matches == nil { @@ -56,13 +60,15 @@ func IncrementVersion(current string) string { // Increment patch patch++ - return fmt.Sprintf("v%d.%d.%d", major, minor, patch) + return core.Sprintf("v%d.%d.%d", major, minor, patch) } // IncrementMinor increments the minor version of a semver string. // Examples: // - "v1.2.3" -> "v1.3.0" // - "1.2.3" -> "v1.3.0" +// +// Usage example: call release.IncrementMinor(...) from integrating code. func IncrementMinor(current string) string { matches := semverRegex.FindStringSubmatch(current) if matches == nil { @@ -75,13 +81,15 @@ func IncrementMinor(current string) string { // Increment minor, reset patch minor++ - return fmt.Sprintf("v%d.%d.0", major, minor) + return core.Sprintf("v%d.%d.0", major, minor) } // IncrementMajor increments the major version of a semver string. // Examples: // - "v1.2.3" -> "v2.0.0" // - "1.2.3" -> "v2.0.0" +// +// Usage example: call release.IncrementMajor(...) from integrating code. func IncrementMajor(current string) string { matches := semverRegex.FindStringSubmatch(current) if matches == nil { @@ -93,11 +101,12 @@ func IncrementMajor(current string) string { // Increment major, reset minor and patch major++ - return fmt.Sprintf("v%d.0.0", major) + return core.Sprintf("v%d.0.0", major) } // ParseVersion parses a semver string into its components. // Returns (major, minor, patch, prerelease, build, error). +// Usage example: call release.ParseVersion(...) from integrating code. func ParseVersion(version string) (int, int, int, string, string, error) { matches := semverRegex.FindStringSubmatch(version) if matches == nil { @@ -114,13 +123,14 @@ func ParseVersion(version string) (int, int, int, string, string, error) { } // ValidateVersion checks if a string is a valid semver. +// Usage example: call release.ValidateVersion(...) from integrating code. func ValidateVersion(version string) bool { return semverRegex.MatchString(version) } // normalizeVersion ensures the version starts with 'v'. func normalizeVersion(version string) string { - if !strings.HasPrefix(version, "v") { + if !core.HasPrefix(version, "v") { return "v" + version } return version @@ -128,24 +138,20 @@ func normalizeVersion(version string) string { // getTagOnHead returns the tag on HEAD, if any. func getTagOnHead(dir string) (string, error) { - cmd := exec.Command("git", "describe", "--tags", "--exact-match", "HEAD") - cmd.Dir = dir - output, err := cmd.Output() + output, err := ax.RunDir(context.Background(), dir, "git", "describe", "--tags", "--exact-match", "HEAD") if err != nil { return "", err } - return strings.TrimSpace(string(output)), nil + return core.Trim(output), nil } // getLatestTag returns the most recent tag in the repository. func getLatestTag(dir string) (string, error) { - cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") - cmd.Dir = dir - output, err := cmd.Output() + output, err := ax.RunDir(context.Background(), dir, "git", "describe", "--tags", "--abbrev=0") if err != nil { return "", err } - return strings.TrimSpace(string(output)), nil + return core.Trim(output), nil } // CompareVersions compares two semver strings. @@ -154,13 +160,22 @@ func getLatestTag(dir string) (string, error) { // -1 if a < b // 0 if a == b // 1 if a > b +// +// Usage example: call release.CompareVersions(...) from integrating code. func CompareVersions(a, b string) int { aMajor, aMinor, aPatch, _, _, errA := ParseVersion(a) bMajor, bMinor, bPatch, _, _, errB := ParseVersion(b) // Invalid versions are considered less than valid ones if errA != nil && errB != nil { - return strings.Compare(a, b) + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } } if errA != nil { return -1 diff --git a/pkg/release/version_test.go b/pkg/release/version_test.go index b170a98..50d23fd 100644 --- a/pkg/release/version_test.go +++ b/pkg/release/version_test.go @@ -1,11 +1,10 @@ package release import ( - "os" - "os/exec" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,18 +15,11 @@ func setupGitRepo(t *testing.T) string { dir := t.TempDir() // Initialize git repo - cmd := exec.Command("git", "init") - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "init") // Configure git user for commits - cmd = exec.Command("git", "config", "user.email", "test@example.com") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "config", "user.name", "Test User") - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test User") return dir } @@ -37,30 +29,23 @@ func createCommit(t *testing.T, dir, message string) { t.Helper() // Create or modify a file - filePath := filepath.Join(dir, "test.txt") - content, _ := os.ReadFile(filePath) + filePath := ax.Join(dir, "test.txt") + content, _ := ax.ReadFile(filePath) content = append(content, []byte(message+"\n")...) - require.NoError(t, os.WriteFile(filePath, content, 0644)) + require.NoError(t, ax.WriteFile(filePath, content, 0644)) // Stage and commit - cmd := exec.Command("git", "add", ".") - cmd.Dir = dir - require.NoError(t, cmd.Run()) - - cmd = exec.Command("git", "commit", "-m", message) - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", message) } // createTag creates a tag in the given directory. func createTag(t *testing.T, dir, tag string) { t.Helper() - cmd := exec.Command("git", "tag", tag) - cmd.Dir = dir - require.NoError(t, cmd.Run()) + runGit(t, dir, "tag", tag) } -func TestDetermineVersion_Good(t *testing.T) { +func TestVersion_DetermineVersion_Good(t *testing.T) { t.Run("returns tag when HEAD has tag", func(t *testing.T) { dir := setupGitRepo(t) createCommit(t, dir, "feat: initial commit") @@ -115,7 +100,7 @@ func TestDetermineVersion_Good(t *testing.T) { }) } -func TestDetermineVersion_Bad(t *testing.T) { +func TestVersion_DetermineVersion_Bad(t *testing.T) { t.Run("returns v0.0.1 for empty repo", func(t *testing.T) { dir := setupGitRepo(t) @@ -126,7 +111,7 @@ func TestDetermineVersion_Bad(t *testing.T) { }) } -func TestGetTagOnHead_Good(t *testing.T) { +func TestVersion_GetTagOnHead_Good(t *testing.T) { t.Run("returns tag when HEAD has tag", func(t *testing.T) { dir := setupGitRepo(t) createCommit(t, dir, "feat: initial commit") @@ -150,7 +135,7 @@ func TestGetTagOnHead_Good(t *testing.T) { }) } -func TestGetTagOnHead_Bad(t *testing.T) { +func TestVersion_GetTagOnHead_Bad(t *testing.T) { t.Run("returns error when HEAD has no tag", func(t *testing.T) { dir := setupGitRepo(t) createCommit(t, dir, "feat: initial commit") @@ -170,7 +155,7 @@ func TestGetTagOnHead_Bad(t *testing.T) { }) } -func TestGetLatestTag_Good(t *testing.T) { +func TestVersion_GetLatestTag_Good(t *testing.T) { t.Run("returns latest tag", func(t *testing.T) { dir := setupGitRepo(t) createCommit(t, dir, "feat: initial commit") @@ -195,7 +180,7 @@ func TestGetLatestTag_Good(t *testing.T) { }) } -func TestGetLatestTag_Bad(t *testing.T) { +func TestVersion_GetLatestTag_Bad(t *testing.T) { t.Run("returns error when no tags exist", func(t *testing.T) { dir := setupGitRepo(t) createCommit(t, dir, "feat: initial commit") @@ -212,21 +197,21 @@ func TestGetLatestTag_Bad(t *testing.T) { }) } -func TestIncrementMinor_Bad(t *testing.T) { +func TestVersion_IncrementMinor_Bad(t *testing.T) { t.Run("returns fallback for invalid version", func(t *testing.T) { result := IncrementMinor("not-valid") assert.Equal(t, "not-valid.1", result) }) } -func TestIncrementMajor_Bad(t *testing.T) { +func TestVersion_IncrementMajor_Bad(t *testing.T) { t.Run("returns fallback for invalid version", func(t *testing.T) { result := IncrementMajor("not-valid") assert.Equal(t, "not-valid.1", result) }) } -func TestCompareVersions_Ugly(t *testing.T) { +func TestVersion_CompareVersions_Ugly(t *testing.T) { t.Run("handles both invalid versions", func(t *testing.T) { result := CompareVersions("invalid-a", "invalid-b") // Should do string comparison for invalid versions @@ -244,7 +229,7 @@ func TestCompareVersions_Ugly(t *testing.T) { }) } -func TestIncrementVersion_Good(t *testing.T) { +func TestVersion_IncrementVersion_Good(t *testing.T) { tests := []struct { name string input string @@ -295,14 +280,14 @@ func TestIncrementVersion_Good(t *testing.T) { } } -func TestIncrementVersion_Bad(t *testing.T) { +func TestVersion_IncrementVersion_Bad(t *testing.T) { t.Run("invalid semver returns original with suffix", func(t *testing.T) { result := IncrementVersion("not-a-version") assert.Equal(t, "not-a-version.1", result) }) } -func TestIncrementMinor_Good(t *testing.T) { +func TestVersion_IncrementMinor_Good(t *testing.T) { tests := []struct { name string input string @@ -333,7 +318,7 @@ func TestIncrementMinor_Good(t *testing.T) { } } -func TestIncrementMajor_Good(t *testing.T) { +func TestVersion_IncrementMajor_Good(t *testing.T) { tests := []struct { name string input string @@ -364,7 +349,7 @@ func TestIncrementMajor_Good(t *testing.T) { } } -func TestParseVersion_Good(t *testing.T) { +func TestVersion_ParseVersion_Good(t *testing.T) { tests := []struct { name string input string @@ -418,7 +403,7 @@ func TestParseVersion_Good(t *testing.T) { } } -func TestParseVersion_Bad(t *testing.T) { +func TestVersion_ParseVersion_Bad(t *testing.T) { tests := []struct { name string input string @@ -438,7 +423,7 @@ func TestParseVersion_Bad(t *testing.T) { } } -func TestValidateVersion_Good(t *testing.T) { +func TestVersion_ValidateVersion_Good(t *testing.T) { validVersions := []string{ "v1.0.0", "1.0.0", @@ -456,7 +441,7 @@ func TestValidateVersion_Good(t *testing.T) { } } -func TestValidateVersion_Bad(t *testing.T) { +func TestVersion_ValidateVersion_Bad(t *testing.T) { invalidVersions := []string{ "", "v1", @@ -474,7 +459,7 @@ func TestValidateVersion_Bad(t *testing.T) { } } -func TestCompareVersions_Good(t *testing.T) { +func TestVersion_CompareVersions_Good(t *testing.T) { tests := []struct { name string a string @@ -500,7 +485,7 @@ func TestCompareVersions_Good(t *testing.T) { } } -func TestNormalizeVersion_Good(t *testing.T) { +func TestVersion_NormalizeVersion_Good(t *testing.T) { tests := []struct { input string expected string diff --git a/pkg/sdk/breaking_test.go b/pkg/sdk/breaking_test.go index c8c579b..82adc40 100644 --- a/pkg/sdk/breaking_test.go +++ b/pkg/sdk/breaking_test.go @@ -1,17 +1,17 @@ package sdk import ( - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --- Breaking Change Detection Tests (oasdiff integration) --- -func TestDiff_Good_AddEndpoint_NonBreaking(t *testing.T) { +func TestBreaking_DiffAddEndpointNonBreaking_Good(t *testing.T) { tmpDir := t.TempDir() base := `openapi: "3.0.0" @@ -50,10 +50,10 @@ paths: "200": description: OK ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revision), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) @@ -62,7 +62,7 @@ paths: assert.Equal(t, "No breaking changes", result.Summary) } -func TestDiff_Good_RemoveEndpoint_Breaking(t *testing.T) { +func TestBreaking_DiffRemoveEndpointBreaking_Good(t *testing.T) { tmpDir := t.TempDir() base := `openapi: "3.0.0" @@ -101,10 +101,10 @@ paths: "200": description: OK ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revision), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) @@ -113,7 +113,7 @@ paths: assert.Contains(t, result.Summary, "breaking change") } -func TestDiff_Good_AddRequiredParam_Breaking(t *testing.T) { +func TestBreaking_DiffAddRequiredParamBreaking_Good(t *testing.T) { tmpDir := t.TempDir() base := `openapi: "3.0.0" @@ -146,10 +146,10 @@ paths: "200": description: OK ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revision), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) @@ -157,7 +157,7 @@ paths: assert.NotEmpty(t, result.Changes) } -func TestDiff_Good_AddOptionalParam_NonBreaking(t *testing.T) { +func TestBreaking_DiffAddOptionalParamNonBreaking_Good(t *testing.T) { tmpDir := t.TempDir() base := `openapi: "3.0.0" @@ -190,17 +190,17 @@ paths: "200": description: OK ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revision), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) assert.False(t, result.Breaking, "adding optional parameter should not be breaking") } -func TestDiff_Good_ChangeResponseType_Breaking(t *testing.T) { +func TestBreaking_DiffChangeResponseTypeBreaking_Good(t *testing.T) { tmpDir := t.TempDir() base := `openapi: "3.0.0" @@ -252,17 +252,17 @@ paths: name: type: string ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revision), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) assert.True(t, result.Breaking, "changing response schema type should be breaking") } -func TestDiff_Good_RemoveHTTPMethod_Breaking(t *testing.T) { +func TestBreaking_DiffRemoveHTTPMethodBreaking_Good(t *testing.T) { tmpDir := t.TempDir() base := `openapi: "3.0.0" @@ -294,10 +294,10 @@ paths: "200": description: OK ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revision), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) @@ -305,7 +305,7 @@ paths: assert.NotEmpty(t, result.Changes) } -func TestDiff_Good_IdenticalSpecs_NonBreaking(t *testing.T) { +func TestBreaking_DiffIdenticalSpecsNonBreaking_Good(t *testing.T) { tmpDir := t.TempDir() spec := `openapi: "3.0.0" @@ -331,10 +331,10 @@ paths: "201": description: Created ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(spec), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(spec), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(spec), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(spec), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) @@ -345,45 +345,45 @@ paths: // --- Error Handling Tests --- -func TestDiff_Bad_NonExistentBase(t *testing.T) { +func TestBreaking_DiffNonExistentBase_Bad(t *testing.T) { tmpDir := t.TempDir() - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(revPath, []byte(`openapi: "3.0.0" + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(revPath, []byte(`openapi: "3.0.0" info: title: Test API version: "1.0.0" paths: {} `), 0644)) - _, err := Diff(filepath.Join(tmpDir, "nonexistent.yaml"), revPath) + _, err := Diff(ax.Join(tmpDir, "nonexistent.yaml"), revPath) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to load base spec") } -func TestDiff_Bad_NonExistentRevision(t *testing.T) { +func TestBreaking_DiffNonExistentRevision_Bad(t *testing.T) { tmpDir := t.TempDir() - basePath := filepath.Join(tmpDir, "base.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(`openapi: "3.0.0" + basePath := ax.Join(tmpDir, "base.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(`openapi: "3.0.0" info: title: Test API version: "1.0.0" paths: {} `), 0644)) - _, err := Diff(basePath, filepath.Join(tmpDir, "nonexistent.yaml")) + _, err := Diff(basePath, ax.Join(tmpDir, "nonexistent.yaml")) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to load revision spec") } -func TestDiff_Bad_InvalidYAML(t *testing.T) { +func TestBreaking_DiffInvalidYAML_Bad(t *testing.T) { tmpDir := t.TempDir() - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte("not: valid: openapi: spec: {{{{"), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(`openapi: "3.0.0" + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte("not: valid: openapi: spec: {{{{"), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(`openapi: "3.0.0" info: title: Test API version: "1.0.0" @@ -396,7 +396,7 @@ paths: {} // --- DiffExitCode Tests --- -func TestDiffExitCode_Good(t *testing.T) { +func TestBreaking_DiffExitCode_Good(t *testing.T) { tests := []struct { name string result *DiffResult @@ -433,7 +433,7 @@ func TestDiffExitCode_Good(t *testing.T) { // --- DiffResult Structure Tests --- -func TestDiffResult_Good_Summary(t *testing.T) { +func TestBreaking_DiffResultSummary_Good(t *testing.T) { t.Run("breaking result has count in summary", func(t *testing.T) { tmpDir := t.TempDir() @@ -474,10 +474,10 @@ paths: "200": description: OK ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revision), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) @@ -489,7 +489,7 @@ paths: }) } -func TestDiffResult_Good_ChangesAreHumanReadable(t *testing.T) { +func TestBreaking_DiffResultChangesAreHumanReadable_Good(t *testing.T) { tmpDir := t.TempDir() base := `openapi: "3.0.0" @@ -510,10 +510,10 @@ info: version: "2.0.0" paths: {} ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revision), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) @@ -527,7 +527,7 @@ paths: {} // --- Multiple Changes Detection Tests --- -func TestDiff_Good_MultipleBreakingChanges(t *testing.T) { +func TestBreaking_DiffMultipleBreakingChanges_Good(t *testing.T) { tmpDir := t.TempDir() base := `openapi: "3.0.0" @@ -570,10 +570,10 @@ paths: "200": description: OK ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - require.NoError(t, os.WriteFile(basePath, []byte(base), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revision), 0644)) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + require.NoError(t, ax.WriteFile(basePath, []byte(base), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revision), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) @@ -586,7 +586,7 @@ paths: // --- JSON Spec Support Tests --- -func TestDiff_Good_JSONSpecs(t *testing.T) { +func TestBreaking_DiffJSONSpecs_Good(t *testing.T) { tmpDir := t.TempDir() baseJSON := `{ @@ -619,10 +619,10 @@ func TestDiff_Good_JSONSpecs(t *testing.T) { } } }` - basePath := filepath.Join(tmpDir, "base.json") - revPath := filepath.Join(tmpDir, "rev.json") - require.NoError(t, os.WriteFile(basePath, []byte(baseJSON), 0644)) - require.NoError(t, os.WriteFile(revPath, []byte(revJSON), 0644)) + basePath := ax.Join(tmpDir, "base.json") + revPath := ax.Join(tmpDir, "rev.json") + require.NoError(t, ax.WriteFile(basePath, []byte(baseJSON), 0644)) + require.NoError(t, ax.WriteFile(revPath, []byte(revJSON), 0644)) result, err := Diff(basePath, revPath) require.NoError(t, err) diff --git a/pkg/sdk/detect.go b/pkg/sdk/detect.go index 683de20..b78c43e 100644 --- a/pkg/sdk/detect.go +++ b/pkg/sdk/detect.go @@ -1,9 +1,8 @@ package sdk import ( - "path/filepath" - "strings" - + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) @@ -22,10 +21,11 @@ var commonSpecPaths = []string{ // DetectSpec finds the OpenAPI spec file. // Priority: config path -> common paths -> Laravel Scramble. +// Usage example: call value.DetectSpec(...) from integrating code. func (s *SDK) DetectSpec() (string, error) { // 1. Check configured path if s.config.Spec != "" { - specPath := filepath.Join(s.projectDir, s.config.Spec) + specPath := ax.Join(s.projectDir, s.config.Spec) if coreio.Local.IsFile(specPath) { return specPath, nil } @@ -34,7 +34,7 @@ func (s *SDK) DetectSpec() (string, error) { // 2. Check common paths for _, p := range commonSpecPaths { - specPath := filepath.Join(s.projectDir, p) + specPath := ax.Join(s.projectDir, p) if coreio.Local.IsFile(specPath) { return specPath, nil } @@ -51,7 +51,7 @@ func (s *SDK) DetectSpec() (string, error) { // detectScramble checks for Laravel Scramble and exports the spec. func (s *SDK) detectScramble() (string, error) { - composerPath := filepath.Join(s.projectDir, "composer.json") + composerPath := ax.Join(s.projectDir, "composer.json") if !coreio.Local.IsFile(composerPath) { return "", coreerr.E("sdk.detectScramble", "no composer.json", nil) } @@ -73,6 +73,6 @@ func (s *SDK) detectScramble() (string, error) { // containsScramble checks if composer.json includes scramble. func containsScramble(content string) bool { - return strings.Contains(content, "dedoc/scramble") || - strings.Contains(content, "\"scramble\"") + return core.Contains(content, "dedoc/scramble") || + core.Contains(content, "\"scramble\"") } diff --git a/pkg/sdk/detect_test.go b/pkg/sdk/detect_test.go index fef2dbc..98986ac 100644 --- a/pkg/sdk/detect_test.go +++ b/pkg/sdk/detect_test.go @@ -1,20 +1,20 @@ package sdk import ( - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestDetectSpec_Good_ConfigPath(t *testing.T) { +func TestDetect_DetectSpecConfigPath_Good(t *testing.T) { tmpDir := t.TempDir() - specPath := filepath.Join(tmpDir, "api", "spec.yaml") - err := os.MkdirAll(filepath.Dir(specPath), 0755) + specPath := ax.Join(tmpDir, "api", "spec.yaml") + err := ax.MkdirAll(ax.Dir(specPath), 0755) require.NoError(t, err) - err = os.WriteFile(specPath, []byte("openapi: 3.0.0"), 0644) + err = ax.WriteFile(specPath, []byte("openapi: 3.0.0"), 0644) require.NoError(t, err) sdk := New(tmpDir, &Config{Spec: "api/spec.yaml"}) @@ -23,10 +23,10 @@ func TestDetectSpec_Good_ConfigPath(t *testing.T) { assert.Equal(t, specPath, got) } -func TestDetectSpec_Good_CommonPath(t *testing.T) { +func TestDetect_DetectSpecCommonPath_Good(t *testing.T) { tmpDir := t.TempDir() - specPath := filepath.Join(tmpDir, "openapi.yaml") - err := os.WriteFile(specPath, []byte("openapi: 3.0.0"), 0644) + specPath := ax.Join(tmpDir, "openapi.yaml") + err := ax.WriteFile(specPath, []byte("openapi: 3.0.0"), 0644) require.NoError(t, err) sdk := New(tmpDir, nil) @@ -35,7 +35,7 @@ func TestDetectSpec_Good_CommonPath(t *testing.T) { assert.Equal(t, specPath, got) } -func TestDetectSpec_Bad_NotFound(t *testing.T) { +func TestDetect_DetectSpecNotFound_Bad(t *testing.T) { tmpDir := t.TempDir() sdk := New(tmpDir, nil) _, err := sdk.DetectSpec() @@ -43,7 +43,7 @@ func TestDetectSpec_Bad_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "no OpenAPI spec found") } -func TestDetectSpec_Bad_ConfigNotFound(t *testing.T) { +func TestDetect_DetectSpecConfigNotFound_Bad(t *testing.T) { tmpDir := t.TempDir() sdk := New(tmpDir, &Config{Spec: "non-existent.yaml"}) _, err := sdk.DetectSpec() @@ -51,7 +51,7 @@ func TestDetectSpec_Bad_ConfigNotFound(t *testing.T) { assert.Contains(t, err.Error(), "configured spec not found") } -func TestContainsScramble(t *testing.T) { +func TestDetect_ContainsScramble_Good(t *testing.T) { tests := []struct { data string expected bool @@ -66,7 +66,7 @@ func TestContainsScramble(t *testing.T) { } } -func TestDetectScramble_Bad(t *testing.T) { +func TestDetect_DetectScramble_Bad(t *testing.T) { t.Run("no composer.json", func(t *testing.T) { sdk := New(t.TempDir(), nil) _, err := sdk.detectScramble() @@ -76,7 +76,7 @@ func TestDetectScramble_Bad(t *testing.T) { t.Run("no scramble in composer.json", func(t *testing.T) { tmpDir := t.TempDir() - err := os.WriteFile(filepath.Join(tmpDir, "composer.json"), []byte(`{}`), 0644) + err := ax.WriteFile(ax.Join(tmpDir, "composer.json"), []byte(`{}`), 0644) require.NoError(t, err) sdk := New(tmpDir, nil) diff --git a/pkg/sdk/diff.go b/pkg/sdk/diff.go index e3829a6..5751533 100644 --- a/pkg/sdk/diff.go +++ b/pkg/sdk/diff.go @@ -1,8 +1,7 @@ package sdk import ( - "fmt" - + "dappco.re/go/core" coreerr "dappco.re/go/core/log" "github.com/oasdiff/kin-openapi/openapi3" "github.com/oasdiff/oasdiff/checker" @@ -11,6 +10,7 @@ import ( ) // DiffResult holds the result of comparing two OpenAPI specs. +// Usage example: declare a value of type sdk.DiffResult in integrating code. type DiffResult struct { // Breaking is true if breaking changes were detected. Breaking bool @@ -21,6 +21,7 @@ type DiffResult struct { } // Diff compares two OpenAPI specs and detects breaking changes. +// Usage example: call sdk.Diff(...) from integrating code. func Diff(basePath, revisionPath string) (*DiffResult, error) { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true @@ -64,7 +65,7 @@ func Diff(basePath, revisionPath string) (*DiffResult, error) { } if result.Breaking { - result.Summary = fmt.Sprintf("%d breaking change(s) detected", len(breaks)) + result.Summary = core.Sprintf("%d breaking change(s) detected", len(breaks)) } else { result.Summary = "No breaking changes" } @@ -74,6 +75,7 @@ func Diff(basePath, revisionPath string) (*DiffResult, error) { // DiffExitCode returns the exit code for CI integration. // 0 = no breaking changes, 1 = breaking changes, 2 = error +// Usage example: call sdk.DiffExitCode(...) from integrating code. func DiffExitCode(result *DiffResult, err error) int { if err != nil { return 2 diff --git a/pkg/sdk/diff_test.go b/pkg/sdk/diff_test.go index f1b3a20..2c48fac 100644 --- a/pkg/sdk/diff_test.go +++ b/pkg/sdk/diff_test.go @@ -1,12 +1,12 @@ package sdk import ( - "os" - "path/filepath" "testing" + + "dappco.re/go/core/build/internal/ax" ) -func TestDiff_Good_NoBreaking(t *testing.T) { +func TestDiff_NoBreaking_Good(t *testing.T) { tmpDir := t.TempDir() baseSpec := `openapi: "3.0.0" @@ -39,10 +39,10 @@ paths: "200": description: OK ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - _ = os.WriteFile(basePath, []byte(baseSpec), 0644) - _ = os.WriteFile(revPath, []byte(revSpec), 0644) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + _ = ax.WriteFile(basePath, []byte(baseSpec), 0644) + _ = ax.WriteFile(revPath, []byte(revSpec), 0644) result, err := Diff(basePath, revPath) if err != nil { @@ -53,7 +53,7 @@ paths: } } -func TestDiff_Good_Breaking(t *testing.T) { +func TestDiff_Breaking_Good(t *testing.T) { tmpDir := t.TempDir() baseSpec := `openapi: "3.0.0" @@ -86,10 +86,10 @@ paths: "200": description: OK ` - basePath := filepath.Join(tmpDir, "base.yaml") - revPath := filepath.Join(tmpDir, "rev.yaml") - _ = os.WriteFile(basePath, []byte(baseSpec), 0644) - _ = os.WriteFile(revPath, []byte(revSpec), 0644) + basePath := ax.Join(tmpDir, "base.yaml") + revPath := ax.Join(tmpDir, "rev.yaml") + _ = ax.WriteFile(basePath, []byte(baseSpec), 0644) + _ = ax.WriteFile(revPath, []byte(revSpec), 0644) result, err := Diff(basePath, revPath) if err != nil { diff --git a/pkg/sdk/generation_test.go b/pkg/sdk/generation_test.go index 4966317..d32d269 100644 --- a/pkg/sdk/generation_test.go +++ b/pkg/sdk/generation_test.go @@ -2,10 +2,10 @@ package sdk import ( "context" - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "dappco.re/go/core/build/pkg/sdk/generators" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,13 +13,13 @@ import ( // --- SDK Generation Orchestration Tests --- -func TestSDK_Generate_Good_AllLanguages(t *testing.T) { +func TestGeneration_SDKGenerateAllLanguages_Good(t *testing.T) { t.Run("Generate iterates all configured languages", func(t *testing.T) { tmpDir := t.TempDir() // Create a minimal OpenAPI spec - specPath := filepath.Join(tmpDir, "openapi.yaml") - err := os.WriteFile(specPath, []byte(minimalSpec), 0644) + specPath := ax.Join(tmpDir, "openapi.yaml") + err := ax.WriteFile(specPath, []byte(minimalSpec), 0644) require.NoError(t, err) cfg := &Config{ @@ -41,12 +41,12 @@ func TestSDK_Generate_Good_AllLanguages(t *testing.T) { }) } -func TestSDK_GenerateLanguage_Good_OutputDir(t *testing.T) { +func TestGeneration_SDKGenerateLanguageOutputDir_Good(t *testing.T) { t.Run("output directory uses language subdirectory", func(t *testing.T) { tmpDir := t.TempDir() - specPath := filepath.Join(tmpDir, "openapi.yaml") - err := os.WriteFile(specPath, []byte(minimalSpec), 0644) + specPath := ax.Join(tmpDir, "openapi.yaml") + err := ax.WriteFile(specPath, []byte(minimalSpec), 0644) require.NoError(t, err) cfg := &Config{ @@ -69,7 +69,7 @@ func TestSDK_GenerateLanguage_Good_OutputDir(t *testing.T) { }) } -func TestSDK_GenerateLanguage_Bad_NoSpec(t *testing.T) { +func TestGeneration_SDKGenerateLanguageNoSpec_Bad(t *testing.T) { t.Run("fails when no OpenAPI spec exists", func(t *testing.T) { tmpDir := t.TempDir() @@ -84,11 +84,11 @@ func TestSDK_GenerateLanguage_Bad_NoSpec(t *testing.T) { }) } -func TestSDK_GenerateLanguage_Bad_UnknownLanguage(t *testing.T) { +func TestGeneration_SDKGenerateLanguageUnknownLanguage_Bad(t *testing.T) { t.Run("fails for unregistered language", func(t *testing.T) { tmpDir := t.TempDir() - specPath := filepath.Join(tmpDir, "openapi.yaml") - err := os.WriteFile(specPath, []byte(minimalSpec), 0644) + specPath := ax.Join(tmpDir, "openapi.yaml") + err := ax.WriteFile(specPath, []byte(minimalSpec), 0644) require.NoError(t, err) s := New(tmpDir, nil) @@ -100,7 +100,7 @@ func TestSDK_GenerateLanguage_Bad_UnknownLanguage(t *testing.T) { // --- Generator Registry Tests --- -func TestRegistry_Good_RegisterAndGet(t *testing.T) { +func TestGeneration_RegistryRegisterAndGet_Good(t *testing.T) { t.Run("register and retrieve all generators", func(t *testing.T) { registry := generators.NewRegistry() registry.Register(generators.NewTypeScriptGenerator()) @@ -132,7 +132,7 @@ func TestRegistry_Good_RegisterAndGet(t *testing.T) { }) } -func TestRegistry_Good_OverwritesDuplicateLanguage(t *testing.T) { +func TestGeneration_RegistryOverwritesDuplicateLanguage_Good(t *testing.T) { registry := generators.NewRegistry() registry.Register(generators.NewTypeScriptGenerator()) registry.Register(generators.NewTypeScriptGenerator()) // register again @@ -149,7 +149,7 @@ func TestRegistry_Good_OverwritesDuplicateLanguage(t *testing.T) { // --- Generator Interface Compliance Tests --- -func TestGenerators_Good_LanguageIdentifiers(t *testing.T) { +func TestGeneration_GeneratorsLanguageIdentifiers_Good(t *testing.T) { tests := []struct { generator generators.Generator expected string @@ -167,7 +167,7 @@ func TestGenerators_Good_LanguageIdentifiers(t *testing.T) { } } -func TestGenerators_Good_InstallInstructions(t *testing.T) { +func TestGeneration_GeneratorsInstallInstructions_Good(t *testing.T) { tests := []struct { language string gen generators.Generator @@ -188,7 +188,7 @@ func TestGenerators_Good_InstallInstructions(t *testing.T) { } } -func TestGenerators_Good_AvailableDoesNotPanic(t *testing.T) { +func TestGeneration_GeneratorsAvailableDoesNotPanic_Good(t *testing.T) { // Available() should never panic regardless of system state gens := []generators.Generator{ generators.NewTypeScriptGenerator(), @@ -207,7 +207,7 @@ func TestGenerators_Good_AvailableDoesNotPanic(t *testing.T) { // --- SDK Config Tests --- -func TestSDKConfig_Good_DefaultConfig(t *testing.T) { +func TestGeneration_SDKConfigDefaultConfig_Good(t *testing.T) { t.Run("default config has all four languages", func(t *testing.T) { cfg := DefaultConfig() assert.Contains(t, cfg.Languages, "typescript") @@ -229,7 +229,7 @@ func TestSDKConfig_Good_DefaultConfig(t *testing.T) { }) } -func TestSDKConfig_Good_SetVersion(t *testing.T) { +func TestGeneration_SDKConfigSetVersion_Good(t *testing.T) { t.Run("SetVersion updates both fields", func(t *testing.T) { s := New("/tmp", &Config{ Package: PackageConfig{ @@ -251,7 +251,7 @@ func TestSDKConfig_Good_SetVersion(t *testing.T) { }) } -func TestSDKConfig_Good_NewWithNilConfig(t *testing.T) { +func TestGeneration_SDKConfigNewWithNilConfig_Good(t *testing.T) { s := New("/project", nil) assert.NotNil(t, s.config) assert.Equal(t, "sdk", s.config.Output) @@ -260,18 +260,18 @@ func TestSDKConfig_Good_NewWithNilConfig(t *testing.T) { // --- Spec Detection Integration Tests --- -func TestSpecDetection_Good_Priority(t *testing.T) { +func TestGeneration_SpecDetectionPriority_Good(t *testing.T) { t.Run("configured spec takes priority over common paths", func(t *testing.T) { tmpDir := t.TempDir() // Create both a common path spec and a configured spec - commonSpec := filepath.Join(tmpDir, "openapi.yaml") - err := os.WriteFile(commonSpec, []byte(minimalSpec), 0644) + commonSpec := ax.Join(tmpDir, "openapi.yaml") + err := ax.WriteFile(commonSpec, []byte(minimalSpec), 0644) require.NoError(t, err) - configuredSpec := filepath.Join(tmpDir, "custom", "api.yaml") - require.NoError(t, os.MkdirAll(filepath.Dir(configuredSpec), 0755)) - err = os.WriteFile(configuredSpec, []byte(minimalSpec), 0644) + configuredSpec := ax.Join(tmpDir, "custom", "api.yaml") + require.NoError(t, ax.MkdirAll(ax.Dir(configuredSpec), 0755)) + err = ax.WriteFile(configuredSpec, []byte(minimalSpec), 0644) require.NoError(t, err) s := New(tmpDir, &Config{Spec: "custom/api.yaml"}) @@ -284,10 +284,10 @@ func TestSpecDetection_Good_Priority(t *testing.T) { tmpDir := t.TempDir() // Create the second common path only (api/openapi.yaml is first) - apiDir := filepath.Join(tmpDir, "api") - require.NoError(t, os.MkdirAll(apiDir, 0755)) - apiSpec := filepath.Join(apiDir, "openapi.json") - err := os.WriteFile(apiSpec, []byte(`{"openapi":"3.0.0"}`), 0644) + apiDir := ax.Join(tmpDir, "api") + require.NoError(t, ax.MkdirAll(apiDir, 0755)) + apiSpec := ax.Join(apiDir, "openapi.json") + err := ax.WriteFile(apiSpec, []byte(`{"openapi":"3.0.0"}`), 0644) require.NoError(t, err) s := New(tmpDir, nil) @@ -297,14 +297,14 @@ func TestSpecDetection_Good_Priority(t *testing.T) { }) } -func TestSpecDetection_Good_AllCommonPaths(t *testing.T) { +func TestGeneration_SpecDetectionAllCommonPaths_Good(t *testing.T) { for _, commonPath := range commonSpecPaths { t.Run(commonPath, func(t *testing.T) { tmpDir := t.TempDir() - specPath := filepath.Join(tmpDir, commonPath) - require.NoError(t, os.MkdirAll(filepath.Dir(specPath), 0755)) - err := os.WriteFile(specPath, []byte(minimalSpec), 0644) + specPath := ax.Join(tmpDir, commonPath) + require.NoError(t, ax.MkdirAll(ax.Dir(specPath), 0755)) + err := ax.WriteFile(specPath, []byte(minimalSpec), 0644) require.NoError(t, err) s := New(tmpDir, nil) diff --git a/pkg/sdk/generators/docker_runtime.go b/pkg/sdk/generators/docker_runtime.go new file mode 100644 index 0000000..dc41597 --- /dev/null +++ b/pkg/sdk/generators/docker_runtime.go @@ -0,0 +1,20 @@ +package generators + +import ( + "context" + "sync" + + "dappco.re/go/core/build/internal/ax" +) + +var ( + dockerRuntimeOnce sync.Once + dockerRuntimeOK bool +) + +func dockerRuntimeAvailable() bool { + dockerRuntimeOnce.Do(func() { + dockerRuntimeOK = ax.Exec(context.Background(), "docker", "info") == nil + }) + return dockerRuntimeOK +} diff --git a/pkg/sdk/generators/generator.go b/pkg/sdk/generators/generator.go index 3e65f8a..340cabc 100644 --- a/pkg/sdk/generators/generator.go +++ b/pkg/sdk/generators/generator.go @@ -3,15 +3,16 @@ package generators import ( "context" - "fmt" "iter" - "maps" - "os" "runtime" - "slices" + "sort" + + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" ) // Options holds common generation options. +// Usage example: declare a value of type generators.Options in integrating code. type Options struct { // SpecPath is the path to the OpenAPI spec file. SpecPath string @@ -24,6 +25,7 @@ type Options struct { } // Generator defines the interface for SDK generators. +// Usage example: declare a value of type generators.Generator in integrating code. type Generator interface { // Language returns the generator's target language identifier. Language() string @@ -39,11 +41,13 @@ type Generator interface { } // Registry holds available generators. +// Usage example: declare a value of type generators.Registry in integrating code. type Registry struct { generators map[string]Generator } // NewRegistry creates a registry with all available generators. +// Usage example: call generators.NewRegistry(...) from integrating code. func NewRegistry() *Registry { r := &Registry{ generators: make(map[string]Generator), @@ -53,26 +57,39 @@ func NewRegistry() *Registry { } // Get returns a generator by language. +// Usage example: call value.Get(...) from integrating code. func (r *Registry) Get(lang string) (Generator, bool) { g, ok := r.generators[lang] return g, ok } // Register adds a generator to the registry. +// Usage example: call value.Register(...) from integrating code. func (r *Registry) Register(g Generator) { r.generators[g.Language()] = g } // Languages returns all registered language identifiers. +// Usage example: call value.Languages(...) from integrating code. func (r *Registry) Languages() []string { - return slices.Collect(r.LanguagesIter()) + var languages []string + for lang := range r.LanguagesIter() { + languages = append(languages, lang) + } + return languages } // LanguagesIter returns an iterator for all registered language identifiers. +// Usage example: call value.LanguagesIter(...) from integrating code. func (r *Registry) LanguagesIter() iter.Seq[string] { return func(yield func(string) bool) { // Sort keys for deterministic iteration - for _, lang := range slices.Sorted(maps.Keys(r.generators)) { + keys := make([]string, 0, len(r.generators)) + for lang := range r.generators { + keys = append(keys, lang) + } + sort.Strings(keys) + for _, lang := range keys { if !yield(lang) { return } @@ -86,5 +103,5 @@ func dockerUserArgs() []string { if runtime.GOOS == "windows" { return nil } - return []string{"--user", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())} + return []string{"--user", core.Sprintf("%d:%d", ax.Getuid(), ax.Getgid())} } diff --git a/pkg/sdk/generators/go.go b/pkg/sdk/generators/go.go index 79833df..b0d7462 100644 --- a/pkg/sdk/generators/go.go +++ b/pkg/sdk/generators/go.go @@ -2,40 +2,44 @@ package generators import ( "context" - "fmt" - "os" - "os/exec" - "path/filepath" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // GoGenerator generates Go SDKs from OpenAPI specs. +// Usage example: declare a value of type generators.GoGenerator in integrating code. type GoGenerator struct{} // NewGoGenerator creates a new Go generator. +// Usage example: call generators.NewGoGenerator(...) from integrating code. func NewGoGenerator() *GoGenerator { return &GoGenerator{} } // Language returns the generator's target language identifier. +// Usage example: call value.Language(...) from integrating code. func (g *GoGenerator) Language() string { return "go" } // Available checks if generator dependencies are installed. +// Usage example: call value.Available(...) from integrating code. func (g *GoGenerator) Available() bool { - _, err := exec.LookPath("oapi-codegen") + _, err := ax.LookPath("oapi-codegen") return err == nil } // Install returns instructions for installing the generator. +// Usage example: call value.Install(...) from integrating code. func (g *GoGenerator) Install() string { return "go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest" } // Generate creates SDK from OpenAPI spec. +// Usage example: call value.Generate(...) from integrating code. func (g *GoGenerator) Generate(ctx context.Context, opts Options) error { if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil { return coreerr.E("go.Generate", "failed to create output dir", err) @@ -44,32 +48,31 @@ func (g *GoGenerator) Generate(ctx context.Context, opts Options) error { if g.Available() { return g.generateNative(ctx, opts) } + if !dockerRuntimeAvailable() { + return coreerr.E("go.Generate", "Docker is required for fallback generation but not available", nil) + } return g.generateDocker(ctx, opts) } func (g *GoGenerator) generateNative(ctx context.Context, opts Options) error { - outputFile := filepath.Join(opts.OutputDir, "client.go") + outputFile := ax.Join(opts.OutputDir, "client.go") - cmd := exec.CommandContext(ctx, "oapi-codegen", + if err := ax.Exec(ctx, "oapi-codegen", "-package", opts.PackageName, "-generate", "types,client", "-o", outputFile, opts.SpecPath, - ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { + ); err != nil { return coreerr.E("go.generateNative", "oapi-codegen failed", err) } - goMod := fmt.Sprintf("module %s\n\ngo 1.21\n", opts.PackageName) - return coreio.Local.Write(filepath.Join(opts.OutputDir, "go.mod"), goMod) + goMod := core.Sprintf("module %s\n\ngo 1.21\n", opts.PackageName) + return coreio.Local.Write(ax.Join(opts.OutputDir, "go.mod"), goMod) } func (g *GoGenerator) generateDocker(ctx context.Context, opts Options) error { - specDir := filepath.Dir(opts.SpecPath) - specName := filepath.Base(opts.SpecPath) + specDir := ax.Dir(opts.SpecPath) + specName := ax.Base(opts.SpecPath) args := []string{"run", "--rm"} args = append(args, dockerUserArgs()...) @@ -83,8 +86,5 @@ func (g *GoGenerator) generateDocker(ctx context.Context, opts Options) error { "--additional-properties=packageName="+opts.PackageName, ) - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return ax.Exec(ctx, "docker", args...) } diff --git a/pkg/sdk/generators/go_test.go b/pkg/sdk/generators/go_test.go index 708b7dd..b97ea3c 100644 --- a/pkg/sdk/generators/go_test.go +++ b/pkg/sdk/generators/go_test.go @@ -2,13 +2,13 @@ package generators import ( "context" - "os" - "path/filepath" "testing" "time" + + "dappco.re/go/core/build/internal/ax" ) -func TestGoGenerator_Good_Available(t *testing.T) { +func TestGo_GoGeneratorAvailable_Good(t *testing.T) { g := NewGoGenerator() // These should not panic @@ -25,7 +25,7 @@ func TestGoGenerator_Good_Available(t *testing.T) { } } -func TestGoGenerator_Good_Generate(t *testing.T) { +func TestGo_GoGeneratorGenerate_Good(t *testing.T) { g := NewGoGenerator() if !g.Available() && !dockerAvailable() { t.Skip("no Go generator available (neither native nor docker)") @@ -34,7 +34,7 @@ func TestGoGenerator_Good_Generate(t *testing.T) { // Create temp directories tmpDir := t.TempDir() specPath := createTestSpec(t, tmpDir) - outputDir := filepath.Join(tmpDir, "output") + outputDir := ax.Join(tmpDir, "output") opts := Options{ SpecPath: specPath, @@ -52,7 +52,7 @@ func TestGoGenerator_Good_Generate(t *testing.T) { } // Verify output directory was created - if _, err := os.Stat(outputDir); os.IsNotExist(err) { + if !ax.Exists(outputDir) { t.Error("output directory was not created") } } diff --git a/pkg/sdk/generators/php.go b/pkg/sdk/generators/php.go index 1770b07..567aa64 100644 --- a/pkg/sdk/generators/php.go +++ b/pkg/sdk/generators/php.go @@ -2,39 +2,42 @@ package generators import ( "context" - "os" - "os/exec" - "path/filepath" + "dappco.re/go/core/build/internal/ax" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // PHPGenerator generates PHP SDKs from OpenAPI specs. +// Usage example: declare a value of type generators.PHPGenerator in integrating code. type PHPGenerator struct{} // NewPHPGenerator creates a new PHP generator. +// Usage example: call generators.NewPHPGenerator(...) from integrating code. func NewPHPGenerator() *PHPGenerator { return &PHPGenerator{} } // Language returns the generator's target language identifier. +// Usage example: call value.Language(...) from integrating code. func (g *PHPGenerator) Language() string { return "php" } // Available checks if generator dependencies are installed. +// Usage example: call value.Available(...) from integrating code. func (g *PHPGenerator) Available() bool { - _, err := exec.LookPath("docker") - return err == nil + return dockerRuntimeAvailable() } // Install returns instructions for installing the generator. +// Usage example: call value.Install(...) from integrating code. func (g *PHPGenerator) Install() string { return "Docker is required for PHP SDK generation" } // Generate creates SDK from OpenAPI spec. +// Usage example: call value.Generate(...) from integrating code. func (g *PHPGenerator) Generate(ctx context.Context, opts Options) error { if !g.Available() { return coreerr.E("php.Generate", "Docker is required but not available", nil) @@ -44,8 +47,8 @@ func (g *PHPGenerator) Generate(ctx context.Context, opts Options) error { return coreerr.E("php.Generate", "failed to create output dir", err) } - specDir := filepath.Dir(opts.SpecPath) - specName := filepath.Base(opts.SpecPath) + specDir := ax.Dir(opts.SpecPath) + specName := ax.Base(opts.SpecPath) args := []string{"run", "--rm"} args = append(args, dockerUserArgs()...) @@ -59,11 +62,7 @@ func (g *PHPGenerator) Generate(ctx context.Context, opts Options) error { "--additional-properties=invokerPackage="+opts.PackageName, ) - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { + if err := ax.Exec(ctx, "docker", args...); err != nil { return coreerr.E("php.Generate", "docker run failed", err) } return nil diff --git a/pkg/sdk/generators/php_test.go b/pkg/sdk/generators/php_test.go index a3a6e4a..a980dff 100644 --- a/pkg/sdk/generators/php_test.go +++ b/pkg/sdk/generators/php_test.go @@ -2,13 +2,13 @@ package generators import ( "context" - "os" - "path/filepath" "testing" "time" + + "dappco.re/go/core/build/internal/ax" ) -func TestPHPGenerator_Good_Available(t *testing.T) { +func TestPHP_PHPGeneratorAvailable_Good(t *testing.T) { g := NewPHPGenerator() // These should not panic @@ -25,7 +25,7 @@ func TestPHPGenerator_Good_Available(t *testing.T) { } } -func TestPHPGenerator_Good_Generate(t *testing.T) { +func TestPHP_PHPGeneratorGenerate_Good(t *testing.T) { g := NewPHPGenerator() if !g.Available() { t.Skip("no PHP generator available (docker not installed)") @@ -34,7 +34,7 @@ func TestPHPGenerator_Good_Generate(t *testing.T) { // Create temp directories tmpDir := t.TempDir() specPath := createTestSpec(t, tmpDir) - outputDir := filepath.Join(tmpDir, "output") + outputDir := ax.Join(tmpDir, "output") opts := Options{ SpecPath: specPath, @@ -52,7 +52,7 @@ func TestPHPGenerator_Good_Generate(t *testing.T) { } // Verify output directory was created - if _, err := os.Stat(outputDir); os.IsNotExist(err) { + if !ax.Exists(outputDir) { t.Error("output directory was not created") } } diff --git a/pkg/sdk/generators/python.go b/pkg/sdk/generators/python.go index d2e7293..6f41838 100644 --- a/pkg/sdk/generators/python.go +++ b/pkg/sdk/generators/python.go @@ -2,39 +2,43 @@ package generators import ( "context" - "os" - "os/exec" - "path/filepath" + "dappco.re/go/core/build/internal/ax" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // PythonGenerator generates Python SDKs from OpenAPI specs. +// Usage example: declare a value of type generators.PythonGenerator in integrating code. type PythonGenerator struct{} // NewPythonGenerator creates a new Python generator. +// Usage example: call generators.NewPythonGenerator(...) from integrating code. func NewPythonGenerator() *PythonGenerator { return &PythonGenerator{} } // Language returns the generator's target language identifier. +// Usage example: call value.Language(...) from integrating code. func (g *PythonGenerator) Language() string { return "python" } // Available checks if generator dependencies are installed. +// Usage example: call value.Available(...) from integrating code. func (g *PythonGenerator) Available() bool { - _, err := exec.LookPath("openapi-python-client") + _, err := ax.LookPath("openapi-python-client") return err == nil } // Install returns instructions for installing the generator. +// Usage example: call value.Install(...) from integrating code. func (g *PythonGenerator) Install() string { return "pip install openapi-python-client" } // Generate creates SDK from OpenAPI spec. +// Usage example: call value.Generate(...) from integrating code. func (g *PythonGenerator) Generate(ctx context.Context, opts Options) error { if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil { return coreerr.E("python.Generate", "failed to create output dir", err) @@ -43,25 +47,24 @@ func (g *PythonGenerator) Generate(ctx context.Context, opts Options) error { if g.Available() { return g.generateNative(ctx, opts) } + if !dockerRuntimeAvailable() { + return coreerr.E("python.Generate", "Docker is required for fallback generation but not available", nil) + } return g.generateDocker(ctx, opts) } func (g *PythonGenerator) generateNative(ctx context.Context, opts Options) error { - parentDir := filepath.Dir(opts.OutputDir) + parentDir := ax.Dir(opts.OutputDir) - cmd := exec.CommandContext(ctx, "openapi-python-client", "generate", + return ax.ExecDir(ctx, parentDir, "openapi-python-client", "generate", "--path", opts.SpecPath, "--output-path", opts.OutputDir, ) - cmd.Dir = parentDir - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() } func (g *PythonGenerator) generateDocker(ctx context.Context, opts Options) error { - specDir := filepath.Dir(opts.SpecPath) - specName := filepath.Base(opts.SpecPath) + specDir := ax.Dir(opts.SpecPath) + specName := ax.Base(opts.SpecPath) args := []string{"run", "--rm"} args = append(args, dockerUserArgs()...) @@ -75,8 +78,5 @@ func (g *PythonGenerator) generateDocker(ctx context.Context, opts Options) erro "--additional-properties=packageName="+opts.PackageName, ) - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return ax.Exec(ctx, "docker", args...) } diff --git a/pkg/sdk/generators/python_test.go b/pkg/sdk/generators/python_test.go index 5b03a76..aa58fcb 100644 --- a/pkg/sdk/generators/python_test.go +++ b/pkg/sdk/generators/python_test.go @@ -2,13 +2,13 @@ package generators import ( "context" - "os" - "path/filepath" "testing" "time" + + "dappco.re/go/core/build/internal/ax" ) -func TestPythonGenerator_Good_Available(t *testing.T) { +func TestPython_PythonGeneratorAvailable_Good(t *testing.T) { g := NewPythonGenerator() // These should not panic @@ -25,7 +25,7 @@ func TestPythonGenerator_Good_Available(t *testing.T) { } } -func TestPythonGenerator_Good_Generate(t *testing.T) { +func TestPython_PythonGeneratorGenerate_Good(t *testing.T) { g := NewPythonGenerator() if !g.Available() && !dockerAvailable() { t.Skip("no Python generator available (neither native nor docker)") @@ -34,7 +34,7 @@ func TestPythonGenerator_Good_Generate(t *testing.T) { // Create temp directories tmpDir := t.TempDir() specPath := createTestSpec(t, tmpDir) - outputDir := filepath.Join(tmpDir, "output") + outputDir := ax.Join(tmpDir, "output") opts := Options{ SpecPath: specPath, @@ -52,7 +52,7 @@ func TestPythonGenerator_Good_Generate(t *testing.T) { } // Verify output directory was created - if _, err := os.Stat(outputDir); os.IsNotExist(err) { + if !ax.Exists(outputDir) { t.Error("output directory was not created") } } diff --git a/pkg/sdk/generators/typescript.go b/pkg/sdk/generators/typescript.go index 6a72e7c..00d3de1 100644 --- a/pkg/sdk/generators/typescript.go +++ b/pkg/sdk/generators/typescript.go @@ -2,43 +2,47 @@ package generators import ( "context" - "os" - "os/exec" - "path/filepath" + "dappco.re/go/core/build/internal/ax" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) // TypeScriptGenerator generates TypeScript SDKs from OpenAPI specs. +// Usage example: declare a value of type generators.TypeScriptGenerator in integrating code. type TypeScriptGenerator struct{} // NewTypeScriptGenerator creates a new TypeScript generator. +// Usage example: call generators.NewTypeScriptGenerator(...) from integrating code. func NewTypeScriptGenerator() *TypeScriptGenerator { return &TypeScriptGenerator{} } // Language returns the generator's target language identifier. +// Usage example: call value.Language(...) from integrating code. func (g *TypeScriptGenerator) Language() string { return "typescript" } // Available checks if generator dependencies are installed. +// Usage example: call value.Available(...) from integrating code. func (g *TypeScriptGenerator) Available() bool { - _, err := exec.LookPath("openapi-typescript-codegen") + _, err := ax.LookPath("openapi-typescript-codegen") if err == nil { return true } - _, err = exec.LookPath("npx") + _, err = ax.LookPath("npx") return err == nil } // Install returns instructions for installing the generator. +// Usage example: call value.Install(...) from integrating code. func (g *TypeScriptGenerator) Install() string { return "npm install -g openapi-typescript-codegen" } // Generate creates SDK from OpenAPI spec. +// Usage example: call value.Generate(...) from integrating code. func (g *TypeScriptGenerator) Generate(ctx context.Context, opts Options) error { if err := coreio.Local.EnsureDir(opts.OutputDir); err != nil { return coreerr.E("typescript.Generate", "failed to create output dir", err) @@ -50,44 +54,41 @@ func (g *TypeScriptGenerator) Generate(ctx context.Context, opts Options) error if g.npxAvailable() { return g.generateNpx(ctx, opts) } + if !dockerRuntimeAvailable() { + return coreerr.E("typescript.Generate", "Docker is required for fallback generation but not available", nil) + } return g.generateDocker(ctx, opts) } func (g *TypeScriptGenerator) nativeAvailable() bool { - _, err := exec.LookPath("openapi-typescript-codegen") + _, err := ax.LookPath("openapi-typescript-codegen") return err == nil } func (g *TypeScriptGenerator) npxAvailable() bool { - _, err := exec.LookPath("npx") + _, err := ax.LookPath("npx") return err == nil } func (g *TypeScriptGenerator) generateNative(ctx context.Context, opts Options) error { - cmd := exec.CommandContext(ctx, "openapi-typescript-codegen", + return ax.Exec(ctx, "openapi-typescript-codegen", "--input", opts.SpecPath, "--output", opts.OutputDir, "--name", opts.PackageName, ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() } func (g *TypeScriptGenerator) generateNpx(ctx context.Context, opts Options) error { - cmd := exec.CommandContext(ctx, "npx", "openapi-typescript-codegen", + return ax.Exec(ctx, "npx", "openapi-typescript-codegen", "--input", opts.SpecPath, "--output", opts.OutputDir, "--name", opts.PackageName, ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() } func (g *TypeScriptGenerator) generateDocker(ctx context.Context, opts Options) error { - specDir := filepath.Dir(opts.SpecPath) - specName := filepath.Base(opts.SpecPath) + specDir := ax.Dir(opts.SpecPath) + specName := ax.Base(opts.SpecPath) args := []string{"run", "--rm"} args = append(args, dockerUserArgs()...) @@ -101,11 +102,7 @@ func (g *TypeScriptGenerator) generateDocker(ctx context.Context, opts Options) "--additional-properties=npmName="+opts.PackageName, ) - cmd := exec.CommandContext(ctx, "docker", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { + if err := ax.Exec(ctx, "docker", args...); err != nil { return coreerr.E("typescript.generateDocker", "docker run failed", err) } return nil diff --git a/pkg/sdk/generators/typescript_test.go b/pkg/sdk/generators/typescript_test.go index 3a40443..941a338 100644 --- a/pkg/sdk/generators/typescript_test.go +++ b/pkg/sdk/generators/typescript_test.go @@ -2,17 +2,15 @@ package generators import ( "context" - "os" - "os/exec" - "path/filepath" "testing" "time" + + "dappco.re/go/core/build/internal/ax" ) // dockerAvailable checks if docker is available for fallback generation. func dockerAvailable() bool { - _, err := exec.LookPath("docker") - return err == nil + return ax.Exec(context.Background(), "docker", "info") == nil } // createTestSpec creates a minimal OpenAPI spec for testing. @@ -30,14 +28,14 @@ paths: "200": description: OK ` - specPath := filepath.Join(dir, "openapi.yaml") - if err := os.WriteFile(specPath, []byte(spec), 0644); err != nil { + specPath := ax.Join(dir, "openapi.yaml") + if err := ax.WriteFile(specPath, []byte(spec), 0o644); err != nil { t.Fatalf("failed to write test spec: %v", err) } return specPath } -func TestTypeScriptGenerator_Good_Available(t *testing.T) { +func TestTypeScript_TypeScriptGeneratorAvailable_Good(t *testing.T) { g := NewTypeScriptGenerator() // These should not panic @@ -54,7 +52,7 @@ func TestTypeScriptGenerator_Good_Available(t *testing.T) { } } -func TestTypeScriptGenerator_Good_Generate(t *testing.T) { +func TestTypeScript_TypeScriptGeneratorGenerate_Good(t *testing.T) { g := NewTypeScriptGenerator() if !g.Available() && !dockerAvailable() { t.Skip("no TypeScript generator available (neither native nor docker)") @@ -63,7 +61,7 @@ func TestTypeScriptGenerator_Good_Generate(t *testing.T) { // Create temp directories tmpDir := t.TempDir() specPath := createTestSpec(t, tmpDir) - outputDir := filepath.Join(tmpDir, "output") + outputDir := ax.Join(tmpDir, "output") opts := Options{ SpecPath: specPath, @@ -81,7 +79,7 @@ func TestTypeScriptGenerator_Good_Generate(t *testing.T) { } // Verify output directory was created - if _, err := os.Stat(outputDir); os.IsNotExist(err) { + if !ax.Exists(outputDir) { t.Error("output directory was not created") } } diff --git a/pkg/sdk/sdk.go b/pkg/sdk/sdk.go index 28ca5b8..0de8037 100644 --- a/pkg/sdk/sdk.go +++ b/pkg/sdk/sdk.go @@ -3,14 +3,15 @@ package sdk import ( "context" - "fmt" - "path/filepath" + "dappco.re/go/core" + "dappco.re/go/core/build/internal/ax" "dappco.re/go/core/build/pkg/sdk/generators" coreerr "dappco.re/go/core/log" ) // Config holds SDK generation configuration from .core/release.yaml. +// Usage example: declare a value of type sdk.Config in integrating code. type Config struct { // Spec is the path to the OpenAPI spec file (auto-detected if empty). Spec string `yaml:"spec,omitempty"` @@ -27,6 +28,7 @@ type Config struct { } // PackageConfig holds package naming configuration. +// Usage example: declare a value of type sdk.PackageConfig in integrating code. type PackageConfig struct { // Name is the base package name. Name string `yaml:"name,omitempty"` @@ -35,6 +37,7 @@ type PackageConfig struct { } // DiffConfig holds breaking change detection configuration. +// Usage example: declare a value of type sdk.DiffConfig in integrating code. type DiffConfig struct { // Enabled determines whether to run diff checks. Enabled bool `yaml:"enabled,omitempty"` @@ -43,6 +46,7 @@ type DiffConfig struct { } // PublishConfig holds monorepo publishing configuration. +// Usage example: declare a value of type sdk.PublishConfig in integrating code. type PublishConfig struct { // Repo is the SDK monorepo (e.g., "myorg/sdks"). Repo string `yaml:"repo,omitempty"` @@ -51,6 +55,7 @@ type PublishConfig struct { } // SDK orchestrates OpenAPI SDK generation. +// Usage example: declare a value of type sdk.SDK in integrating code. type SDK struct { config *Config projectDir string @@ -58,6 +63,7 @@ type SDK struct { } // New creates a new SDK instance. +// Usage example: call sdk.New(...) from integrating code. func New(projectDir string, config *Config) *SDK { if config == nil { config = DefaultConfig() @@ -70,6 +76,7 @@ func New(projectDir string, config *Config) *SDK { // SetVersion sets the SDK version for generation. // This updates both the internal version field and the config's Package.Version. +// Usage example: call value.SetVersion(...) from integrating code. func (s *SDK) SetVersion(version string) { s.version = version if s.config != nil { @@ -78,6 +85,7 @@ func (s *SDK) SetVersion(version string) { } // DefaultConfig returns sensible defaults for SDK configuration. +// Usage example: call sdk.DefaultConfig(...) from integrating code. func DefaultConfig() *Config { return &Config{ Languages: []string{"typescript", "python", "go", "php"}, @@ -90,6 +98,7 @@ func DefaultConfig() *Config { } // Generate generates SDKs for all configured languages. +// Usage example: call value.Generate(...) from integrating code. func (s *SDK) Generate(ctx context.Context) error { // Generate for each language for _, lang := range s.config.Languages { @@ -102,6 +111,7 @@ func (s *SDK) Generate(ctx context.Context) error { } // GenerateLanguage generates SDK for a specific language. +// Usage example: call value.GenerateLanguage(...) from integrating code. func (s *SDK) GenerateLanguage(ctx context.Context, lang string) error { specPath, err := s.DetectSpec() if err != nil { @@ -120,11 +130,11 @@ func (s *SDK) GenerateLanguage(ctx context.Context, lang string) error { } if !gen.Available() { - fmt.Printf("Warning: %s generator not available. Install with: %s\n", lang, gen.Install()) - fmt.Printf("Falling back to Docker...\n") + core.Print(nil, "Warning: %s generator not available. Install with: %s", lang, gen.Install()) + core.Print(nil, "Falling back to Docker...") } - outputDir := filepath.Join(s.projectDir, s.config.Output, lang) + outputDir := ax.Join(s.projectDir, s.config.Output, lang) opts := generators.Options{ SpecPath: specPath, OutputDir: outputDir, @@ -132,11 +142,11 @@ func (s *SDK) GenerateLanguage(ctx context.Context, lang string) error { Version: s.config.Package.Version, } - fmt.Printf("Generating %s SDK...\n", lang) + core.Print(nil, "Generating %s SDK...", lang) if err := gen.Generate(ctx, opts); err != nil { return coreerr.E("sdk.GenerateLanguage", lang+" generation failed", err) } - fmt.Printf("Generated %s SDK at %s\n", lang, outputDir) + core.Print(nil, "Generated %s SDK at %s", lang, outputDir) return nil } diff --git a/pkg/sdk/sdk_test.go b/pkg/sdk/sdk_test.go index ced3b91..c3d9d20 100644 --- a/pkg/sdk/sdk_test.go +++ b/pkg/sdk/sdk_test.go @@ -2,22 +2,22 @@ package sdk import ( "context" - "os" - "path/filepath" "testing" + "dappco.re/go/core/build/internal/ax" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestSDK_Good_SetVersion(t *testing.T) { +func TestSDK_SetVersion_Good(t *testing.T) { s := New("/tmp", nil) s.SetVersion("v1.2.3") assert.Equal(t, "v1.2.3", s.version) } -func TestSDK_Good_VersionPassedToGenerator(t *testing.T) { +func TestSDK_VersionPassedToGenerator_Good(t *testing.T) { config := &Config{ Languages: []string{"typescript"}, Output: "sdk", @@ -31,14 +31,14 @@ func TestSDK_Good_VersionPassedToGenerator(t *testing.T) { assert.Equal(t, "v2.0.0", s.config.Package.Version) } -func TestDefaultConfig(t *testing.T) { +func TestSDK_DefaultConfig_Good(t *testing.T) { cfg := DefaultConfig() assert.Contains(t, cfg.Languages, "typescript") assert.Equal(t, "sdk", cfg.Output) assert.True(t, cfg.Diff.Enabled) } -func TestSDK_New(t *testing.T) { +func TestSDK_New_Good(t *testing.T) { t.Run("with nil config", func(t *testing.T) { s := New("/tmp", nil) assert.NotNil(t, s.config) @@ -58,9 +58,9 @@ func TestSDK_GenerateLanguage_Bad(t *testing.T) { tmpDir := t.TempDir() - specPath := filepath.Join(tmpDir, "openapi.yaml") + specPath := ax.Join(tmpDir, "openapi.yaml") - err := os.WriteFile(specPath, []byte("openapi: 3.0.0"), 0644) + err := ax.WriteFile(specPath, []byte("openapi: 3.0.0"), 0644) require.NoError(t, err)