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, }, 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, }, 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 }