mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* 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.
127 lines
3.0 KiB
Go
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
|
|
}
|