go-build/pkg/build/builders/node.go

285 lines
7.7 KiB
Go
Raw Permalink Normal View History

2026-04-01 10:35:09 +00:00
// Package builders provides build implementations for different project types.
package builders
import (
"context"
"path"
"runtime"
"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"
)
// NodeBuilder builds Node.js projects with the detected package manager.
//
// b := builders.NewNodeBuilder()
type NodeBuilder struct{}
// NewNodeBuilder creates a new NodeBuilder instance.
//
// b := builders.NewNodeBuilder()
func NewNodeBuilder() *NodeBuilder {
return &NodeBuilder{}
}
// Name returns the builder's identifier.
//
// name := b.Name() // → "node"
func (b *NodeBuilder) Name() string {
return "node"
}
// Detect checks if this builder can handle the project in the given directory.
//
// ok, err := b.Detect(io.Local, ".")
func (b *NodeBuilder) Detect(fs io.Medium, dir string) (bool, error) {
return build.IsNodeProject(fs, dir) || b.resolveNodeProjectDir(fs, dir) != "", nil
2026-04-01 10:35:09 +00:00
}
// Build runs the project build script once per target and collects artifacts
// from the target-specific output directory.
//
// artifacts, err := b.Build(ctx, cfg, []build.Target{{OS: "linux", Arch: "amd64"}})
func (b *NodeBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) {
if cfg == nil {
return nil, coreerr.E("NodeBuilder.Build", "config is nil", nil)
}
if len(targets) == 0 {
targets = []build.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}
}
outputDir := cfg.OutputDir
if outputDir == "" {
outputDir = ax.Join(cfg.ProjectDir, "dist")
}
if err := cfg.FS.EnsureDir(outputDir); err != nil {
return nil, coreerr.E("NodeBuilder.Build", "failed to create output directory", err)
}
projectDir := b.resolveNodeProjectDir(cfg.FS, cfg.ProjectDir)
if projectDir == "" {
projectDir = cfg.ProjectDir
}
packageManager, err := b.resolvePackageManager(cfg.FS, projectDir)
2026-04-01 10:35:09 +00:00
if err != nil {
return nil, err
}
command, args, err := b.resolveBuildCommand(packageManager)
if err != nil {
return nil, err
}
var artifacts []build.Artifact
for _, target := range targets {
platformDir := ax.Join(outputDir, core.Sprintf("%s_%s", target.OS, target.Arch))
if err := cfg.FS.EnsureDir(platformDir); err != nil {
return artifacts, coreerr.E("NodeBuilder.Build", "failed to create platform directory", err)
}
env := appendConfiguredEnv(cfg.Env,
2026-04-01 10:35:09 +00:00
core.Sprintf("GOOS=%s", target.OS),
core.Sprintf("GOARCH=%s", target.Arch),
core.Sprintf("TARGET_OS=%s", target.OS),
core.Sprintf("TARGET_ARCH=%s", target.Arch),
core.Sprintf("OUTPUT_DIR=%s", outputDir),
core.Sprintf("TARGET_DIR=%s", platformDir),
)
2026-04-01 10:35:09 +00:00
if cfg.Name != "" {
env = append(env, core.Sprintf("NAME=%s", cfg.Name))
}
if cfg.Version != "" {
env = append(env, core.Sprintf("VERSION=%s", cfg.Version))
}
output, err := ax.CombinedOutput(ctx, projectDir, env, command, args...)
2026-04-01 10:35:09 +00:00
if err != nil {
return artifacts, coreerr.E("NodeBuilder.Build", command+" build failed: "+output, err)
}
found := b.findArtifactsForTarget(cfg.FS, outputDir, target)
artifacts = append(artifacts, found...)
}
return artifacts, nil
}
// resolveNodeProjectDir locates the directory containing package.json.
// It prefers the project root, then searches nested directories to depth 2.
func (b *NodeBuilder) resolveNodeProjectDir(fs io.Medium, projectDir string) string {
if fs.IsFile(ax.Join(projectDir, "package.json")) {
return projectDir
}
return b.findNodeProjectDir(fs, projectDir, 0)
}
// findNodeProjectDir searches for a package.json within nested directories.
func (b *NodeBuilder) findNodeProjectDir(fs io.Medium, dir string, depth int) string {
if depth >= 2 {
return ""
}
entries, err := fs.List(dir)
if err != nil {
return ""
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if name == "node_modules" || core.HasPrefix(name, ".") {
continue
}
candidateDir := ax.Join(dir, name)
if fs.IsFile(ax.Join(candidateDir, "package.json")) {
return candidateDir
}
if nested := b.findNodeProjectDir(fs, candidateDir, depth+1); nested != "" {
return nested
}
}
return ""
}
2026-04-01 10:35:09 +00:00
// resolvePackageManager selects the package manager from lockfiles.
//
// packageManager := b.resolvePackageManager(io.Local, ".")
func (b *NodeBuilder) resolvePackageManager(fs io.Medium, projectDir string) (string, error) {
if declared := detectDeclaredPackageManager(fs, projectDir); declared != "" {
return declared, nil
}
2026-04-01 10:35:09 +00:00
switch {
case fs.IsFile(ax.Join(projectDir, "bun.lockb")) || fs.IsFile(ax.Join(projectDir, "bun.lock")):
return "bun", nil
case fs.IsFile(ax.Join(projectDir, "pnpm-lock.yaml")):
return "pnpm", nil
case fs.IsFile(ax.Join(projectDir, "yarn.lock")):
return "yarn", nil
case fs.IsFile(ax.Join(projectDir, "package-lock.json")):
return "npm", nil
default:
return "npm", nil
}
}
// resolveBuildCommand returns the executable and arguments for the selected package manager.
//
// command, args, err := b.resolveBuildCommand("npm")
func (b *NodeBuilder) resolveBuildCommand(packageManager string) (string, []string, error) {
var paths []string
switch packageManager {
case "bun":
paths = []string{"/usr/local/bin/bun", "/opt/homebrew/bin/bun"}
case "pnpm":
paths = []string{"/usr/local/bin/pnpm", "/opt/homebrew/bin/pnpm"}
case "yarn":
paths = []string{"/usr/local/bin/yarn", "/opt/homebrew/bin/yarn"}
default:
paths = []string{"/usr/local/bin/npm", "/opt/homebrew/bin/npm"}
packageManager = "npm"
}
command, err := ax.ResolveCommand(packageManager, paths...)
if err != nil {
return "", nil, coreerr.E("NodeBuilder.resolveBuildCommand", packageManager+" CLI not found", err)
}
switch packageManager {
case "yarn":
return command, []string{"build"}, nil
default:
return command, []string{"run", "build"}, nil
}
}
// findArtifactsForTarget searches for build outputs in the target-specific output directory.
//
// artifacts := b.findArtifactsForTarget(io.Local, "dist", build.Target{OS: "linux", Arch: "amd64"})
func (b *NodeBuilder) findArtifactsForTarget(fs io.Medium, outputDir string, target build.Target) []build.Artifact {
var artifacts []build.Artifact
platformDir := ax.Join(outputDir, core.Sprintf("%s_%s", target.OS, target.Arch))
if fs.IsDir(platformDir) {
entries, err := fs.List(platformDir)
if err == nil {
for _, entry := range entries {
if entry.IsDir() {
if target.OS == "darwin" && core.HasSuffix(entry.Name(), ".app") {
artifacts = append(artifacts, build.Artifact{
Path: ax.Join(platformDir, entry.Name()),
OS: target.OS,
Arch: target.Arch,
})
}
continue
}
name := entry.Name()
if core.HasPrefix(name, ".") || name == "CHECKSUMS.txt" {
continue
}
artifacts = append(artifacts, build.Artifact{
Path: ax.Join(platformDir, name),
OS: target.OS,
Arch: target.Arch,
})
}
}
if len(artifacts) > 0 {
return artifacts
}
}
patterns := []string{
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 {
entries, err := fs.List(outputDir)
if err != nil {
continue
}
for _, entry := range entries {
match := entry.Name()
matched, _ := path.Match(pattern, match)
if !matched {
continue
}
fullPath := ax.Join(outputDir, match)
if fs.IsDir(fullPath) {
continue
}
artifacts = append(artifacts, build.Artifact{
Path: fullPath,
OS: target.OS,
Arch: target.Arch,
})
}
if len(artifacts) > 0 {
break
}
}
return artifacts
}
// Ensure NodeBuilder implements the Builder interface.
var _ build.Builder = (*NodeBuilder)(nil)