Files
pmg/packagemanager/npm_executor.go
Sahil BansalandGitHub c3a3518c36 feat: Show cooldown report for pinned version installs (#225)
* feat: Show cooldown report for pinned version installs

When a user installs a package with an explicit version (e.g.
npm install foo@1.2.3) and that version falls within the dependency
cooldown window, the cooldown block is now recorded and shown in the
report. Previously, the report only appeared when ALL versions of a
package were in cooldown (remaining == 0), causing pinned version
installs to fail with a confusing "version not found" error from the
package manager instead of a clear cooldown explanation.

Introduces InterceptorContext to carry per-execution data (pinned
versions) from the CLI command through the interceptor layer, keeping
it separate from long-lived dependencies like the analyzer and cache.

* fix: Normalize PyPI pinned version keys for cooldown lookup

CLI-provided package names (e.g. Flask_Cors) don't match the
URL-parsed form (flask-cors). Normalize keys once at construction
time so cooldown lookups match correctly.

* fix: Handle dots in PyPI package name normalization per PEP 503

denormalizePyPIPackageName already documented [-_.] replacement but
only handled underscores. Now also replaces dots with hyphens so
names like zope.interface match the URL-parsed form zope-interface.

* refactor: Extract shared cooldown stats recording into helper

Deduplicate identical stats-recording blocks from npm_cooldown.go
and pypi_cooldown.go into recordCooldownStats in cooldown.go.

* fix: Distinguish explicit version pins from auto-resolved versions

PyPI parsers resolve all packages to concrete versions (even without
a user-specified constraint), so HasVersion() was always true. Add
IsExplicitVersion to PackageInstallTarget, set it only when the user
provided an explicit constraint. Use it in proxy_flow.go to avoid
false pinned-version cooldown reports.
2026-04-23 17:47:14 +05:30

127 lines
3.0 KiB
Go

package packagemanager
import (
"io"
"slices"
"strings"
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
"github.com/spf13/pflag"
)
type NpmPackageExecutorConfig struct {
CommandName string
}
func DefaultNpxPackageExecutorConfig() NpmPackageExecutorConfig {
return NpmPackageExecutorConfig{
CommandName: "npx",
}
}
func DefaultPnpxPackageExecutorConfig() NpmPackageExecutorConfig {
return NpmPackageExecutorConfig{
CommandName: "pnpx",
}
}
type npmPackageExecutor struct {
Config NpmPackageExecutorConfig
}
func NewNpmPackageExecutor(config NpmPackageExecutorConfig) (*npmPackageExecutor, error) {
return &npmPackageExecutor{
Config: config,
}, nil
}
var _ PackageManager = &npmPackageExecutor{}
func (n *npmPackageExecutor) Name() string {
return n.Config.CommandName
}
func (n *npmPackageExecutor) Ecosystem() packagev1.Ecosystem {
return packagev1.Ecosystem_ECOSYSTEM_NPM
}
func (n *npmPackageExecutor) ParseCommand(args []string) (*ParsedCommand, error) {
if len(args) > 0 && (args[0] == "npx" || args[0] == "pnpx") {
args = args[1:]
}
command := Command{Exe: n.Config.CommandName, Args: args}
if len(args) < 1 {
return &ParsedCommand{
Command: command,
}, nil
}
flagSet := pflag.NewFlagSet(n.Config.CommandName, pflag.ContinueOnError)
flagSet.SetOutput(io.Discard)
flagSet.ParseErrorsAllowlist.UnknownFlags = true
var packages []string
switch n.Config.CommandName {
case "npx":
flagSet.StringArrayVarP(&packages, "package", "p", []string{}, "Package List")
case "pnpx":
flagSet.StringArrayVar(&packages, "package", []string{}, "Package List")
}
err := flagSet.Parse(args)
if err != nil {
return &ParsedCommand{Command: command}, nil
}
for _, arg := range flagSet.Args() {
// Append the scoped package
if strings.HasPrefix(arg, "@") && !slices.Contains(packages, arg) {
packages = append(packages, arg)
}
}
// For both npx and pnpx, the first positional argument is typically
// the package to execute (e.g., `npx cowsay@1.6.0` or `pnpx cowsay@1.6.0`).
// However, if -p/--package flags are provided, the first positional arg
// is the binary to run, not the package (e.g., `npx -p typescript tsc`).
if len(flagSet.Args()) > 0 && len(packages) == 0 {
pkg := flagSet.Args()[0]
if !slices.Contains(packages, pkg) {
packages = append(packages, pkg)
}
}
var installTargets []*PackageInstallTarget
for _, pkg := range packages {
packageName, version, err := npmParsePackageInfo(pkg)
if err != nil {
return nil, ErrFailedToParsePackage.Wrap(err)
}
if version != "" {
version = npmCleanVersion(version)
}
installTarget := &PackageInstallTarget{
IsExplicitVersion: version != "",
PackageVersion: &packagev1.PackageVersion{
Package: &packagev1.Package{
Ecosystem: packagev1.Ecosystem_ECOSYSTEM_NPM,
Name: packageName,
},
Version: version,
},
}
installTargets = append(installTargets, installTarget)
}
return &ParsedCommand{
Command: command,
InstallTargets: installTargets,
}, nil
}