mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
257 lines
6.9 KiB
Go
257 lines
6.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
|
|
NonDownloadCommands []string
|
|
CommandName string
|
|
}
|
|
|
|
func DefaultNpmPackageManagerConfig() NpmPackageManagerConfig {
|
|
return NpmPackageManagerConfig{
|
|
InstallCommands: []string{"install", "i", "add"},
|
|
// Commands that are known to never download packages from a registry.
|
|
// Anything not in this list (including unknown future commands) runs with the proxy.
|
|
NonDownloadCommands: []string{
|
|
// Removal — uninstalls local packages, no registry download
|
|
"uninstall", "remove", "rm", "r", "un", "unlink",
|
|
// Local operations — no registry contact
|
|
"rebuild", "prune", "link", "cache", "pack",
|
|
// Inspection / read-only registry queries
|
|
"ls", "list", "outdated", "view", "info", "show", "search",
|
|
"config", "ping", "whoami", "version", "help",
|
|
},
|
|
CommandName: "npm",
|
|
}
|
|
}
|
|
|
|
func DefaultPnpmPackageManagerConfig() NpmPackageManagerConfig {
|
|
return NpmPackageManagerConfig{
|
|
InstallCommands: []string{"install", "i", "add"},
|
|
NonDownloadCommands: []string{
|
|
"remove", "rm", "uninstall", "un",
|
|
"prune", "link", "unlink",
|
|
"ls", "list", "outdated", "info", "view", "config", "why",
|
|
},
|
|
CommandName: "pnpm",
|
|
}
|
|
}
|
|
|
|
func DefaultBunPackageManagerConfig() NpmPackageManagerConfig {
|
|
return NpmPackageManagerConfig{
|
|
InstallCommands: []string{"install", "i", "add", "ci"},
|
|
NonDownloadCommands: []string{
|
|
// Removal
|
|
"remove", "rm",
|
|
},
|
|
CommandName: "bun",
|
|
}
|
|
}
|
|
|
|
func DefaultYarnPackageManagerConfig() NpmPackageManagerConfig {
|
|
return NpmPackageManagerConfig{
|
|
InstallCommands: []string{"install", "add", ""},
|
|
NonDownloadCommands: []string{
|
|
"remove", "unlink",
|
|
"ls", "list", "outdated", "info", "config", "why",
|
|
},
|
|
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 {
|
|
return &ParsedCommand{Command: command, IsKnownNonDownloadCommand: IsFirstNonFlagArgInList(args, npm.Config.NonDownloadCommands)}, 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{
|
|
IsExplicitVersion: version != "",
|
|
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
|
|
}
|