diff --git a/internal/projectdetect/projectdetect_test.go b/internal/projectdetect/projectdetect_test.go index ea80db7..69c6d7d 100644 --- a/internal/projectdetect/projectdetect_test.go +++ b/internal/projectdetect/projectdetect_test.go @@ -60,6 +60,17 @@ func TestDetectProjectType_Good(t *testing.T) { require.NoError(t, err) assert.Equal(t, build.ProjectTypeTaskfile, projectType) }) + + t.Run("detects nested Node.js projects", func(t *testing.T) { + dir := t.TempDir() + nested := ax.Join(dir, "apps", "web") + require.NoError(t, ax.MkdirAll(nested, 0o755)) + require.NoError(t, ax.WriteFile(ax.Join(nested, "package.json"), []byte("{}"), 0o644)) + + projectType, err := DetectProjectType(fs, dir) + require.NoError(t, err) + assert.Equal(t, build.ProjectTypeNode, projectType) + }) } func TestDetectProjectType_Bad(t *testing.T) { diff --git a/pkg/build/builders/node.go b/pkg/build/builders/node.go index ce09084..49ea6a9 100644 --- a/pkg/build/builders/node.go +++ b/pkg/build/builders/node.go @@ -36,7 +36,7 @@ func (b *NodeBuilder) Name() string { // // ok, err := b.Detect(io.Local, ".") func (b *NodeBuilder) Detect(fs io.Medium, dir string) (bool, error) { - return build.IsNodeProject(fs, dir), nil + return build.IsNodeProject(fs, dir) || b.resolveNodeProjectDir(fs, dir) != "", nil } // Build runs the project build script once per target and collects artifacts @@ -60,7 +60,12 @@ func (b *NodeBuilder) Build(ctx context.Context, cfg *build.Config, targets []bu return nil, coreerr.E("NodeBuilder.Build", "failed to create output directory", err) } - packageManager, err := b.resolvePackageManager(cfg.FS, cfg.ProjectDir) + projectDir := b.resolveNodeProjectDir(cfg.FS, cfg.ProjectDir) + if projectDir == "" { + projectDir = cfg.ProjectDir + } + + packageManager, err := b.resolvePackageManager(cfg.FS, projectDir) if err != nil { return nil, err } @@ -92,7 +97,7 @@ func (b *NodeBuilder) Build(ctx context.Context, cfg *build.Config, targets []bu env = append(env, core.Sprintf("VERSION=%s", cfg.Version)) } - output, err := ax.CombinedOutput(ctx, cfg.ProjectDir, env, command, args...) + output, err := ax.CombinedOutput(ctx, projectDir, env, command, args...) if err != nil { return artifacts, coreerr.E("NodeBuilder.Build", command+" build failed: "+output, err) } @@ -104,6 +109,50 @@ func (b *NodeBuilder) Build(ctx context.Context, cfg *build.Config, targets []bu 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 "" +} + // resolvePackageManager selects the package manager from lockfiles. // // packageManager := b.resolvePackageManager(io.Local, ".") diff --git a/pkg/build/builders/node_test.go b/pkg/build/builders/node_test.go index 7bf540e..170092f 100644 --- a/pkg/build/builders/node_test.go +++ b/pkg/build/builders/node_test.go @@ -80,6 +80,18 @@ func TestNode_NodeBuilderDetect_Good(t *testing.T) { assert.NoError(t, err) assert.False(t, detected) }) + + t.Run("detects nested package.json projects", func(t *testing.T) { + dir := t.TempDir() + nested := ax.Join(dir, "apps", "web") + require.NoError(t, ax.MkdirAll(nested, 0o755)) + require.NoError(t, ax.WriteFile(ax.Join(nested, "package.json"), []byte("{}"), 0o644)) + + builder := NewNodeBuilder() + detected, err := builder.Detect(fs, dir) + assert.NoError(t, err) + assert.True(t, detected) + }) } func TestNode_NodeBuilderBuild_Good(t *testing.T) { @@ -228,3 +240,44 @@ func TestNode_NodeBuilderBuildDefaults_Good(t *testing.T) { assert.Equal(t, runtime.GOOS, artifacts[0].OS) assert.Equal(t, runtime.GOARCH, artifacts[0].Arch) } + +func TestNode_NodeBuilderBuild_Good_NestedProject(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 := t.TempDir() + nestedDir := ax.Join(projectDir, "apps", "web") + require.NoError(t, ax.MkdirAll(nestedDir, 0o755)) + require.NoError(t, ax.WriteFile(ax.Join(nestedDir, "package.json"), []byte(`{"name":"nested-app","scripts":{"build":"node build.js"}}`), 0o644)) + require.NoError(t, ax.WriteFile(ax.Join(nestedDir, "build.js"), []byte(`console.log("nested build")`), 0o644)) + + outputDir := t.TempDir() + logDir := t.TempDir() + logPath := ax.Join(logDir, "node-nested.log") + t.Setenv("NODE_BUILD_LOG_FILE", logPath) + + builder := NewNodeBuilder() + cfg := &build.Config{ + FS: io.Local, + ProjectDir: projectDir, + OutputDir: outputDir, + Name: "nested-app", + Version: "v1.2.3", + } + + artifacts, err := builder.Build(context.Background(), cfg, []build.Target{{OS: "linux", Arch: "amd64"}}) + require.NoError(t, err) + require.Len(t, artifacts, 1) + assert.FileExists(t, artifacts[0].Path) + + content, err := ax.ReadFile(logPath) + require.NoError(t, err) + assert.Contains(t, string(content), "PWD="+nestedDir) + assert.Contains(t, string(content), "GOOS=linux") + assert.Contains(t, string(content), "GOARCH=amd64") +} diff --git a/pkg/build/discovery.go b/pkg/build/discovery.go index 90eef4d..5b52484 100644 --- a/pkg/build/discovery.go +++ b/pkg/build/discovery.go @@ -74,6 +74,7 @@ func Discover(fs io.Medium, dir string) ([]ProjectType, error) { projectType ProjectType detected bool }{ + {ProjectTypeNode, IsNodeProject(fs, dir) || HasSubtreeNpm(fs, dir)}, {ProjectTypeDocs, IsMkDocsProject(fs, dir)}, {ProjectTypeDocker, IsDockerProject(fs, dir)}, {ProjectTypeLinuxKit, IsLinuxKitProject(fs, dir)}, diff --git a/pkg/build/discovery_test.go b/pkg/build/discovery_test.go index 07d149f..c6d51b5 100644 --- a/pkg/build/discovery_test.go +++ b/pkg/build/discovery_test.go @@ -45,6 +45,17 @@ func TestDiscovery_Discover_Good(t *testing.T) { assert.Equal(t, []ProjectType{ProjectTypeNode}, types) }) + t.Run("detects nested Node.js project", func(t *testing.T) { + dir := t.TempDir() + nested := ax.Join(dir, "apps", "web") + require.NoError(t, ax.MkdirAll(nested, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(nested, "package.json"), []byte("{}"), 0644)) + + types, err := Discover(fs, dir) + assert.NoError(t, err) + assert.Equal(t, []ProjectType{ProjectTypeNode}, types) + }) + t.Run("detects PHP project", func(t *testing.T) { dir := setupTestDir(t, "composer.json") types, err := Discover(fs, dir) @@ -203,6 +214,17 @@ func TestDiscovery_PrimaryType_Good(t *testing.T) { assert.Equal(t, ProjectTypeGo, primary) }) + t.Run("returns node for nested package.json project", func(t *testing.T) { + dir := t.TempDir() + nested := ax.Join(dir, "apps", "web") + require.NoError(t, ax.MkdirAll(nested, 0755)) + require.NoError(t, ax.WriteFile(ax.Join(nested, "package.json"), []byte("{}"), 0644)) + + primary, err := PrimaryType(fs, dir) + assert.NoError(t, err) + assert.Equal(t, ProjectTypeNode, primary) + }) + t.Run("returns empty string for empty directory", func(t *testing.T) { dir := t.TempDir() primary, err := PrimaryType(fs, dir) @@ -537,7 +559,7 @@ func TestDiscovery_DiscoverFull_Good(t *testing.T) { result, err := DiscoverFull(fs, dir) require.NoError(t, err) - assert.Equal(t, []ProjectType{ProjectTypeWails, ProjectTypeGo}, result.Types) + assert.Equal(t, []ProjectType{ProjectTypeWails, ProjectTypeGo, ProjectTypeNode}, result.Types) assert.Equal(t, "wails", result.PrimaryStack) assert.True(t, result.HasFrontend) assert.True(t, result.Markers["wails.json"]) @@ -556,7 +578,7 @@ func TestDiscovery_DiscoverFull_Good(t *testing.T) { result, err := DiscoverFull(fs, dir) require.NoError(t, err) - assert.Equal(t, []ProjectType{ProjectTypeGo}, result.Types) + assert.Equal(t, []ProjectType{ProjectTypeGo, ProjectTypeNode}, result.Types) assert.True(t, result.HasSubtreeNpm) assert.True(t, result.HasFrontend) })