diff --git a/cmd/build/cmd_project.go b/cmd/build/cmd_project.go index ff24cf6..d168d15 100644 --- a/cmd/build/cmd_project.go +++ b/cmd/build/cmd_project.go @@ -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: diff --git a/docs/architecture.md b/docs/architecture.md index 3497358..a48a9f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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/`. | diff --git a/docs/index.md b/docs/index.md index e33888a..6f12559 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/pkg/build/builders/node.go b/pkg/build/builders/node.go new file mode 100644 index 0000000..74be415 --- /dev/null +++ b/pkg/build/builders/node.go @@ -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) diff --git a/pkg/build/builders/node_test.go b/pkg/build/builders/node_test.go new file mode 100644 index 0000000..710494f --- /dev/null +++ b/pkg/build/builders/node_test.go @@ -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) +}