diff --git a/build/docker/alpine.Dockerfile b/build/docker/alpine.Dockerfile index c444dcd7..cb92158b 100644 --- a/build/docker/alpine.Dockerfile +++ b/build/docker/alpine.Dockerfile @@ -72,7 +72,9 @@ RUN apk --no-cache --update add dotnet8-sdk --repository=https://dl-cdn.alpineli RUN dotnet --version && npm -v && yarn -v && go version -RUN npm install --global bower && bower -v +# Install pnpm and bower for JavaScript resolution (npm/yarn/pnpm/bower) +RUN npm install --global pnpm && pnpm -v && \ + npm install --global bower && bower -v RUN apk add --no-cache \ git \ diff --git a/build/docker/debian.Dockerfile b/build/docker/debian.Dockerfile index ed85254b..38eaa62c 100644 --- a/build/docker/debian.Dockerfile +++ b/build/docker/debian.Dockerfile @@ -77,9 +77,10 @@ RUN apt -y update && apt -y upgrade && apt -y install nodejs && \ apt -y clean && rm -rf /var/lib/apt/lists/* RUN npm install --global npm@latest && \ npm install --global yarn && \ + npm install --global pnpm && \ npm install --global bower -RUN npm -v && yarn -v && bower -v +RUN npm -v && yarn -v && pnpm -v && bower -v # https://learn.microsoft.com/en-us/dotnet/core/install/linux-scripted-manual#scripted-install # https://learn.microsoft.com/en-us/dotnet/core/install/linux-debian diff --git a/internal/resolution/file/file_batch_factory.go b/internal/resolution/file/file_batch_factory.go index c88399c1..c15ad5f3 100644 --- a/internal/resolution/file/file_batch_factory.go +++ b/internal/resolution/file/file_batch_factory.go @@ -1,6 +1,8 @@ package file import ( + "encoding/json" + "fmt" "os" "path/filepath" "regexp" @@ -8,9 +10,11 @@ import ( "github.com/debricked/cli/internal/resolution/pm" "github.com/debricked/cli/internal/resolution/pm/npm" + "github.com/debricked/cli/internal/resolution/pm/pnpm" "github.com/debricked/cli/internal/resolution/pm/poetry" "github.com/debricked/cli/internal/resolution/pm/uv" "github.com/debricked/cli/internal/resolution/pm/yarn" + "github.com/fatih/color" ) type IBatchFactory interface { @@ -19,8 +23,9 @@ type IBatchFactory interface { } type BatchFactory struct { - pms []pm.IPm - npmPreferred bool + pms []pm.IPm + npmPreferred bool + warnedYarnDefaultPM bool } func NewBatchFactory() *BatchFactory { @@ -51,10 +56,6 @@ func (bf *BatchFactory) Make(files []string) []IBatch { func (bf *BatchFactory) processFile(file string, batchMap map[string]IBatch) { base := filepath.Base(file) for _, p := range bf.pms { - if bf.skipPackageManager(p) { - continue - } - for _, manifest := range p.Manifests() { if bf.shouldProcessManifest(manifest, base, file, p) { compiledRegex, _ := regexp.Compile(manifest) @@ -67,15 +68,51 @@ func (bf *BatchFactory) processFile(file string, batchMap map[string]IBatch) { } func (bf *BatchFactory) shouldProcessManifest(manifest, base, file string, p pm.IPm) bool { - if manifest == "pyproject.toml" && strings.EqualFold(base, "pyproject.toml") { - pmName := detectPyprojectPm(file) + if isNodePackageJSON(manifest, base) { + return bf.shouldProcessNodeManifest(file, p) + } - return pmName == p.Name() + if isPyprojectToml(manifest, base) { + return shouldProcessPyprojectManifest(file, p) } return true } +func isNodePackageJSON(manifest, base string) bool { + return manifest == `package\.json$` && strings.EqualFold(base, "package.json") +} + +func (bf *BatchFactory) shouldProcessNodeManifest(file string, p pm.IPm) bool { + pmName := detectNodePm(file) + if pmName != "" { + // If we can detect the PM from lockfiles or package.json, use that + return pmName == p.Name() + } + + // No explicit packageManager found: fall back to npmPreferred flag between npm and yarn + switch { + case p.Name() == npm.Name && bf.npmPreferred: + return true + case p.Name() == yarn.Name && !bf.npmPreferred: + bf.warnYarnDefault() + + return true + default: + return false + } +} + +func isPyprojectToml(manifest, base string) bool { + return manifest == "pyproject.toml" && strings.EqualFold(base, "pyproject.toml") +} + +func shouldProcessPyprojectManifest(file string, p pm.IPm) bool { + pmName := detectPyprojectPm(file) + + return pmName == p.Name() +} + func (bf *BatchFactory) addToBatch(p pm.IPm, file string, batchMap map[string]IBatch) { batch, ok := batchMap[p.Name()] if !ok { @@ -85,17 +122,49 @@ func (bf *BatchFactory) addToBatch(p pm.IPm, file string, batchMap map[string]IB batch.Add(file) } -func (bf *BatchFactory) skipPackageManager(p pm.IPm) bool { - name := p.Name() +func (bf *BatchFactory) warnYarnDefault() { + if bf.warnedYarnDefaultPM { - switch true { - case name == npm.Name && !bf.npmPreferred: - return true - case name == yarn.Name && bf.npmPreferred: - return true + return + } + + fmt.Printf("%s Unable to detect package manager through package.json file, defaulting to yarn.\n", color.YellowString("⚠️")) + bf.warnedYarnDefaultPM = true +} + +func detectNodePm(packageJSONPath string) string { + return detectNodePmFromPackageJSON(packageJSONPath) +} + +func detectNodePmFromPackageJSON(packageJSONPath string) string { + // Prefer explicit packageManager field if present + content, err := os.ReadFile(packageJSONPath) + if err != nil { + return "" } - return false + var pkg struct { + PackageManager string `json:"packageManager"` + } + if jsonErr := json.Unmarshal(content, &pkg); jsonErr != nil || pkg.PackageManager == "" { + return "" + } + + name := pkg.PackageManager + if at := strings.Index(name, "@"); at > 0 { + name = name[:at] + } + + switch name { + case pnpm.Name: + return pnpm.Name + case yarn.Name: + return yarn.Name + case npm.Name: + return npm.Name + default: + return "" + } } func detectPyprojectPm(pyprojectPath string) string { diff --git a/internal/resolution/pm/pm.go b/internal/resolution/pm/pm.go index 39ad5c89..3cbe2170 100644 --- a/internal/resolution/pm/pm.go +++ b/internal/resolution/pm/pm.go @@ -9,6 +9,7 @@ import ( "github.com/debricked/cli/internal/resolution/pm/npm" "github.com/debricked/cli/internal/resolution/pm/nuget" "github.com/debricked/cli/internal/resolution/pm/pip" + "github.com/debricked/cli/internal/resolution/pm/pnpm" "github.com/debricked/cli/internal/resolution/pm/poetry" "github.com/debricked/cli/internal/resolution/pm/sbt" "github.com/debricked/cli/internal/resolution/pm/uv" @@ -29,6 +30,7 @@ func Pms() []IPm { poetry.NewPm(), uv.NewPm(), yarn.NewPm(), + pnpm.NewPm(), npm.NewPm(), bower.NewPm(), nuget.NewPm(), diff --git a/internal/resolution/pm/pnpm/cmd_factory.go b/internal/resolution/pm/pnpm/cmd_factory.go new file mode 100644 index 00000000..44418f0b --- /dev/null +++ b/internal/resolution/pm/pnpm/cmd_factory.go @@ -0,0 +1,40 @@ +package pnpm + +import ( + "os/exec" + "path/filepath" +) + +type ICmdFactory interface { + MakeInstallCmd(command string, file string) (*exec.Cmd, error) +} + +type IExecPath interface { + LookPath(file string) (string, error) +} + +type ExecPath struct{} + +func (ExecPath) LookPath(file string) (string, error) { + return exec.LookPath(file) +} + +type CmdFactory struct { + execPath IExecPath +} + +func (cmdf CmdFactory) MakeInstallCmd(command string, file string) (*exec.Cmd, error) { + path, err := cmdf.execPath.LookPath(command) + + fileDir := filepath.Dir(file) + + return &exec.Cmd{ + Path: path, + Args: []string{ + command, + "install", + "--ignore-scripts", // Avoid risky scripts + }, + Dir: fileDir, + }, err +} diff --git a/internal/resolution/pm/pnpm/cmd_factory_test.go b/internal/resolution/pm/pnpm/cmd_factory_test.go new file mode 100644 index 00000000..74d3cc8d --- /dev/null +++ b/internal/resolution/pm/pnpm/cmd_factory_test.go @@ -0,0 +1,20 @@ +package pnpm + +import ( + "testing" + + "github.com/debricked/cli/internal/resolution/pm/pnpm/testdata" + "github.com/stretchr/testify/assert" +) + +func TestMakeInstallCmd(t *testing.T) { + pnpmCommand := "pnpm" + cmd, err := CmdFactory{ + execPath: testdata.ExecPathMock{}, + }.MakeInstallCmd(pnpmCommand, "file") + assert.NoError(t, err) + assert.NotNil(t, cmd) + args := cmd.Args + assert.Contains(t, args, "pnpm") + assert.Contains(t, args, "install") +} diff --git a/internal/resolution/pm/pnpm/job.go b/internal/resolution/pm/pnpm/job.go new file mode 100644 index 00000000..5ff7af1f --- /dev/null +++ b/internal/resolution/pm/pnpm/job.go @@ -0,0 +1,101 @@ +package pnpm + +import ( + "regexp" + "strings" + + "github.com/debricked/cli/internal/resolution/job" + "github.com/debricked/cli/internal/resolution/pm/util" +) + +const ( + pnpm = "pnpm" + executableNotFoundErrRegex = `executable file not found` +) + +type Job struct { + job.BaseJob + install bool + pnpmCommand string + cmdFactory ICmdFactory +} + +func NewJob( + file string, + install bool, + cmdFactory ICmdFactory, +) *Job { + return &Job{ + BaseJob: job.NewBaseJob(file), + install: install, + cmdFactory: cmdFactory, + } +} + +func (j *Job) Install() bool { + return j.install +} + +func (j *Job) Run() { + if j.install { + status := "installing dependencies" + j.SendStatus(status) + j.pnpmCommand = pnpm + + installCmd, err := j.cmdFactory.MakeInstallCmd(j.pnpmCommand, j.GetFile()) + + if err != nil { + j.handleError(j.createError(err.Error(), installCmd.String(), status)) + + return + } + + if output, err := installCmd.Output(); err != nil { + joined := strings.Join([]string{string(output), j.GetExitError(err, "").Error()}, "") + j.handleError(j.createError(joined, installCmd.String(), status)) + + return + } + } +} + +func (j *Job) createError(error string, cmd string, status string) job.IError { + cmdError := util.NewPMJobError(error) + cmdError.SetCommand(cmd) + cmdError.SetStatus(status) + + return cmdError +} + +func (j *Job) handleError(cmdError job.IError) { + expressions := []string{ + executableNotFoundErrRegex, + } + + for _, expression := range expressions { + regex := regexp.MustCompile(expression) + matches := regex.FindAllStringSubmatch(cmdError.Error(), -1) + + if len(matches) > 0 { + cmdError = j.addDocumentation(expression, matches, cmdError) + j.Errors().Append(cmdError) + + return + } + } + + j.Errors().Append(cmdError) +} + +func (j *Job) addDocumentation(expr string, matches [][]string, cmdError job.IError) job.IError { + documentation := cmdError.Documentation() + + switch expr { + case executableNotFoundErrRegex: + documentation = j.GetExecutableNotFoundErrorDocumentation("PNPM") + } + + cmdError.SetDocumentation(documentation) + + return cmdError +} diff --git a/internal/resolution/pm/pnpm/job_test.go b/internal/resolution/pm/pnpm/job_test.go new file mode 100644 index 00000000..8e9495f8 --- /dev/null +++ b/internal/resolution/pm/pnpm/job_test.go @@ -0,0 +1,85 @@ +package pnpm + +import ( + "errors" + "testing" + + jobTestdata "github.com/debricked/cli/internal/resolution/job/testdata" + "github.com/debricked/cli/internal/resolution/pm/pnpm/testdata" + "github.com/debricked/cli/internal/resolution/pm/util" + "github.com/stretchr/testify/assert" +) + +const ( + badName = "bad-name" +) + +func TestNewJob(t *testing.T) { + j := NewJob("file", false, CmdFactory{ + execPath: ExecPath{}, + }) + assert.Equal(t, "file", j.GetFile()) + assert.False(t, j.Errors().HasError()) +} + +func TestInstall(t *testing.T) { + j := Job{install: true} + assert.Equal(t, true, j.Install()) + + j = Job{install: false} + assert.Equal(t, false, j.Install()) +} + +func TestRunInstallCmdErr(t *testing.T) { + cases := []struct { + name string + error string + doc string + }{ + { + name: "General error", + error: "cmd-error", + doc: util.UnknownError, + }, + { + name: "PNPM not found", + error: " |exec: \"pnpm\": executable file not found in $PATH", + doc: "PNPM wasn't found. Please check if it is installed and accessible by the CLI.", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cmdErr := errors.New(c.error) + cmdFactoryMock := testdata.NewEchoCmdFactory() + cmdFactoryMock.MakeInstallErr = cmdErr + cmd, _ := cmdFactoryMock.MakeInstallCmd("echo", "pnpm-lock.yaml") + + expectedError := util.NewPMJobError(c.error) + expectedError.SetDocumentation(c.doc) + expectedError.SetStatus("installing dependencies") + expectedError.SetCommand(cmd.String()) + + j := NewJob("file", true, cmdFactoryMock) + + go jobTestdata.WaitStatus(j) + j.Run() + + allErrors := j.Errors().GetAll() + + assert.Len(t, j.Errors().GetAll(), 1) + assert.Contains(t, allErrors, expectedError) + }) + } +} + +func TestRunInstallCmdOutputErr(t *testing.T) { + cmdMock := testdata.NewEchoCmdFactory() + cmdMock.InstallCmdName = badName + j := NewJob("file", true, cmdMock) + + go jobTestdata.WaitStatus(j) + j.Run() + + jobTestdata.AssertPathErr(t, j.Errors()) +} diff --git a/internal/resolution/pm/pnpm/pm.go b/internal/resolution/pm/pnpm/pm.go new file mode 100644 index 00000000..3d0fc4d6 --- /dev/null +++ b/internal/resolution/pm/pnpm/pm.go @@ -0,0 +1,24 @@ +package pnpm + +const Name = "pnpm" + +type Pm struct { + name string +} + +func NewPm() Pm { + return Pm{ + name: Name, + } +} + +func (pm Pm) Name() string { + return pm.name +} + +func (Pm) Manifests() []string { + return []string{ + `package\.json$`, + `pnpm-lock\.yaml$`, + } +} diff --git a/internal/resolution/pm/pnpm/pm_test.go b/internal/resolution/pm/pnpm/pm_test.go new file mode 100644 index 00000000..fe79a1ec --- /dev/null +++ b/internal/resolution/pm/pnpm/pm_test.go @@ -0,0 +1,61 @@ +package pnpm + +import ( + "regexp" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewPm(t *testing.T) { + pm := NewPm() + assert.Equal(t, Name, pm.name) +} + +func TestName(t *testing.T) { + pm := NewPm() + assert.Equal(t, Name, pm.Name()) +} + +func TestManifests(t *testing.T) { + pm := Pm{} + manifests := pm.Manifests() + assert.Len(t, manifests, 2) + + // First manifest: package.json + manifestPkg := manifests[0] + assert.Equal(t, `package\.json$`, manifestPkg) + _, err := regexp.Compile(manifestPkg) + assert.NoError(t, err) + + casesPkg := map[string]bool{ + "package.json": true, + "pnpm-lock.yaml": false, + "pnpm-lock.yml": false, + "package-lock.json": false, + } + for file, isMatch := range casesPkg { + t.Run("pkg-"+file, func(t *testing.T) { + matched, _ := regexp.MatchString(manifestPkg, file) + assert.Equal(t, isMatch, matched) + }) + } + + // Second manifest: pnpm-lock.yaml + manifestLock := manifests[1] + assert.Equal(t, `pnpm-lock\.yaml$`, manifestLock) + _, err = regexp.Compile(manifestLock) + assert.NoError(t, err) + + casesLock := map[string]bool{ + "pnpm-lock.yaml": true, + "pnpm-lock.yml": false, + "package.json": false, + } + for file, isMatch := range casesLock { + t.Run("lock-"+file, func(t *testing.T) { + matched, _ := regexp.MatchString(manifestLock, file) + assert.Equal(t, isMatch, matched) + }) + } +} diff --git a/internal/resolution/pm/pnpm/strategy.go b/internal/resolution/pm/pnpm/strategy.go new file mode 100644 index 00000000..a6aab07f --- /dev/null +++ b/internal/resolution/pm/pnpm/strategy.go @@ -0,0 +1,27 @@ +package pnpm + +import "github.com/debricked/cli/internal/resolution/job" + +type Strategy struct { + files []string +} + +func (s Strategy) Invoke() ([]job.IJob, error) { + var jobs []job.IJob + for _, file := range s.files { + jobs = append(jobs, NewJob( + file, + true, + CmdFactory{ + execPath: ExecPath{}, + }, + ), + ) + } + + return jobs, nil +} + +func NewStrategy(files []string) Strategy { + return Strategy{files} +} diff --git a/internal/resolution/pm/pnpm/testdata/cmd_factory_mock.go b/internal/resolution/pm/pnpm/testdata/cmd_factory_mock.go new file mode 100644 index 00000000..a9928df1 --- /dev/null +++ b/internal/resolution/pm/pnpm/testdata/cmd_factory_mock.go @@ -0,0 +1,25 @@ +package testdata + +import "os/exec" + +type CmdFactoryMock struct { + InstallCmdName string + MakeInstallErr error +} + +type ExecPathMock struct{} + +func (ExecPathMock) LookPath(file string) (string, error) { + // Just return the name; Exec.Cmd will use PATH resolution + return file, nil +} + +func NewEchoCmdFactory() CmdFactoryMock { + return CmdFactoryMock{ + InstallCmdName: "echo", + } +} + +func (f CmdFactoryMock) MakeInstallCmd(command string, file string) (*exec.Cmd, error) { + return exec.Command(f.InstallCmdName), f.MakeInstallErr +} diff --git a/internal/resolution/strategy/strategy_factory.go b/internal/resolution/strategy/strategy_factory.go index 04543026..75565a43 100644 --- a/internal/resolution/strategy/strategy_factory.go +++ b/internal/resolution/strategy/strategy_factory.go @@ -12,6 +12,7 @@ import ( "github.com/debricked/cli/internal/resolution/pm/npm" "github.com/debricked/cli/internal/resolution/pm/nuget" "github.com/debricked/cli/internal/resolution/pm/pip" + "github.com/debricked/cli/internal/resolution/pm/pnpm" "github.com/debricked/cli/internal/resolution/pm/poetry" "github.com/debricked/cli/internal/resolution/pm/sbt" "github.com/debricked/cli/internal/resolution/pm/uv" @@ -46,6 +47,8 @@ func (sf Factory) Make(pmFileBatch file.IBatch, paths []string) (IStrategy, erro return uv.NewStrategy(pmFileBatch.Files()), nil case yarn.Name: return yarn.NewStrategy(pmFileBatch.Files()), nil + case pnpm.Name: + return pnpm.NewStrategy(pmFileBatch.Files()), nil case npm.Name: return npm.NewStrategy(pmFileBatch.Files()), nil case bower.Name: