Skip to content

Commit bdbb25e

Browse files
authored
Add packageManager field support for Node.js package manager detection (#34)
1 parent 1c76011 commit bdbb25e

File tree

3 files changed

+217
-9
lines changed

3 files changed

+217
-9
lines changed

cli/docs/commands/deps.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ The `deps` command automatically detects project types and installs all dependen
77
## Purpose
88

99
- **Auto-Detection**: Automatically identify project types in your workspace
10-
- **Package Manager Selection**: Choose the correct package manager based on lock files
10+
- **Package Manager Selection**: Choose the correct package manager based on package.json packageManager field or lock files
1111
- **Multi-Project Support**: Handle multiple projects with different languages
1212
- **Dependency Installation**: Install all required dependencies
1313
- **Virtual Environment Setup**: Create Python virtual environments automatically
@@ -98,10 +98,11 @@ This command has no additional flags. It automatically detects and installs depe
9898
9999
┌─────────────────────────────────────────────────────────────┐
100100
│ Detection Priority: │
101-
│ 1. pnpm-lock.yaml → pnpm │
102-
│ 2. yarn.lock → yarn │
103-
│ 3. package-lock.json → npm │
104-
│ 4. package.json → npm (default) │
101+
│ 1. packageManager field in package.json (e.g., "pnpm@8.15") │
102+
│ 2. pnpm-lock.yaml → pnpm │
103+
│ 3. yarn.lock → yarn │
104+
│ 4. package-lock.json → npm │
105+
│ 5. package.json → npm (default) │
105106
└─────────────────────────────────────────────────────────────┘
106107
107108
┌───────┴────────┐
@@ -657,12 +658,16 @@ npm install -g pnpm
657658

658659
### Issue: Wrong package manager detected
659660

660-
**Cause**: Multiple lock files exist
661+
**Cause**: Multiple lock files exist or packageManager field in package.json is incorrect
661662

662663
**Solution**:
663664
```bash
664-
# Clean up conflicting lock files
665+
# Option 1: Set explicit packageManager in package.json (recommended)
665666
cd src/web
667+
# Edit package.json and add:
668+
# "packageManager": "pnpm@8.15.0"
669+
670+
# Option 2: Clean up conflicting lock files
666671
rm package-lock.json # If using pnpm
667672
# Keep only one lock file type
668673
```

cli/src/internal/detector/detector.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,22 +169,28 @@ func FindNodeProjects(rootDir string) ([]types.NodeProject, error) {
169169
}
170170

171171
// DetectNodePackageManager determines whether to use pnpm, yarn, or npm.
172-
// Priority: pnpm-lock.yaml or pnpm-workspace.yaml > yarn.lock > package-lock.json > npm (default).
172+
// Priority: packageManager field in package.json > lock files > npm (default).
173173
func DetectNodePackageManager(projectDir string) string {
174174
// Use unbounded search (for backward compatibility with tests)
175175
return DetectNodePackageManagerWithBoundary(projectDir, "")
176176
}
177177

178178
// DetectNodePackageManagerWithBoundary determines package manager by checking only the project directory.
179179
// Does not search up the directory tree to avoid interference from parent workspace configurations.
180+
// Priority: packageManager field in package.json > lock files > npm (default).
180181
func DetectNodePackageManagerWithBoundary(projectDir string, boundaryDir string) string {
181182
// Clean the paths to absolute
182183
absDir, err := filepath.Abs(projectDir)
183184
if err != nil {
184185
absDir = projectDir
185186
}
186187

187-
// Check ONLY the project directory itself for lock files
188+
// First, check for packageManager field in package.json (highest priority)
189+
if pkgMgr := getPackageManagerFromPackageJson(absDir); pkgMgr != "" {
190+
return pkgMgr
191+
}
192+
193+
// Fall back to lock file detection
188194
// Priority: pnpm-lock.yaml > yarn.lock > package-lock.json > npm (default)
189195
if _, err := os.Stat(filepath.Join(absDir, "pnpm-lock.yaml")); err == nil {
190196
return "pnpm"
@@ -200,6 +206,54 @@ func DetectNodePackageManagerWithBoundary(projectDir string, boundaryDir string)
200206
return "npm"
201207
}
202208

209+
// getPackageManagerFromPackageJson reads package.json and extracts the packageManager field.
210+
// The packageManager field format is: "name@version" (e.g., "pnpm@8.15.0", "yarn@4.1.0", "npm@10.5.0")
211+
// Returns the package manager name (without version) if found, empty string otherwise.
212+
func getPackageManagerFromPackageJson(projectDir string) string {
213+
packageJsonPath := filepath.Join(projectDir, "package.json")
214+
215+
// Validate path before reading
216+
if err := security.ValidatePath(packageJsonPath); err != nil {
217+
return ""
218+
}
219+
220+
// #nosec G304 -- Path validated by security.ValidatePath
221+
data, err := os.ReadFile(packageJsonPath)
222+
if err != nil {
223+
return ""
224+
}
225+
226+
var pkg struct {
227+
PackageManager string `json:"packageManager"`
228+
}
229+
230+
if err := json.Unmarshal(data, &pkg); err != nil {
231+
return ""
232+
}
233+
234+
// The packageManager field is in the format "name@version"
235+
// We need to extract just the name part
236+
if pkg.PackageManager == "" {
237+
return ""
238+
}
239+
240+
// Split by '@' to extract the package manager name from "name@version" format
241+
// (e.g., "npm@8.19.2" -> "npm")
242+
parts := strings.Split(pkg.PackageManager, "@")
243+
244+
// The package manager name is the first part
245+
pkgMgrName := parts[0]
246+
247+
// Validate it's a supported package manager
248+
switch pkgMgrName {
249+
case "npm", "yarn", "pnpm":
250+
return pkgMgrName
251+
default:
252+
// Unsupported package manager, fall back to lock file detection
253+
return ""
254+
}
255+
}
256+
203257
// FindDotnetProjects searches for .csproj and .sln files.
204258
// Only searches within rootDir and does not traverse outside it.
205259
func FindDotnetProjects(rootDir string) ([]types.DotnetProject, error) {

cli/src/internal/detector/detector_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,152 @@ func TestFindAppHost(t *testing.T) {
392392
t.Error("FindAppHost() ProjectFile is empty, expected .csproj path")
393393
}
394394
}
395+
396+
func TestGetPackageManagerFromPackageJson(t *testing.T) {
397+
tests := []struct {
398+
name string
399+
content string
400+
expected string
401+
}{
402+
{
403+
name: "packageManager field with npm",
404+
content: `{"name": "test", "packageManager": "npm@10.5.0"}`,
405+
expected: "npm",
406+
},
407+
{
408+
name: "packageManager field with yarn",
409+
content: `{"name": "test", "packageManager": "yarn@4.1.0"}`,
410+
expected: "yarn",
411+
},
412+
{
413+
name: "packageManager field with pnpm",
414+
content: `{"name": "test", "packageManager": "pnpm@8.15.0"}`,
415+
expected: "pnpm",
416+
},
417+
{
418+
name: "no packageManager field",
419+
content: `{"name": "test", "version": "1.0.0"}`,
420+
expected: "",
421+
},
422+
{
423+
name: "empty packageManager field",
424+
content: `{"name": "test", "packageManager": ""}`,
425+
expected: "",
426+
},
427+
{
428+
name: "unsupported package manager",
429+
content: `{"name": "test", "packageManager": "bun@1.0.0"}`,
430+
expected: "",
431+
},
432+
{
433+
name: "invalid JSON",
434+
content: `{invalid json}`,
435+
expected: "",
436+
},
437+
{
438+
name: "packageManager without version",
439+
content: `{"name": "test", "packageManager": "pnpm"}`,
440+
expected: "pnpm",
441+
},
442+
}
443+
444+
for _, tt := range tests {
445+
t.Run(tt.name, func(t *testing.T) {
446+
// Create temporary directory
447+
tmpDir, err := os.MkdirTemp("", "detector-test-*")
448+
if err != nil {
449+
t.Fatalf("failed to create temp dir: %v", err)
450+
}
451+
defer func() { _ = os.RemoveAll(tmpDir) }()
452+
453+
// Create package.json
454+
packageJsonPath := filepath.Join(tmpDir, "package.json")
455+
if err := os.WriteFile(packageJsonPath, []byte(tt.content), 0600); err != nil {
456+
t.Fatalf("failed to create package.json: %v", err)
457+
}
458+
459+
// Test detection
460+
result := getPackageManagerFromPackageJson(tmpDir)
461+
if result != tt.expected {
462+
t.Errorf("getPackageManagerFromPackageJson() = %q, want %q", result, tt.expected)
463+
}
464+
})
465+
}
466+
}
467+
468+
func TestDetectNodePackageManagerWithPackageManagerField(t *testing.T) {
469+
tests := []struct {
470+
name string
471+
packageJson string
472+
lockFiles []string
473+
expected string
474+
}{
475+
{
476+
name: "packageManager field takes priority over lock files",
477+
packageJson: `{"name": "test", "packageManager": "yarn@4.1.0"}`,
478+
lockFiles: []string{"pnpm-lock.yaml", "package-lock.json"},
479+
expected: "yarn",
480+
},
481+
{
482+
name: "fallback to pnpm lock file when no packageManager field",
483+
packageJson: `{"name": "test"}`,
484+
lockFiles: []string{"pnpm-lock.yaml"},
485+
expected: "pnpm",
486+
},
487+
{
488+
name: "fallback to yarn lock file when no packageManager field",
489+
packageJson: `{"name": "test"}`,
490+
lockFiles: []string{"yarn.lock"},
491+
expected: "yarn",
492+
},
493+
{
494+
name: "fallback to npm lock file when no packageManager field",
495+
packageJson: `{"name": "test"}`,
496+
lockFiles: []string{"package-lock.json"},
497+
expected: "npm",
498+
},
499+
{
500+
name: "default to npm when no packageManager field and no lock files",
501+
packageJson: `{"name": "test"}`,
502+
lockFiles: []string{},
503+
expected: "npm",
504+
},
505+
{
506+
name: "packageManager field with pnpm overrides yarn lock",
507+
packageJson: `{"name": "test", "packageManager": "pnpm@8.15.0"}`,
508+
lockFiles: []string{"yarn.lock"},
509+
expected: "pnpm",
510+
},
511+
}
512+
513+
for _, tt := range tests {
514+
t.Run(tt.name, func(t *testing.T) {
515+
// Create temporary directory
516+
tmpDir, err := os.MkdirTemp("", "detector-test-*")
517+
if err != nil {
518+
t.Fatalf("failed to create temp dir: %v", err)
519+
}
520+
defer func() { _ = os.RemoveAll(tmpDir) }()
521+
522+
// Create package.json
523+
packageJsonPath := filepath.Join(tmpDir, "package.json")
524+
if err := os.WriteFile(packageJsonPath, []byte(tt.packageJson), 0600); err != nil {
525+
t.Fatalf("failed to create package.json: %v", err)
526+
}
527+
528+
// Create lock files
529+
for _, lockFile := range tt.lockFiles {
530+
lockPath := filepath.Join(tmpDir, lockFile)
531+
if err := os.WriteFile(lockPath, []byte(""), 0600); err != nil {
532+
t.Fatalf("failed to create lock file %s: %v", lockFile, err)
533+
}
534+
}
535+
536+
// Test detection
537+
result := DetectNodePackageManagerWithBoundary(tmpDir, "")
538+
if result != tt.expected {
539+
t.Errorf("DetectNodePackageManagerWithBoundary() = %q, want %q", result, tt.expected)
540+
}
541+
})
542+
}
543+
}

0 commit comments

Comments
 (0)