diff --git a/internal/runtime/node/npm.go b/internal/runtime/node/npm.go index 11d6034f..bb1f5587 100644 --- a/internal/runtime/node/npm.go +++ b/internal/runtime/node/npm.go @@ -17,7 +17,10 @@ package node import ( "bytes" "context" + "encoding/json" "fmt" + "os" + "path/filepath" "regexp" "strings" @@ -37,6 +40,37 @@ type NPM interface { type NPMClient struct { } +// PackageJSON represents the structure of package.json +type PackageJSON struct { + PackageManager string `json:"packageManager"` +} + +// detectPackageManager reads package.json and returns the appropriate package manager command +func detectPackageManager(dirPath string) string { + packageJSONPath := filepath.Join(dirPath, "package.json") + + // Read package.json + data, err := os.ReadFile(packageJSONPath) + if err != nil { + return "npm" // Default to npm if can't read package.json + } + + var pkg PackageJSON + if err := json.Unmarshal(data, &pkg); err != nil { + return "npm" // Default to npm if can't parse JSON + } + + // Parse packageManager field (e.g., "pnpm@8.0.0" -> "pnpm") + if pkg.PackageManager != "" { + parts := strings.Split(pkg.PackageManager, "@") + if len(parts) > 0 { + return parts[0] + } + } + + return "npm" // Default to npm +} + // InstallAllPackages installs all packages by running a command similar to `npm install .` func (n *NPMClient) InstallAllPackages(ctx context.Context, dirPath string, hookExecutor hooks.HookExecutor, ios iostreams.IOStreamer) (string, error) { // Internal hook implementation with a preferred install command @@ -45,9 +79,22 @@ func (n *NPMClient) InstallAllPackages(ctx context.Context, dirPath string, hook // // TODO: The SDK should implement this hook instead of hardcoding the command // An internal hook is used for streaming install outputs to debug logs + + packageManager := detectPackageManager(dirPath) + var command string + + switch packageManager { + case "yarn": + command = "yarn install --verbose" + case "pnpm": + command = "pnpm install --reporter=default" + default: + command = "npm install --no-package-lock --no-audit --progress=false --loglevel=verbose ." + } + hookScript := hooks.HookScript{ Name: "InstallProjectDependencies", - Command: "npm install --no-package-lock --no-audit --progress=false --loglevel=verbose .", + Command: command, } stdout := bytes.Buffer{} @@ -76,9 +123,21 @@ func (n *NPMClient) InstallDevPackage(ctx context.Context, pkgName string, dirPa npmSpan, _ := opentracing.StartSpanFromContext(ctx, "npm.install.slack-cli-hooks") defer npmSpan.Finish() + packageManager := detectPackageManager(dirPath) + var command string + + switch packageManager { + case "yarn": + command = fmt.Sprintf("yarn add --dev %s", pkgName) + case "pnpm": + command = fmt.Sprintf("pnpm add --save-dev %s", pkgName) + default: + command = fmt.Sprintf("npm install --save-dev --no-audit --progress=false --loglevel=verbose %s", pkgName) + } + hookScript := hooks.HookScript{ Name: "InstallProjectDependencies", - Command: fmt.Sprintf("npm install --save-dev --no-audit --progress=false --loglevel=verbose %s", pkgName), + Command: command, } stdout := bytes.Buffer{} @@ -107,9 +166,21 @@ func (n *NPMClient) ListPackage(ctx context.Context, pkgName string, dirPath str npmSpan, _ := opentracing.StartSpanFromContext(ctx, "npm.list") defer npmSpan.Finish() + packageManager := detectPackageManager(dirPath) + var command string + + switch packageManager { + case "yarn": + command = fmt.Sprintf("yarn list --pattern %s --depth=0", pkgName) + case "pnpm": + command = fmt.Sprintf("pnpm list %s --depth 0", pkgName) + default: + command = fmt.Sprintf("npm list %s --depth 0", pkgName) + } + hookScript := hooks.HookScript{ Name: "InstallProjectDependencies", - Command: fmt.Sprintf("npm list %s --depth 0", pkgName), + Command: command, } stdout := bytes.Buffer{} diff --git a/internal/runtime/node/npm_test.go b/internal/runtime/node/npm_test.go index 2a2f040c..b03e478b 100644 --- a/internal/runtime/node/npm_test.go +++ b/internal/runtime/node/npm_test.go @@ -16,6 +16,8 @@ package node import ( "errors" + "os" + "path/filepath" "strings" "testing" @@ -232,3 +234,252 @@ func Test_NPMClient_ListPackage(t *testing.T) { }) } } + +func Test_detectPackageManager(t *testing.T) { + tests := map[string]struct { + packageJSON string + expectedManager string + }{ + "npm default when no packageManager field": { + packageJSON: `{"name": "test-project", "version": "1.0.0"}`, + expectedManager: "npm", + }, + "npm when packageManager is npm": { + packageJSON: `{"name": "test-project", "packageManager": "npm@9.0.0"}`, + expectedManager: "npm", + }, + "yarn when packageManager is yarn": { + packageJSON: `{"name": "test-project", "packageManager": "yarn@3.6.0"}`, + expectedManager: "yarn", + }, + "pnpm when packageManager is pnpm": { + packageJSON: `{"name": "test-project", "packageManager": "pnpm@8.6.0"}`, + expectedManager: "pnpm", + }, + "npm default when package.json doesn't exist": { + packageJSON: "", + expectedManager: "npm", + }, + "npm default when package.json is invalid JSON": { + packageJSON: `{"name": "test-project", "version":}`, + expectedManager: "npm", + }, + "packageManager without version": { + packageJSON: `{"name": "test-project", "packageManager": "yarn"}`, + expectedManager: "yarn", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Create temporary directory + tmpDir, err := os.MkdirTemp("", "test-detect-package-manager") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create package.json if content provided + if tt.packageJSON != "" { + packageJSONPath := filepath.Join(tmpDir, "package.json") + err := os.WriteFile(packageJSONPath, []byte(tt.packageJSON), 0644) + require.NoError(t, err) + } + + // Test + manager := detectPackageManager(tmpDir) + + // Assertions + require.Equal(t, tt.expectedManager, manager) + }) + } +} + +func Test_NPMClient_InstallAllPackages_PackageManagerDetection(t *testing.T) { + tests := map[string]struct { + packageJSON string + expectedCommand string + }{ + "uses npm by default": { + packageJSON: `{"name": "test-project"}`, + expectedCommand: "npm install --no-package-lock --no-audit --progress=false --loglevel=verbose .", + }, + "uses yarn when specified": { + packageJSON: `{"name": "test-project", "packageManager": "yarn@3.6.0"}`, + expectedCommand: "yarn install --verbose", + }, + "uses pnpm when specified": { + packageJSON: `{"name": "test-project", "packageManager": "pnpm@8.6.0"}`, + expectedCommand: "pnpm install --reporter=default", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Setup + ctx := slackcontext.MockContext(t.Context()) + + // Create temporary directory with package.json + tmpDir, err := os.MkdirTemp("", "test-package-manager") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + packageJSONPath := filepath.Join(tmpDir, "package.json") + err = os.WriteFile(packageJSONPath, []byte(tt.packageJSON), 0644) + require.NoError(t, err) + + fs := slackdeps.NewFsMock() + os := slackdeps.NewOsMock() + cfg := config.NewConfig(fs, os) + ios := iostreams.NewIOStreamsMock(cfg, fs, os) + ios.AddDefaultMocks() + + // Mock hook executor to capture the command + var actualCommand string + mockHookExecutor := &hooks.MockHookExecutor{} + mockHookExecutor.On("Execute", mock.Anything, mock.MatchedBy(func(opts hooks.HookExecOpts) bool { + actualCommand = opts.Hook.Command + return true + })). + Run(func(args mock.Arguments) { + opts := args.Get(1).(hooks.HookExecOpts) + _, err := opts.Stdout.Write([]byte("install successful")) + require.NoError(t, err) + }). + Return("", nil) + + // Test + npm := NPMClient{} + _, err = npm.InstallAllPackages(ctx, tmpDir, mockHookExecutor, ios) + + // Assertions + require.NoError(t, err) + require.Equal(t, tt.expectedCommand, actualCommand) + }) + } +} + +func Test_NPMClient_InstallDevPackage_PackageManagerDetection(t *testing.T) { + tests := map[string]struct { + packageJSON string + expectedCommand string + }{ + "uses npm by default": { + packageJSON: `{"name": "test-project"}`, + expectedCommand: "npm install --save-dev --no-audit --progress=false --loglevel=verbose test-package", + }, + "uses yarn when specified": { + packageJSON: `{"name": "test-project", "packageManager": "yarn@3.6.0"}`, + expectedCommand: "yarn add --dev test-package", + }, + "uses pnpm when specified": { + packageJSON: `{"name": "test-project", "packageManager": "pnpm@8.6.0"}`, + expectedCommand: "pnpm add --save-dev test-package", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Setup + ctx := slackcontext.MockContext(t.Context()) + + // Create temporary directory with package.json + tmpDir, err := os.MkdirTemp("", "test-package-manager") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + packageJSONPath := filepath.Join(tmpDir, "package.json") + err = os.WriteFile(packageJSONPath, []byte(tt.packageJSON), 0644) + require.NoError(t, err) + + fs := slackdeps.NewFsMock() + os := slackdeps.NewOsMock() + cfg := config.NewConfig(fs, os) + ios := iostreams.NewIOStreamsMock(cfg, fs, os) + ios.AddDefaultMocks() + + // Mock hook executor to capture the command + var actualCommand string + mockHookExecutor := &hooks.MockHookExecutor{} + mockHookExecutor.On("Execute", mock.Anything, mock.MatchedBy(func(opts hooks.HookExecOpts) bool { + actualCommand = opts.Hook.Command + return true + })). + Run(func(args mock.Arguments) { + opts := args.Get(1).(hooks.HookExecOpts) + _, err := opts.Stdout.Write([]byte("install successful")) + require.NoError(t, err) + }). + Return("", nil) + + // Test + npm := NPMClient{} + _, err = npm.InstallDevPackage(ctx, "test-package", tmpDir, mockHookExecutor, ios) + + // Assertions + require.NoError(t, err) + require.Equal(t, tt.expectedCommand, actualCommand) + }) + } +} + +func Test_NPMClient_ListPackage_PackageManagerDetection(t *testing.T) { + tests := map[string]struct { + packageJSON string + expectedCommand string + }{ + "uses npm by default": { + packageJSON: `{"name": "test-project"}`, + expectedCommand: "npm list test-package --depth 0", + }, + "uses yarn when specified": { + packageJSON: `{"name": "test-project", "packageManager": "yarn@3.6.0"}`, + expectedCommand: "yarn list --pattern test-package --depth=0", + }, + "uses pnpm when specified": { + packageJSON: `{"name": "test-project", "packageManager": "pnpm@8.6.0"}`, + expectedCommand: "pnpm list test-package --depth 0", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Setup + ctx := slackcontext.MockContext(t.Context()) + + // Create temporary directory with package.json + tmpDir, err := os.MkdirTemp("", "test-package-manager") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + packageJSONPath := filepath.Join(tmpDir, "package.json") + err = os.WriteFile(packageJSONPath, []byte(tt.packageJSON), 0644) + require.NoError(t, err) + + fs := slackdeps.NewFsMock() + os := slackdeps.NewOsMock() + cfg := config.NewConfig(fs, os) + ios := iostreams.NewIOStreamsMock(cfg, fs, os) + ios.AddDefaultMocks() + + // Mock hook executor to capture the command + var actualCommand string + mockHookExecutor := &hooks.MockHookExecutor{} + mockHookExecutor.On("Execute", mock.Anything, mock.MatchedBy(func(opts hooks.HookExecOpts) bool { + actualCommand = opts.Hook.Command + return true + })). + Run(func(args mock.Arguments) { + opts := args.Get(1).(hooks.HookExecOpts) + _, err := opts.Stdout.Write([]byte("test-package@1.0.0")) + require.NoError(t, err) + }). + Return("", nil) + + // Test + npm := NPMClient{} + _, _ = npm.ListPackage(ctx, "test-package", tmpDir, mockHookExecutor, ios) + + // Assertions + require.Equal(t, tt.expectedCommand, actualCommand) + }) + } +}