Skip to content
2 changes: 1 addition & 1 deletion agentuity.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,4 @@
"description": "The agents that are part of this project"
}
}
}
}
27 changes: 26 additions & 1 deletion cmd/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
110 changes: 91 additions & 19 deletions internal/bundler/bundler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand All @@ -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()
Expand Down
125 changes: 125 additions & 0 deletions internal/bundler/bundler_test.go
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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"},
)
}
2 changes: 1 addition & 1 deletion internal/dev/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
8 changes: 4 additions & 4 deletions internal/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading