feat(build): add Node project builder

This commit is contained in:
Virgil 2026-04-01 10:35:09 +00:00
parent 0c39bc99f3
commit fd566a40fb
5 changed files with 437 additions and 3 deletions

View file

@ -386,7 +386,7 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) {
case build.ProjectTypeCPP:
return builders.NewCPPBuilder(), nil
case build.ProjectTypeNode:
return nil, coreerr.E("build.getBuilder", "node.js builder not yet implemented", nil)
return builders.NewNodeBuilder(), nil
case build.ProjectTypePHP:
return nil, coreerr.E("build.getBuilder", "PHP builder not yet implemented", nil)
default:

View file

@ -53,6 +53,7 @@ type Artifact struct {
|---|---|---|
| **GoBuilder** | `go.mod` or `wails.json` | Sets `GOOS`/`GOARCH`/`CGO_ENABLED=0`, runs `go build -trimpath` with ldflags. Output per target: `dist/{os}_{arch}/{binary}`. |
| **WailsBuilder** | `wails.json` | Checks `go.mod` for Wails v3 vs v2. V3 delegates to TaskfileBuilder; V2 runs `wails build -platform` then copies from `build/bin/` to `dist/`. |
| **NodeBuilder** | `package.json` | Detects the active package manager from lockfiles, runs the build script once per target, and collects artifacts from `dist/{os}_{arch}/`. |
| **DockerBuilder** | `Dockerfile` | Validates `docker` and `buildx`, builds multi-platform images with `docker buildx build --platform`. Supports `--push` or local load/OCI tarball. |
| **LinuxKitBuilder** | `linuxkit.yml` or `.core/linuxkit/*.yml` | Validates `linuxkit` CLI, runs `linuxkit build --format --name --dir --arch`. Outputs qcow2, iso, raw, vmdk, vhd, or cloud images. Linux-only targets. |
| **CPPBuilder** | `CMakeLists.txt` | Validates `make`, runs `make configure` then `make build` then `make package` for host builds. Cross-compilation uses Conan profile targets (e.g. `make gcc-linux-armv8`). Finds artifacts in `build/packages/` or `build/release/src/`. |

View file

@ -41,7 +41,7 @@ The builder is chosen by marker-file priority:
|-------------------|------------|
| `wails.json` | Wails |
| `go.mod` | Go |
| `package.json` | Node (stub)|
| `package.json` | Node |
| `composer.json` | PHP (stub) |
| `CMakeLists.txt` | C++ |
| `Dockerfile` | Docker |
@ -96,7 +96,7 @@ forge.lthn.ai/core/go-build/
|
+-- pkg/
|-- build/ Core build types, config loading, discovery, archiving, checksums
| |-- builders/ Builder implementations (Go, Wails, Docker, LinuxKit, C++, Taskfile)
| |-- builders/ Builder implementations (Go, Wails, Node, Docker, LinuxKit, C++, Taskfile)
| +-- signing/ Code-signing implementations (macOS codesign, GPG, Windows stub)
|
|-- release/ Release orchestration, versioning, changelog, config

231
pkg/build/builders/node.go Normal file
View file

@ -0,0 +1,231 @@
// 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), nil
}
// 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)
}
packageManager, err := b.resolvePackageManager(cfg.FS, cfg.ProjectDir)
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 := []string{
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),
}
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, cfg.ProjectDir, env, command, args...)
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
}
// resolvePackageManager selects the package manager from lockfiles.
//
// packageManager := b.resolvePackageManager(io.Local, ".")
func (b *NodeBuilder) resolvePackageManager(fs io.Medium, projectDir string) (string, error) {
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)

View file

@ -0,0 +1,202 @@
package builders
import (
"context"
"os"
"runtime"
"strings"
"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 setupFakeNodeToolchain(t *testing.T, binDir string) {
t.Helper()
script := `#!/bin/sh
set -eu
log_file="${NODE_BUILD_LOG_FILE:-}"
if [ -n "$log_file" ]; then
printf '%s\n' "$(basename "$0")" >> "$log_file"
printf '%s\n' "$@" >> "$log_file"
printf '%s\n' "GOOS=${GOOS:-}" >> "$log_file"
printf '%s\n' "GOARCH=${GOARCH:-}" >> "$log_file"
printf '%s\n' "OUTPUT_DIR=${OUTPUT_DIR:-}" >> "$log_file"
printf '%s\n' "TARGET_DIR=${TARGET_DIR:-}" >> "$log_file"
fi
output_dir="${OUTPUT_DIR:-dist}"
platform_dir="${TARGET_DIR:-$output_dir/${GOOS:-}_${GOARCH:-}}"
mkdir -p "$platform_dir"
name="${NAME:-nodeapp}"
printf 'fake node artifact\n' > "$platform_dir/$name"
chmod +x "$platform_dir/$name"
`
for _, name := range []string{"npm", "pnpm", "yarn", "bun"} {
require.NoError(t, ax.WriteFile(ax.Join(binDir, name), []byte(script), 0o755))
}
}
func setupNodeTestProject(t *testing.T) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, ax.WriteFile(ax.Join(dir, "package.json"), []byte(`{"name":"testapp","scripts":{"build":"node build.js"}}`), 0o644))
require.NoError(t, ax.WriteFile(ax.Join(dir, "build.js"), []byte(`console.log("build")`), 0o644))
return dir
}
func TestNode_NodeBuilderName_Good(t *testing.T) {
builder := NewNodeBuilder()
assert.Equal(t, "node", builder.Name())
}
func TestNode_NodeBuilderDetect_Good(t *testing.T) {
fs := io.Local
t.Run("detects package.json projects", func(t *testing.T) {
dir := t.TempDir()
require.NoError(t, ax.WriteFile(ax.Join(dir, "package.json"), []byte("{}"), 0o644))
builder := NewNodeBuilder()
detected, err := builder.Detect(fs, dir)
assert.NoError(t, err)
assert.True(t, detected)
})
t.Run("returns false for empty directory", func(t *testing.T) {
builder := NewNodeBuilder()
detected, err := builder.Detect(fs, t.TempDir())
assert.NoError(t, err)
assert.False(t, detected)
})
}
func TestNode_NodeBuilderBuild_Good(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
binDir := t.TempDir()
setupFakeNodeToolchain(t, binDir)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
projectDir := setupNodeTestProject(t)
outputDir := t.TempDir()
logDir := t.TempDir()
logPath := ax.Join(logDir, "node.log")
t.Setenv("NODE_BUILD_LOG_FILE", logPath)
require.NoError(t, ax.WriteFile(ax.Join(projectDir, "pnpm-lock.yaml"), []byte("lockfile"), 0o644))
builder := NewNodeBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
Name: "testapp",
Version: "v1.2.3",
}
targets := []build.Target{
{OS: "linux", Arch: "amd64"},
}
artifacts, err := builder.Build(context.Background(), cfg, targets)
require.NoError(t, err)
require.Len(t, artifacts, 1)
assert.FileExists(t, artifacts[0].Path)
assert.Equal(t, "linux", artifacts[0].OS)
assert.Equal(t, "amd64", artifacts[0].Arch)
content, err := ax.ReadFile(logPath)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
require.GreaterOrEqual(t, len(lines), 5)
assert.Equal(t, "pnpm", lines[0])
assert.Equal(t, "run", lines[1])
assert.Equal(t, "build", lines[2])
assert.Equal(t, "GOOS=linux", lines[3])
assert.Equal(t, "GOARCH=amd64", lines[4])
assert.Contains(t, lines, "OUTPUT_DIR="+outputDir)
assert.Contains(t, lines, "TARGET_DIR="+ax.Join(outputDir, "linux_amd64"))
}
func TestNode_NodeBuilderFindArtifactsForTarget_Good(t *testing.T) {
fs := io.Local
builder := NewNodeBuilder()
t.Run("finds files in platform subdirectory", func(t *testing.T) {
dir := t.TempDir()
platformDir := ax.Join(dir, "linux_amd64")
require.NoError(t, ax.MkdirAll(platformDir, 0o755))
artifactPath := ax.Join(platformDir, "testapp")
require.NoError(t, ax.WriteFile(artifactPath, []byte("binary"), 0o755))
artifacts := builder.findArtifactsForTarget(fs, dir, build.Target{OS: "linux", Arch: "amd64"})
require.Len(t, artifacts, 1)
assert.Equal(t, artifactPath, artifacts[0].Path)
})
t.Run("finds darwin app bundles", func(t *testing.T) {
dir := t.TempDir()
platformDir := ax.Join(dir, "darwin_arm64")
appDir := ax.Join(platformDir, "TestApp.app")
require.NoError(t, ax.MkdirAll(appDir, 0o755))
artifacts := builder.findArtifactsForTarget(fs, dir, build.Target{OS: "darwin", Arch: "arm64"})
require.Len(t, artifacts, 1)
assert.Equal(t, appDir, artifacts[0].Path)
})
t.Run("falls back to name patterns in root", func(t *testing.T) {
dir := t.TempDir()
artifactPath := ax.Join(dir, "testapp-linux-amd64")
require.NoError(t, ax.WriteFile(artifactPath, []byte("binary"), 0o755))
artifacts := builder.findArtifactsForTarget(fs, dir, build.Target{OS: "linux", Arch: "amd64"})
require.NotEmpty(t, artifacts)
assert.Equal(t, artifactPath, artifacts[0].Path)
})
}
func TestNode_NodeBuilderInterface_Good(t *testing.T) {
var _ build.Builder = (*NodeBuilder)(nil)
var _ build.Builder = NewNodeBuilder()
}
func TestNode_NodeBuilderBuildDefaults_Good(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
binDir := t.TempDir()
setupFakeNodeToolchain(t, binDir)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
projectDir := setupNodeTestProject(t)
outputDir := t.TempDir()
builder := NewNodeBuilder()
cfg := &build.Config{
FS: io.Local,
ProjectDir: projectDir,
OutputDir: outputDir,
}
artifacts, err := builder.Build(context.Background(), cfg, nil)
require.NoError(t, err)
require.Len(t, artifacts, 1)
assert.Equal(t, runtime.GOOS, artifacts[0].OS)
assert.Equal(t, runtime.GOARCH, artifacts[0].Arch)
}