diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 9fe579b3..c5655b07 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -3,6 +3,7 @@ package bundler import ( "archive/zip" "context" + "encoding/json" "fmt" "io" "math" @@ -20,7 +21,9 @@ import ( cstr "github.com/agentuity/go-common/string" "github.com/agentuity/go-common/sys" "github.com/agentuity/go-common/tui" + "github.com/bmatcuk/doublestar/v4" "github.com/evanw/esbuild/pkg/api" + "gopkg.in/yaml.v3" "k8s.io/apimachinery/pkg/api/resource" ) @@ -117,11 +120,11 @@ func installSourceMapSupportIfNeeded(ctx BundleContext, dir string) error { return nil } -func runTypecheck(ctx BundleContext, dir string) error { +func runTypecheck(ctx BundleContext, dir string, installDir string) error { if ctx.Production { return nil } - tsc := filepath.Join(dir, "node_modules", ".bin", "tsc") + tsc := filepath.Join(installDir, "node_modules", ".bin", "tsc") if !util.Exists(tsc) { ctx.Logger.Warn("no tsc found at %s, skipping typecheck", tsc) return nil @@ -141,6 +144,169 @@ func runTypecheck(ctx BundleContext, dir string) error { return nil } +// WorkspaceConfig represents workspace configuration +type WorkspaceConfig struct { + Root string + Type string // "npm", "yarn", or "pnpm" + Patterns []string // workspace patterns +} + +// detectWorkspaceRoot walks up from startDir looking for workspace configuration +func detectWorkspaceRoot(logger logger.Logger, startDir string) (*WorkspaceConfig, error) { + dir := startDir + for { + // Check for npm/yarn workspaces in package.json + packageJsonPath := filepath.Join(dir, "package.json") + if util.Exists(packageJsonPath) { + var pkg struct { + Workspaces interface{} `json:"workspaces"` + } + data, err := os.ReadFile(packageJsonPath) + if err == nil { + if json.Unmarshal(data, &pkg) == nil && pkg.Workspaces != nil { + patterns, err := parseNpmWorkspaces(pkg.Workspaces) + if err == nil && len(patterns) > 0 { + logger.Debug("found npm workspace config at %s", packageJsonPath) + return &WorkspaceConfig{ + Root: dir, + Type: "npm", + Patterns: patterns, + }, nil + } + } + } + } + + // Check for pnpm workspace + pnpmWorkspacePath := filepath.Join(dir, "pnpm-workspace.yaml") + if util.Exists(pnpmWorkspacePath) { + var workspace struct { + Packages []string `yaml:"packages"` + } + data, err := os.ReadFile(pnpmWorkspacePath) + if err == nil { + if yaml.Unmarshal(data, &workspace) == nil && len(workspace.Packages) > 0 { + logger.Debug("found pnpm workspace config at %s", pnpmWorkspacePath) + return &WorkspaceConfig{ + Root: dir, + Type: "pnpm", + Patterns: workspace.Packages, + }, nil + } + } + } + + parent := filepath.Dir(dir) + if parent == dir { + // Reached root directory + break + } + dir = parent + } + return nil, nil +} + +// parseNpmWorkspaces handles different npm workspaces formats +func parseNpmWorkspaces(workspaces interface{}) ([]string, error) { + switch v := workspaces.(type) { + case []interface{}: + // Array format: ["packages/*", "apps/*"] + patterns := make([]string, len(v)) + for i, pattern := range v { + if str, ok := pattern.(string); ok { + patterns[i] = str + } else { + return nil, fmt.Errorf("invalid workspace pattern type") + } + } + return patterns, nil + case map[string]interface{}: + // Object format: {"packages": ["packages/*"]} + if packages, ok := v["packages"].([]interface{}); ok { + patterns := make([]string, len(packages)) + for i, pattern := range packages { + if str, ok := pattern.(string); ok { + patterns[i] = str + } else { + return nil, fmt.Errorf("invalid workspace pattern type") + } + } + return patterns, nil + } + } + return nil, fmt.Errorf("unsupported workspace format") +} + +// isAgentInWorkspace checks if the agent directory matches any workspace patterns +func isAgentInWorkspace(logger logger.Logger, agentDir string, workspace *WorkspaceConfig) bool { + // Get relative path from workspace root to agent directory + relPath, err := filepath.Rel(workspace.Root, agentDir) + if err != nil { + logger.Debug("failed to get relative path: %v", err) + return false + } + + // Check if agent is outside workspace root + if strings.HasPrefix(relPath, "..") { + logger.Debug("agent directory is outside workspace root") + return false + } + + // Check each workspace pattern + for _, pattern := range workspace.Patterns { + if matchesWorkspacePattern(relPath, pattern) { + logger.Debug("agent directory matches workspace pattern: %s", pattern) + return true + } + } + + logger.Debug("agent directory doesn't match any workspace patterns") + return false +} + +// matchesWorkspacePattern checks if a path matches a workspace pattern using robust glob matching +// Supports npm-style patterns including "**" for recursive matching and proper cross-platform paths +func matchesWorkspacePattern(path, pattern string) bool { + // Normalize paths to use forward slashes for cross-platform compatibility + normalizedPath := filepath.ToSlash(path) + normalizedPattern := filepath.ToSlash(pattern) + + // Handle negation patterns (e.g., "!excluded") + if strings.HasPrefix(normalizedPattern, "!") { + // This is a negation pattern - check if the path matches the pattern without "!" + innerPattern := strings.TrimPrefix(normalizedPattern, "!") + matched, err := doublestar.PathMatch(innerPattern, normalizedPath) + // For negation patterns, we return the inverse of the match + return err == nil && !matched + } + + // Use doublestar for robust glob matching that supports "**" and proper npm-style patterns + matched, err := doublestar.PathMatch(normalizedPattern, normalizedPath) + return err == nil && matched +} + +// findWorkspaceInstallDir determines where to install dependencies +func findWorkspaceInstallDir(logger logger.Logger, agentDir string) string { + workspace, err := detectWorkspaceRoot(logger, agentDir) + if err != nil { + logger.Debug("error detecting workspace: %v", err) + return agentDir + } + + if workspace == nil { + logger.Debug("no workspace detected, using agent directory") + return agentDir + } + + if isAgentInWorkspace(logger, agentDir, workspace) { + logger.Debug("agent is part of %s workspace, using workspace root: %s", workspace.Type, workspace.Root) + return workspace.Root + } + + logger.Debug("agent is not part of workspace, using agent directory") + return agentDir +} + // detectPackageManager detects which package manager to use based on lockfiles func detectPackageManager(projectDir string) string { if util.Exists(filepath.Join(projectDir, "pnpm-lock.yaml")) { @@ -156,19 +322,33 @@ func detectPackageManager(projectDir string) string { // jsInstallCommandSpec returns the base command name and arguments for installing JavaScript dependencies // This function returns the base command without CI-specific modifications -func jsInstallCommandSpec(projectDir string) (string, []string, error) { +func jsInstallCommandSpec(projectDir string, isWorkspace bool, production bool) (string, []string, error) { packageManager := detectPackageManager(projectDir) switch packageManager { case "pnpm": + if isWorkspace && !production { + // In workspaces during development, install all dependencies including devDependencies + // This ensures @types packages are available for TypeScript compilation + return "pnpm", []string{"install", "--ignore-scripts", "--silent"}, nil + } return "pnpm", []string{"install", "--prod", "--ignore-scripts", "--silent"}, nil case "bun": + if isWorkspace && !production { + return "bun", []string{"install", "--ignore-scripts", "--no-progress", "--no-summary", "--silent"}, nil + } return "bun", []string{"install", "--production", "--ignore-scripts", "--no-progress", "--no-summary", "--silent"}, nil case "yarn": return "yarn", []string{"install", "--frozen-lockfile"}, nil case "npm": + if isWorkspace && !production { + return "npm", []string{"install", "--no-audit", "--no-fund", "--ignore-scripts"}, nil + } return "npm", []string{"install", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"}, nil default: + if isWorkspace && !production { + return "npm", []string{"install", "--no-audit", "--no-fund", "--ignore-scripts"}, nil + } return "npm", []string{"install", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"}, nil } } @@ -218,7 +398,7 @@ func applyCIModifications(ctx BundleContext, cmd, runtime string, args []string) } // getJSInstallCommand returns the complete install command with CI modifications applied -func getJSInstallCommand(ctx BundleContext, projectDir, runtime string) (string, []string, error) { +func getJSInstallCommand(ctx BundleContext, projectDir, runtime string, isWorkspace bool) (string, []string, error) { // For bun, we need to ensure the lockfile is up to date before we can run the install // otherwise we'll get an error about the lockfile being out of date // Only do this if we have a logger (i.e., not in tests) @@ -228,7 +408,7 @@ func getJSInstallCommand(ctx BundleContext, projectDir, runtime string) (string, } } - cmd, args, err := jsInstallCommandSpec(projectDir) + cmd, args, err := jsInstallCommandSpec(projectDir, isWorkspace, ctx.Production) if err != nil { return "", nil, err } @@ -241,15 +421,19 @@ func getJSInstallCommand(ctx BundleContext, projectDir, runtime string) (string, func bundleJavascript(ctx BundleContext, dir string, outdir string, theproject *project.Project) error { - if ctx.Install || !util.Exists(filepath.Join(dir, "node_modules")) { - cmd, args, err := getJSInstallCommand(ctx, dir, theproject.Bundler.Runtime) + // Determine where to install dependencies (workspace root or agent directory) + installDir := findWorkspaceInstallDir(ctx.Logger, dir) + isWorkspace := installDir != dir // We're using workspace root if installDir differs from agent dir + + if ctx.Install || !util.Exists(filepath.Join(installDir, "node_modules")) { + cmd, args, err := getJSInstallCommand(ctx, installDir, theproject.Bundler.Runtime, isWorkspace) if err != nil { return err } install := exec.CommandContext(ctx.Context, cmd, args...) util.ProcessSetup(install) - install.Dir = dir + install.Dir = installDir out, err := install.CombinedOutput() var ec int if install.ProcessState != nil { @@ -270,7 +454,7 @@ func bundleJavascript(ctx BundleContext, dir string, outdir string, theproject * var shimSourceMap bool if theproject.Bundler.Runtime == "bunjs" { - if err := installSourceMapSupportIfNeeded(ctx, dir); err != nil { + if err := installSourceMapSupportIfNeeded(ctx, installDir); err != nil { return fmt.Errorf("failed to install bun source-map-support: %w", err) } shimSourceMap = true @@ -280,7 +464,7 @@ func bundleJavascript(ctx BundleContext, dir string, outdir string, theproject * return err } - if err := runTypecheck(ctx, dir); err != nil { + if err := runTypecheck(ctx, dir, installDir); err != nil { return err } @@ -304,7 +488,7 @@ func bundleJavascript(ctx BundleContext, dir string, outdir string, theproject * return fmt.Errorf("failed to load %s: %w", pkgjson, err) } ctx.Logger.Debug("resolving agentuity sdk") - agentuitypkg, err := resolveAgentuity(ctx.Logger, dir) + agentuitypkg, err := resolveAgentuity(ctx.Logger, installDir) if err != nil { return err } diff --git a/internal/bundler/bundler_test.go b/internal/bundler/bundler_test.go index b2e0a546..24e28f88 100644 --- a/internal/bundler/bundler_test.go +++ b/internal/bundler/bundler_test.go @@ -38,16 +38,16 @@ func TestPyProject(t *testing.T) { } // Helper function to test package manager detection logic -func testPackageManagerCommand(t *testing.T, tempDir string, runtime string, isCI bool, expectedCmd string, expectedArgs []string) { +func testPackageManagerCommand(t *testing.T, tempDir string, runtime string, isCI bool, isWorkspace bool, expectedCmd string, expectedArgs []string) { ctx := BundleContext{ Context: context.Background(), Logger: nil, // nil logger will skip bun lockfile generation in tests CI: isCI, } - - actualCmd, actualArgs, err := getJSInstallCommand(ctx, tempDir, runtime) + + actualCmd, actualArgs, err := getJSInstallCommand(ctx, tempDir, runtime, isWorkspace) require.NoError(t, err) - + assert.Equal(t, expectedCmd, actualCmd) assert.Equal(t, expectedArgs, actualArgs) } @@ -130,7 +130,7 @@ func TestJavaScriptPackageManagerDetection(t *testing.T) { } // Test the logic with CI=false - testPackageManagerCommand(t, tempDir, tt.runtime, false, tt.expectedCmd, tt.expectedArgs) + testPackageManagerCommand(t, tempDir, tt.runtime, false, false, tt.expectedCmd, tt.expectedArgs) }) } } @@ -144,6 +144,7 @@ func TestPnpmCIFlags(t *testing.T) { tempDir, "pnpm", true, + false, // isWorkspace=false "pnpm", []string{"install", "--prod", "--ignore-scripts", "--reporter=append-only", "--frozen-lockfile"}, ) @@ -153,7 +154,86 @@ func TestPnpmCIFlags(t *testing.T) { tempDir, "pnpm", false, + false, // isWorkspace=false "pnpm", []string{"install", "--prod", "--ignore-scripts", "--silent"}, ) } + +func TestMatchesWorkspacePattern(t *testing.T) { + tests := []struct { + name string + path string + pattern string + expected bool + }{ + // Basic exact match + {"exact match", "packages/core", "packages/core", true}, + {"no match", "packages/core", "packages/utils", false}, + + // Simple glob patterns + {"single star", "packages/core", "packages/*", true}, + {"single star no match", "packages/core/src", "packages/*", false}, + + // Double star patterns (recursive) + {"double star recursive", "packages/core/src/index.ts", "packages/**/index.ts", true}, + {"double star deep", "src/components/ui/button/index.ts", "**/button/index.ts", true}, + {"double star no match", "packages/core/src/main.ts", "packages/**/index.ts", false}, + + // Negation patterns + {"negation match", "packages/excluded", "!packages/excluded", false}, + {"negation no match", "packages/included", "!packages/excluded", true}, + + // Cross-platform path handling (normalize different separator styles) + {"windows-style path", filepath.Join("packages", "core"), "packages/*", true}, + {"nested path", filepath.Join("packages", "core", "src"), "packages/**/src", true}, + + // Complex patterns + {"file extension", "src/test.spec.ts", "**/*.spec.ts", true}, + {"multiple levels", "apps/web/src/pages/index.tsx", "apps/*/src/**/*.tsx", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchesWorkspacePattern(tt.path, tt.pattern) + assert.Equal(t, tt.expected, result, "pattern %q should match path %q: %v", tt.pattern, tt.path, tt.expected) + }) + } +} + +func TestNpmStyleGlobPatterns(t *testing.T) { + // Test specific patterns mentioned in the original issue + tests := []struct { + name string + path string + pattern string + expected bool + }{ + // Test "packages/**/src" pattern with deep nesting + {"deep nested src", "packages/ui/components/src", "packages/**/src", true}, + {"deep nested src file", "packages/ui/components/src/index.ts", "packages/**/src/*", true}, + + // Test "**/*" pattern for matching everything + {"match all files", "any/deeply/nested/file.ts", "**/*", true}, + {"match all directories", "any/deeply/nested/dir", "**/*", true}, + + // Test negation patterns like "!excluded" + {"exclude specific pattern", "packages/excluded", "!packages/excluded", false}, + {"include non-excluded", "packages/included", "!packages/excluded", true}, + {"exclude with wildcards", "test/excluded/file.ts", "!test/excluded/**", false}, + {"include with wildcards", "test/included/file.ts", "!test/excluded/**", true}, + + // Test exact patterns that the old implementation might have broken + {"exact directory match", "packages", "packages", true}, + {"exact file match", "package.json", "package.json", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchesWorkspacePattern(tt.path, tt.pattern) + assert.Equal(t, tt.expected, result, + "pattern %q should match path %q: expected %v, got %v", + tt.pattern, tt.path, tt.expected, result) + }) + } +}