Skip to content
Merged
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
4 changes: 3 additions & 1 deletion build/docker/alpine.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
3 changes: 2 additions & 1 deletion build/docker/debian.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 86 additions & 17 deletions internal/resolution/file/file_batch_factory.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package file

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"

"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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions internal/resolution/pm/pm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -29,6 +30,7 @@ func Pms() []IPm {
poetry.NewPm(),
uv.NewPm(),
yarn.NewPm(),
pnpm.NewPm(),
npm.NewPm(),
bower.NewPm(),
nuget.NewPm(),
Expand Down
40 changes: 40 additions & 0 deletions internal/resolution/pm/pnpm/cmd_factory.go
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 20 additions & 0 deletions internal/resolution/pm/pnpm/cmd_factory_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
101 changes: 101 additions & 0 deletions internal/resolution/pm/pnpm/job.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading