diff --git a/agentuity.schema.json b/agentuity.schema.json index b1644407..c2617378 100644 --- a/agentuity.schema.json +++ b/agentuity.schema.json @@ -220,4 +220,4 @@ "description": "The agents that are part of this project" } } -} \ No newline at end of file +} diff --git a/cmd/project.go b/cmd/project.go index b97732ac..995c6819 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -38,6 +38,31 @@ Use the subcommands to manage your projects.`, }, } +// detectRuntime auto-detects the runtime based on lockfiles in the directory +// If no lockfile is found, falls back to the default runtime from the template +func detectRuntime(dir string, defaultRuntime string) string { + // Only auto-detect for JavaScript-based runtimes + if defaultRuntime != "nodejs" && defaultRuntime != "bunjs" { + return defaultRuntime + } + + // Check for bun lockfiles - use bunjs runtime + if util.Exists(filepath.Join(dir, "bun.lockb")) || + util.Exists(filepath.Join(dir, "bun.lock")) { + return "bunjs" + } + + // Check for nodejs runtime + if util.Exists(filepath.Join(dir, "pnpm-lock.yaml")) || + util.Exists(filepath.Join(dir, "package-lock.json")) || + util.Exists(filepath.Join(dir, "yarn.lock")) { + return "nodejs" + } + + // No lockfile found, use the template default + return defaultRuntime +} + func saveEnv(dir string, apikey string, projectKey string) { filename := filepath.Join(dir, ".env") envLines, err := env.ParseEnvFile(filename) @@ -140,7 +165,7 @@ func initProject(ctx context.Context, logger logger.Logger, args InitProjectArgs Identifier: args.Provider.Identifier, Language: args.Provider.Language, Framework: args.Provider.Framework, - Runtime: args.Provider.Runtime, + Runtime: detectRuntime(args.Dir, args.Provider.Runtime), Ignore: args.Provider.Bundle.Ignore, AgentConfig: project.AgentBundlerConfig{ Dir: args.Provider.SrcDir, diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 15328680..9fe579b3 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -141,6 +141,38 @@ func runTypecheck(ctx BundleContext, dir string) error { return nil } +// detectPackageManager detects which package manager to use based on lockfiles +func detectPackageManager(projectDir string) string { + if util.Exists(filepath.Join(projectDir, "pnpm-lock.yaml")) { + return "pnpm" + } else if util.Exists(filepath.Join(projectDir, "bun.lockb")) || util.Exists(filepath.Join(projectDir, "bun.lock")) { + return "bun" + } else if util.Exists(filepath.Join(projectDir, "yarn.lock")) { + return "yarn" + } else { + return "npm" + } +} + +// 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) { + packageManager := detectPackageManager(projectDir) + + switch packageManager { + case "pnpm": + return "pnpm", []string{"install", "--prod", "--ignore-scripts", "--silent"}, nil + case "bun": + return "bun", []string{"install", "--production", "--ignore-scripts", "--no-progress", "--no-summary", "--silent"}, nil + case "yarn": + return "yarn", []string{"install", "--frozen-lockfile"}, nil + case "npm": + return "npm", []string{"install", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"}, nil + default: + return "npm", []string{"install", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"}, nil + } +} + func generateBunLockfile(ctx BundleContext, logger logger.Logger, dir string) error { args := []string{"install", "--lockfile-only"} install := exec.CommandContext(ctx.Context, "bun", args...) @@ -153,29 +185,69 @@ func generateBunLockfile(ctx BundleContext, logger logger.Logger, dir string) er return nil } -func bundleJavascript(ctx BundleContext, dir string, outdir string, theproject *project.Project) error { +// applyCIModifications applies CI-specific modifications to install command arguments +func applyCIModifications(ctx BundleContext, cmd, runtime string, args []string) []string { + if !ctx.CI { + return args + } - if ctx.Install || !util.Exists(filepath.Join(dir, "node_modules")) { - var install *exec.Cmd - switch theproject.Bundler.Runtime { - case "nodejs": - install = exec.CommandContext(ctx.Context, "npm", "install", "--no-audit", "--no-fund", "--include=prod", "--ignore-scripts") - case "bunjs": - // for bun, we need to ensure the lockfile is up to date before we can run the install below - // otherwise we'll get an error about the lockfile being out of date - if err := generateBunLockfile(ctx, ctx.Logger, dir); err != nil { - return err + if cmd == "bun" { + // Drop quiet flags for CI using a filtered copy + filtered := make([]string, 0, len(args)) + for _, arg := range args { + if arg == "--no-progress" || arg == "--no-summary" || arg == "--silent" { + continue } - args := []string{"install", "--production", "--ignore-scripts"} - if ctx.CI { - args = append(args, "--verbose", "--no-cache") - } else { - args = append(args, "--no-progress", "--no-summary", "--silent") + filtered = append(filtered, arg) + } + return filtered + } else if cmd == "pnpm" { + // Remove silent flag and add CI-specific flags + filtered := make([]string, 0, len(args)+2) + for _, arg := range args { + if arg == "--silent" { + continue } - install = exec.CommandContext(ctx.Context, "bun", args...) - default: - return fmt.Errorf("unsupported runtime: %s", theproject.Bundler.Runtime) + filtered = append(filtered, arg) } + filtered = append(filtered, "--reporter=append-only", "--frozen-lockfile") + return filtered + } + + return args +} + +// getJSInstallCommand returns the complete install command with CI modifications applied +func getJSInstallCommand(ctx BundleContext, projectDir, runtime string) (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) + if runtime == "bunjs" && ctx.Logger != nil { + if err := generateBunLockfile(ctx, ctx.Logger, projectDir); err != nil { + return "", nil, err + } + } + + cmd, args, err := jsInstallCommandSpec(projectDir) + if err != nil { + return "", nil, err + } + + // Apply CI-specific modifications + args = applyCIModifications(ctx, cmd, runtime, args) + + return cmd, args, nil +} + +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) + if err != nil { + return err + } + + install := exec.CommandContext(ctx.Context, cmd, args...) util.ProcessSetup(install) install.Dir = dir out, err := install.CombinedOutput() diff --git a/internal/bundler/bundler_test.go b/internal/bundler/bundler_test.go index ae9c4f13..b2e0a546 100644 --- a/internal/bundler/bundler_test.go +++ b/internal/bundler/bundler_test.go @@ -1,9 +1,13 @@ package bundler import ( + "context" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPyProject(t *testing.T) { @@ -32,3 +36,124 @@ func TestPyProject(t *testing.T) { assert.Equal(t, "1.0.0-alpha", pyProjectVersionRegex.FindStringSubmatch(`version = "1.0.0-alpha"`)[1]) assert.Equal(t, "1.0.0-beta", pyProjectVersionRegex.FindStringSubmatch(`version = "1.0.0-beta"`)[1]) } + +// Helper function to test package manager detection logic +func testPackageManagerCommand(t *testing.T, tempDir string, runtime string, isCI 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) + require.NoError(t, err) + + assert.Equal(t, expectedCmd, actualCmd) + assert.Equal(t, expectedArgs, actualArgs) +} + +func TestJavaScriptPackageManagerDetection(t *testing.T) { + tests := []struct { + name string + runtime string + lockFiles []string + expectedCmd string + expectedArgs []string + }{ + { + name: "nodejs with yarn.lock should use yarn", + runtime: "nodejs", + lockFiles: []string{"yarn.lock"}, + expectedCmd: "yarn", + expectedArgs: []string{"install", "--frozen-lockfile"}, + }, + { + name: "nodejs without yarn.lock should use npm", + runtime: "nodejs", + lockFiles: []string{}, + expectedCmd: "npm", + expectedArgs: []string{"install", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"}, + }, + { + name: "nodejs with package-lock.json should use npm", + runtime: "nodejs", + lockFiles: []string{"package-lock.json"}, + expectedCmd: "npm", + expectedArgs: []string{"install", "--no-audit", "--no-fund", "--omit=dev", "--ignore-scripts"}, + }, + { + name: "nodejs with both yarn.lock and package-lock.json should prefer yarn", + runtime: "nodejs", + lockFiles: []string{"yarn.lock", "package-lock.json"}, + expectedCmd: "yarn", + expectedArgs: []string{"install", "--frozen-lockfile"}, + }, + { + name: "pnpm runtime should use pnpm", + runtime: "pnpm", + lockFiles: []string{"pnpm-lock.yaml"}, + expectedCmd: "pnpm", + expectedArgs: []string{"install", "--prod", "--ignore-scripts", "--silent"}, + }, + { + name: "bunjs runtime should use bun with bun.lockb", + runtime: "bunjs", + lockFiles: []string{"bun.lockb", "package.json"}, + expectedCmd: "bun", + expectedArgs: []string{"install", "--production", "--ignore-scripts", "--no-progress", "--no-summary", "--silent"}, + }, + { + name: "bunjs runtime should use bun with bun.lock", + runtime: "bunjs", + lockFiles: []string{"bun.lock", "package.json"}, + expectedCmd: "bun", + expectedArgs: []string{"install", "--production", "--ignore-scripts", "--no-progress", "--no-summary", "--silent"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary directory + tempDir := t.TempDir() + + // Create lock files and package.json + for _, lockFile := range tt.lockFiles { + filePath := filepath.Join(tempDir, lockFile) + var content []byte + if lockFile == "package.json" { + content = []byte(`{"name": "test-package", "version": "1.0.0"}`) + } else { + content = []byte("") + } + err := os.WriteFile(filePath, content, 0644) + require.NoError(t, err) + } + + // Test the logic with CI=false + testPackageManagerCommand(t, tempDir, tt.runtime, false, tt.expectedCmd, tt.expectedArgs) + }) + } +} + +func TestPnpmCIFlags(t *testing.T) { + tempDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tempDir, "pnpm-lock.yaml"), []byte(""), 0644)) + // CI=true + testPackageManagerCommand( + t, + tempDir, + "pnpm", + true, + "pnpm", + []string{"install", "--prod", "--ignore-scripts", "--reporter=append-only", "--frozen-lockfile"}, + ) + // CI=false + testPackageManagerCommand( + t, + tempDir, + "pnpm", + false, + "pnpm", + []string{"install", "--prod", "--ignore-scripts", "--silent"}, + ) +} diff --git a/internal/dev/dev.go b/internal/dev/dev.go index 335c1f2e..d7d999f0 100644 --- a/internal/dev/dev.go +++ b/internal/dev/dev.go @@ -125,7 +125,7 @@ func CreateRunProjectCmd(ctx context.Context, log logger.Logger, theproject proj projectServerCmd.Env = append(projectServerCmd.Env, "NODE_ENV=development") } - // for nodejs, we need to enable source maps directly in the environment. + // for nodejs and pnpm, we need to enable source maps directly in the environment. // for bun, we need to inject a shim helper to parse the source maps if theproject.Project.Bundler.Runtime == "nodejs" { nodeOptions := os.Getenv("NODE_OPTIONS") diff --git a/internal/mcp/project.go b/internal/mcp/project.go index 93e71724..2a9459aa 100644 --- a/internal/mcp/project.go +++ b/internal/mcp/project.go @@ -23,7 +23,7 @@ type CreateProjectArguments struct { } type ListTemplatesArguments struct { - Provider string `json:"provider" jsonschema:"required,description=The provider to use for the project which can be either 'bunjs' for BunJS, 'nodejs' for NodeJS or 'python-uv' for Python with UV"` + Provider string `json:"provider" jsonschema:"required,description=The provider to use for the project which can be either 'bunjs' for BunJS, 'nodejs' for NodeJS, or 'uv' for Python with UV"` } func init() { diff --git a/internal/project/project.go b/internal/project/project.go index 94752b2f..37740d49 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -193,12 +193,12 @@ func (p *Project) Load(dir string) error { } switch p.Bundler.Language { case "js", "javascript", "typescript": - if p.Bundler.Runtime != "bunjs" && p.Bundler.Runtime != "nodejs" && p.Bundler.Runtime != "deno" { - return fmt.Errorf("invalid bundler.runtime value: %s. only bunjs, nodejs, and deno are supported", p.Bundler.Runtime) + if p.Bundler.Runtime != "bunjs" && p.Bundler.Runtime != "nodejs" { + return fmt.Errorf("invalid bundler.runtime value: %s. only bunjs and nodejs are supported", p.Bundler.Runtime) } case "py", "python": - if p.Bundler.Runtime != "uv" && p.Bundler.Runtime != "python" && p.Bundler.Runtime != "" { - return fmt.Errorf("invalid bundler.runtime value: %s. only uv or python is supported", p.Bundler.Runtime) + if p.Bundler.Runtime != "uv" { + return fmt.Errorf("invalid bundler.runtime value: %s. only uv is supported", p.Bundler.Runtime) } default: return fmt.Errorf("invalid bundler.language value: %s. only js or py are supported", p.Bundler.Language)