feat(build): add Node project builder
This commit is contained in:
parent
0c39bc99f3
commit
fd566a40fb
5 changed files with 437 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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/`. |
|
||||
|
|
|
|||
|
|
@ -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
231
pkg/build/builders/node.go
Normal 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)
|
||||
202
pkg/build/builders/node_test.go
Normal file
202
pkg/build/builders/node_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue