feat(build): support nested Node.js projects

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 18:28:59 +00:00
parent 7da8d7e843
commit 231d43fda1
5 changed files with 141 additions and 5 deletions

View file

@ -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) {

View file

@ -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, ".")

View file

@ -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")
}

View file

@ -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)},

View file

@ -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)
})