Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 74 additions & 3 deletions internal/runtime/node/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ package node
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"

Expand All @@ -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., "[email protected]" -> "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
Expand All @@ -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{}
Expand Down Expand Up @@ -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{}
Expand Down Expand Up @@ -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{}
Expand Down
251 changes: 251 additions & 0 deletions internal/runtime/node/npm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ package node

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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": "[email protected]"}`,
expectedManager: "npm",
},
"yarn when packageManager is yarn": {
packageJSON: `{"name": "test-project", "packageManager": "[email protected]"}`,
expectedManager: "yarn",
},
"pnpm when packageManager is pnpm": {
packageJSON: `{"name": "test-project", "packageManager": "[email protected]"}`,
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": "[email protected]"}`,
expectedCommand: "yarn install --verbose",
},
"uses pnpm when specified": {
packageJSON: `{"name": "test-project", "packageManager": "[email protected]"}`,
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": "[email protected]"}`,
expectedCommand: "yarn add --dev test-package",
},
"uses pnpm when specified": {
packageJSON: `{"name": "test-project", "packageManager": "[email protected]"}`,
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": "[email protected]"}`,
expectedCommand: "yarn list --pattern test-package --depth=0",
},
"uses pnpm when specified": {
packageJSON: `{"name": "test-project", "packageManager": "[email protected]"}`,
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("[email protected]"))
require.NoError(t, err)
}).
Return("", nil)

// Test
npm := NPMClient{}
_, _ = npm.ListPackage(ctx, "test-package", tmpDir, mockHookExecutor, ios)

// Assertions
require.Equal(t, tt.expectedCommand, actualCommand)
})
}
}
Loading