Files
pmg/packagemanager/npm.go
T
Sahil BansalandGitHub dbe968dec6 fix: only strip own package manager name in ParseCommand args (#208)
* fix: only strip own package manager name in ParseCommand args

ParseCommand was stripping the first arg if it matched any package
manager name (npm, pnpm, bun, yarn). This caused yarn's parser to
incorrectly strip "npm" from "yarn npm login", since "npm" is a
valid yarn subcommand, not a package manager prefix.

Fixes #204

* use require in test
2026-04-08 22:27:45 +05:30

232 lines
5.9 KiB
Go

package packagemanager
import (
"fmt"
"io"
"slices"
"strings"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/spf13/pflag"
)
type NpmPackageManagerConfig struct {
InstallCommands []string
CommandName string
}
func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig {
return NpmPackageManagerConfig{
InstallCommands: []string{"install", "i", "add"},
CommandName: "npm",
}
}
func DefaultPnpmPackageManagerConfig() NpmPackageManagerConfig {
return NpmPackageManagerConfig{
InstallCommands: []string{"install", "i", "add"},
CommandName: "pnpm",
}
}
func DefaultBunPackageManagerConfig() NpmPackageManagerConfig {
return NpmPackageManagerConfig{
InstallCommands: []string{"install", "i", "add"},
CommandName: "bun",
}
}
func DefaultYarnPackageManagerConfig() NpmPackageManagerConfig {
return NpmPackageManagerConfig{
InstallCommands: []string{"install", "add", ""},
CommandName: "yarn",
}
}
type npmPackageManager struct {
Config NpmPackageManagerConfig
}
func NewNpmPackageManager(config NpmPackageManagerConfig) (*npmPackageManager, error) {
return &npmPackageManager{
Config: config,
}, nil
}
var _ PackageManager = &npmPackageManager{}
func (npm *npmPackageManager) Name() string {
return npm.Config.CommandName
}
func (npm *npmPackageManager) Ecosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_NPM
}
func (npm *npmPackageManager) ParseCommand(args []string) (*ParsedCommand, error) {
if len(args) > 0 && args[0] == npm.Config.CommandName {
args = args[1:]
}
command := Command{Exe: npm.Config.CommandName, Args: args}
// Since manifest-based installs like 'npm i' are now valid commands
if len(args) < 1 {
if npm.Config.CommandName == "yarn" {
return &ParsedCommand{
Command: command,
InstallTargets: []*PackageInstallTarget{},
IsManifestInstall: true,
ManifestFiles: []string{},
}, nil
}
return &ParsedCommand{
Command: command,
}, nil
}
// Find the install command position
var installCmdIndex = -1
for idx, arg := range args {
if slices.Contains(npm.Config.InstallCommands, arg) {
installCmdIndex = idx
break
}
}
if installCmdIndex == -1 {
// No install command found, return as-is
return &ParsedCommand{Command: command}, nil
}
// Extract arguments after the install command
installArgs := args[installCmdIndex+1:]
// Extract packages from args
var packages []string
var isManifestInstall bool
var devPackages []string
flagSet := pflag.NewFlagSet(npm.Config.CommandName, pflag.ContinueOnError)
flagSet.SetOutput(io.Discard)
flagSet.ParseErrorsAllowlist.UnknownFlags = true
switch npm.Config.CommandName {
case "npm", "pnpm":
flagSet.StringArrayVarP(&devPackages, "save-dev", "D", nil, "Install dev packages")
case "bun":
flagSet.StringArrayVarP(&devPackages, "dev", "d", nil, "Install dev packages")
case "yarn":
flagSet.StringArrayVarP(&devPackages, "dev", "D", nil, "Install dev packages")
}
// Known only to prevent UnknownFlags mode from swallowing the next package arg.
flagSet.BoolP("global", "g", false, "Install packages globally")
err := flagSet.Parse(installArgs)
if err != nil {
return &ParsedCommand{Command: command}, nil
}
packages = flagSet.Args()
// If install command was found but no explicit packages,
// this is a manifest-based installation
if installCmdIndex != -1 && len(packages) == 0 {
isManifestInstall = true
}
// Yarn-specific validation: yarn install does not accept package names
if npm.Config.CommandName == "yarn" && args[installCmdIndex] == "install" && len(packages) > 0 {
return &ParsedCommand{
Command: command,
}, nil
}
// No packages found and not a manifest install
if len(packages) == 0 && !isManifestInstall {
return &ParsedCommand{
Command: command,
}, nil
}
packages = append(packages, devPackages...)
// Process all package arguments
var installTargets []*PackageInstallTarget
for _, pkg := range packages {
packageName, version, err := npmParsePackageInfo(pkg)
if err != nil {
return nil, ErrFailedToParsePackage.Wrap(err)
}
// Clean version if specified
if version != "" {
version = npmCleanVersion(version)
}
installTargets = append(installTargets, &PackageInstallTarget{
PackageVersion: &packagev1.PackageVersion{
Package: &packagev1.Package{
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
Name: packageName,
},
Version: version,
},
})
}
return &ParsedCommand{
Command: command,
InstallTargets: installTargets,
IsManifestInstall: isManifestInstall,
ManifestFiles: []string{},
}, nil
}
func npmParsePackageInfo(input string) (packageName, version string, err error) {
if input == "" {
return "", "", fmt.Errorf("package info cannot be empty")
}
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "@") {
// Scoped package (e.g. @types/node or @types/node@1.0.0)
lastAtIndex := strings.LastIndex(input, "@")
if lastAtIndex > 0 {
packageName = strings.TrimSpace(input[:lastAtIndex])
version = strings.TrimSpace(input[lastAtIndex+1:])
return packageName, version, nil
}
// If no version specifier, return the whole input as package name
return strings.TrimSpace(input), "", nil
}
// Normal package (e.g. lodash or lodash@4.17.21)
parts := strings.Split(input, "@")
if len(parts) == 2 {
packageName = strings.TrimSpace(parts[0])
version = strings.TrimSpace(parts[1])
return packageName, version, nil
}
if len(parts) == 1 {
packageName = strings.TrimSpace(parts[0])
return packageName, "", nil
}
return "", "", fmt.Errorf("invalid format: expected 'package' OR 'package@version', got '%s'", input)
}
func npmCleanVersion(version string) string {
version = strings.TrimPrefix(version, "^")
version = strings.TrimPrefix(version, "~")
if version == "*" || version == "" {
return "latest"
}
return version
}