mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: Add dependency cooldown for npm packages Strip recently-published package versions from npm registry metadata responses so npm's resolver naturally falls back to older versions. Overrides the Accept header to force full packument responses (which include the "time" field needed for publish-date checks). Reports cooldown blocks only when all versions are stripped (remaining == 0), matching npm's --min-release-age behavior for silent fallback. * fix: Report oldest version in cooldown block (shortest wait) When all versions are blocked by cooldown, report the oldest version since it exits the cooldown window first — giving the user the shortest wait time instead of the longest. * fix: Handle resp.Body.Close error return for errcheck linter * test: Add dependency cooldown assertions to template config tests * fix: config template for dependency cooldown * fix: Prevent npm from caching cooldown-stripped metadata responses * fix: Restore body on ReadAll failure and log Close errors in response modifier * fix: Close response body before replacing to prevent connection leak * fix: Correct daysLeft ceiling math and update ContentLength on error recovery * fix: Clear Status on status code change and update ContentLength in modifier error path * refactor: address review comments on dependency cooldown PR - Make NpmCooldownHandler and constructor package-private - Pass cooldown days as parameter instead of reading config internally - Convert standalone functions to methods on npmCooldownHandler - Set Accept-Encoding: identity to prevent gzip responses breaking JSON parsing - Return 503 with descriptive message when upstream body read fails * fix: log errors in stripCooldownVersions instead of swallowing them * fix: Config preserve fallback defaults * fix: Code review fixes * fix: correct cooldown tip to show wait time instead of incorrect trusted_packages advice * fix: prevent integer overflow in cooldown duration calculation with large days values * refactor: deduplicate CooldownBlock into internal/models, fix misleading variable names - Move CooldownBlock struct to internal/models to eliminate duplication between proxy/interceptors and internal/ui packages - Simplify proxy_flow.go by using direct assignment instead of field copy - Rename latestStripped/latestDate to oldestVer/oldestDate for clarity * fix: Dependency Cooldown Check Encapsulation (#207) * fix: Encapsulate cooldown check * feat: Add --skip-dependency-cooldown override * fix: Code review fixes --------- Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
522 lines
17 KiB
Go
522 lines
17 KiB
Go
package flows
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/safedep/dry/log"
|
|
"github.com/safedep/pmg/analyzer"
|
|
"github.com/safedep/pmg/config"
|
|
"github.com/safedep/pmg/guard"
|
|
"github.com/safedep/pmg/internal/pty"
|
|
"github.com/safedep/pmg/internal/ui"
|
|
"github.com/safedep/pmg/packagemanager"
|
|
"github.com/safedep/pmg/proxy"
|
|
"github.com/safedep/pmg/proxy/certmanager"
|
|
"github.com/safedep/pmg/proxy/interceptors"
|
|
"github.com/safedep/pmg/sandbox/executor"
|
|
"github.com/safedep/pmg/usefulerror"
|
|
)
|
|
|
|
type proxyFlow struct {
|
|
pm packagemanager.PackageManager
|
|
packageResolver packagemanager.PackageResolver
|
|
}
|
|
|
|
// ProxyFlow creates a new proxy-based flow for package manager protection
|
|
func ProxyFlow(pm packagemanager.PackageManager, packageResolver packagemanager.PackageResolver) *proxyFlow {
|
|
return &proxyFlow{
|
|
pm: pm,
|
|
packageResolver: packageResolver,
|
|
}
|
|
}
|
|
|
|
// Run executes the proxy-based flow
|
|
func (f *proxyFlow) Run(ctx context.Context, args []string, parsedCmd *packagemanager.ParsedCommand) error {
|
|
// Check if we have a supported ecosystem else fail fast
|
|
ecosystem := f.pm.Ecosystem()
|
|
if !interceptors.IsSupported(ecosystem) {
|
|
return fmt.Errorf("proxy mode is not supported for %s", ecosystem.String())
|
|
}
|
|
|
|
// Configure sandbox based on command type and enforcement policy
|
|
config.ConfigureSandbox(parsedCmd.IsInstallationCommand())
|
|
|
|
cfg := config.Get()
|
|
|
|
// Initialize report data at the start
|
|
reportData := ui.NewReportData()
|
|
reportData.PackageManagerName = f.pm.Name()
|
|
reportData.FlowType = ui.FlowTypeProxy
|
|
reportData.DryRun = cfg.DryRun
|
|
reportData.InsecureMode = cfg.InsecureInstallation
|
|
reportData.TransitiveEnabled = cfg.Config.Transitive
|
|
reportData.ParanoidMode = cfg.Config.Paranoid
|
|
reportData.SandboxEnabled = cfg.Config.Sandbox.Enabled
|
|
|
|
if cfg.Config.Sandbox.Enabled {
|
|
if policyRef, exists := cfg.Config.Sandbox.Policies[f.pm.Name()]; exists {
|
|
reportData.SandboxProfile = policyRef.Profile
|
|
}
|
|
}
|
|
|
|
if cfg.SandboxProfileOverride != "" {
|
|
reportData.SandboxProfile = cfg.SandboxProfileOverride
|
|
}
|
|
|
|
startTime := time.Now()
|
|
|
|
// Check if dry-run mode is enabled
|
|
if cfg.DryRun {
|
|
log.Infof("Dry-run mode: Would execute %s with experimental proxy protection", f.pm.Name())
|
|
log.Infof("Dry-run mode: Command would be: %s %v", parsedCmd.Command.Exe, parsedCmd.Command.Args)
|
|
|
|
reportData.Outcome = ui.OutcomeDryRun
|
|
ui.Report(reportData)
|
|
|
|
return nil
|
|
}
|
|
|
|
// Setup CA certificate for MITM
|
|
caCert, caCertPath, err := f.setupCACertificate()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to setup CA certificate for proxy mode: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
// Clean up temporary CA certificate file
|
|
if caCertPath != "" {
|
|
if err := os.Remove(caCertPath); err != nil {
|
|
log.Errorf("Failed to remove CA certificate file: %v", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Create certificate manager
|
|
certMgr, err := f.createCertificateManager(caCert)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create certificate manager: %w", err)
|
|
}
|
|
|
|
// Create analyzer
|
|
malysisAnalyzer, err := f.createAnalyzer()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create analyzer: %w", err)
|
|
}
|
|
|
|
// Create analysis cache and stats collector
|
|
cache := interceptors.NewInMemoryAnalysisCache()
|
|
statsCollector := interceptors.NewAnalysisStatsCollector()
|
|
|
|
// Create confirmation channel and start confirmation handler
|
|
confirmationChan := make(chan *interceptors.ConfirmationRequest, 10)
|
|
defer close(confirmationChan)
|
|
|
|
// Create interaction callbacks for user prompts
|
|
// Note: We use a pointer so we can later inject the input reader via SetInput
|
|
interaction := &guard.PackageManagerGuardInteraction{
|
|
SetStatus: ui.SetStatus,
|
|
ClearStatus: ui.ClearStatus,
|
|
ShowWarning: ui.ShowWarning,
|
|
Block: ui.BlockNoExit,
|
|
}
|
|
|
|
// Create ecosystem-specific interceptor using factory
|
|
factory := interceptors.NewInterceptorFactory(malysisAnalyzer, cache, statsCollector, confirmationChan)
|
|
interceptor, err := factory.CreateInterceptor(ecosystem)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create interceptor for %s: %w", ecosystem.String(), err)
|
|
}
|
|
|
|
log.Debugf("Created %s interceptor for ecosystem %s", interceptor.Name(), ecosystem.String())
|
|
|
|
// Create and start proxy server
|
|
proxyServer, proxyAddr, err := f.createAndStartProxyServer(certMgr, []proxy.Interceptor{
|
|
interceptor,
|
|
interceptors.NewAuditLoggerInterceptor(),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to start proxy server: %w", err)
|
|
}
|
|
|
|
// Ensure proxy is stopped on exit
|
|
defer func() {
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := proxyServer.Stop(shutdownCtx); err != nil {
|
|
log.Errorf("Failed to stop proxy server: %v", err)
|
|
}
|
|
}()
|
|
|
|
ui.ClearStatus()
|
|
log.Infof("Proxy server started on %s", proxyAddr)
|
|
log.Infof("Running %s with proxy protection enabled", f.pm.Name())
|
|
|
|
proxyEnv := f.setupEnvForProxy(proxyAddr, caCertPath)
|
|
|
|
var executionError error
|
|
if pty.IsInteractiveTerminal() {
|
|
// Execute the package manager command with proxy environment variables
|
|
executionError = f.executeWithProxy(ctx, parsedCmd, proxyEnv, confirmationChan, interaction)
|
|
} else {
|
|
// Execute the package manager command with proxy environment variables for non PTY or non-interactive TTY
|
|
executionError = f.executeWithProxyForNonInteractiveTTY(ctx, parsedCmd, proxyEnv, confirmationChan, interaction)
|
|
}
|
|
|
|
// Populate report data from stats collector
|
|
stats := statsCollector.GetStats()
|
|
reportData.StartTime = startTime
|
|
reportData.TotalAnalyzed = stats.TotalAnalyzed
|
|
reportData.AllowedCount = stats.AllowedCount
|
|
reportData.ConfirmedCount = stats.ConfirmedCount
|
|
reportData.BlockedCount = stats.BlockedCount
|
|
reportData.BlockedPackages = statsCollector.GetBlockedPackages()
|
|
reportData.ConfirmedPackages = statsCollector.GetConfirmedPackages()
|
|
|
|
reportData.CooldownBlockedPackages = statsCollector.GetCooldownBlocks()
|
|
|
|
// Set outcome based on execution result using shared inference logic
|
|
reportData.Outcome = inferOutcome(cfg.InsecureInstallation, cfg.DryRun, reportData.BlockedCount, stats.UserCancelledCount, executionError)
|
|
|
|
// Show the report
|
|
ui.Report(reportData)
|
|
|
|
// Run should always end with handleExecutionResultError to ensure the process exits with the correct exit code
|
|
// from the execution result.
|
|
return handleExecutionResultError(executionError)
|
|
}
|
|
|
|
// handleExecutionResultError handles the error from the execution result.
|
|
func handleExecutionResultError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
os.Exit(exitErr.ExitCode())
|
|
}
|
|
|
|
return fmt.Errorf("failed to execute command: %w", err)
|
|
}
|
|
|
|
// setupCACertificate generates CA for MITM and writes proxy bundle for child package managers.
|
|
func (f *proxyFlow) setupCACertificate() (*certmanager.Certificate, string, error) {
|
|
log.Debugf("Generating CA certificate for proxy MITM")
|
|
|
|
// Generate CA certificate
|
|
caConfig := certmanager.DefaultCertManagerConfig()
|
|
caCert, err := certmanager.GenerateCAWithSystemCA(caConfig)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to generate CA certificate: %w", err)
|
|
}
|
|
|
|
// Write CA certificate to temporary file for package managers to trust
|
|
tempDir := os.TempDir()
|
|
caCertPath := filepath.Join(tempDir, fmt.Sprintf("pmg-ca-cert-%d.pem", os.Getpid()))
|
|
|
|
if err := os.WriteFile(caCertPath, caCert.Certificate, 0o600); err != nil {
|
|
return nil, "", fmt.Errorf("failed to write CA certificate to %s: %w", caCertPath, err)
|
|
}
|
|
|
|
log.Debugf("CA certificate written to %s", caCertPath)
|
|
|
|
return caCert, caCertPath, nil
|
|
}
|
|
|
|
// createCertificateManager creates a certificate manager with the given CA certificate
|
|
func (f *proxyFlow) createCertificateManager(caCert *certmanager.Certificate) (certmanager.CertificateManager, error) {
|
|
caConfig := certmanager.DefaultCertManagerConfig()
|
|
certMgr, err := certmanager.NewCertificateManagerWithCA(caCert, caConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create certificate manager: %w", err)
|
|
}
|
|
|
|
return certMgr, nil
|
|
}
|
|
|
|
// createAnalyzer creates the malysis query analyzer
|
|
func (f *proxyFlow) createAnalyzer() (analyzer.PackageVersionAnalyzer, error) {
|
|
log.Debugf("Creating malysis query analyzer")
|
|
return analyzer.NewMalysisQueryAnalyzer(analyzer.MalysisQueryAnalyzerConfig{})
|
|
}
|
|
|
|
// createAndStartProxyServer creates and starts the proxy server with the given interceptor
|
|
func (f *proxyFlow) createAndStartProxyServer(
|
|
certMgr certmanager.CertificateManager,
|
|
interceptorsList []proxy.Interceptor,
|
|
) (proxy.ProxyServer, string, error) {
|
|
proxyConfig := proxy.DefaultProxyConfig()
|
|
proxyConfig.CertManager = certMgr
|
|
proxyConfig.Interceptors = interceptorsList
|
|
|
|
proxyServer, err := proxy.NewProxyServer(proxyConfig)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to create proxy server: %w", err)
|
|
}
|
|
|
|
if err := proxyServer.Start(); err != nil {
|
|
return nil, "", fmt.Errorf("failed to start proxy server: %w", err)
|
|
}
|
|
|
|
proxyAddr := proxyServer.Address()
|
|
if proxyAddr == "" {
|
|
return nil, "", fmt.Errorf("proxy server started but address is empty")
|
|
}
|
|
|
|
return proxyServer, proxyAddr, nil
|
|
}
|
|
|
|
func (f *proxyFlow) setupEnvForProxy(proxyAddr, caCertPath string) []string {
|
|
proxyURL := fmt.Sprintf("http://%s", proxyAddr)
|
|
|
|
noProxyList := "localhost,127.0.0.1,[::1]"
|
|
|
|
env := os.Environ()
|
|
env = append(env,
|
|
"NODE_USE_ENV_PROXY=1",
|
|
fmt.Sprintf("HTTP_PROXY=%s", proxyURL),
|
|
fmt.Sprintf("HTTPS_PROXY=%s", proxyURL),
|
|
fmt.Sprintf("NO_PROXY=%s", noProxyList),
|
|
fmt.Sprintf("NODE_EXTRA_CA_CERTS=%s", caCertPath),
|
|
fmt.Sprintf("http_proxy=%s", proxyURL),
|
|
fmt.Sprintf("https_proxy=%s", proxyURL),
|
|
fmt.Sprintf("no_proxy=%s", noProxyList),
|
|
fmt.Sprintf("SSL_CERT_FILE=%s", caCertPath),
|
|
fmt.Sprintf("REQUESTS_CA_BUNDLE=%s", caCertPath),
|
|
fmt.Sprintf("PIP_CERT=%s", caCertPath),
|
|
fmt.Sprintf("PIP_PROXY=%s", proxyURL),
|
|
"PIP_RETRIES=0",
|
|
)
|
|
|
|
return env
|
|
}
|
|
|
|
// executeWithProxyForNonInteractiveTTY runs the command without PTY (for CI/non-interactive environments)
|
|
func (f *proxyFlow) executeWithProxyForNonInteractiveTTY(
|
|
ctx context.Context,
|
|
parsedCmd *packagemanager.ParsedCommand,
|
|
env []string,
|
|
confirmationChan chan *interceptors.ConfirmationRequest,
|
|
interaction *guard.PackageManagerGuardInteraction,
|
|
) error {
|
|
log.Debugf("Executing proxy for non interactive TTY")
|
|
|
|
// For non-interactive terminals, we enforce suspicious packages as malicious
|
|
interaction.GetConfirmationOnMalware = func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
|
return false, nil
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...)
|
|
cmd.Env = append(env, "CI=true")
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
|
|
go interceptors.HandleConfirmationRequests(
|
|
confirmationChan,
|
|
interaction,
|
|
nil,
|
|
)
|
|
|
|
result, err := executor.ApplySandbox(ctx, cmd, f.pm.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to apply sandbox: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
err := result.Close()
|
|
if err != nil {
|
|
log.Errorf("failed to close sandbox: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Only run the command if the sandbox didn't already execute it
|
|
if result.ShouldRun() {
|
|
log.Debugf("Running command with args: %s: %v", cmd.Path, cmd.Args[1:])
|
|
|
|
err = cmd.Run()
|
|
if err != nil {
|
|
return f.handlePackageManagerExecutionError(err)
|
|
}
|
|
}
|
|
|
|
log.Debugf("Command completed successfully")
|
|
return nil
|
|
}
|
|
|
|
// executeWithProxy executes the package manager command with proxy environment variables.
|
|
func (f *proxyFlow) executeWithProxy(
|
|
ctx context.Context,
|
|
parsedCmd *packagemanager.ParsedCommand,
|
|
env []string,
|
|
confirmationChan chan *interceptors.ConfirmationRequest,
|
|
interaction *guard.PackageManagerGuardInteraction,
|
|
) error {
|
|
log.Debugf("Executing proxy for interactive TTY")
|
|
|
|
// Set the confirmation handler to use the interaction's reader
|
|
// This allows PTY input routing during proxy mode
|
|
interaction.GetConfirmationOnMalware = func(malwarePackages []*analyzer.PackageVersionAnalysisResult) (bool, error) {
|
|
return ui.GetConfirmationOnMalwareWithReader(malwarePackages, interaction.Reader())
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, parsedCmd.Command.Exe, parsedCmd.Command.Args...)
|
|
result, err := executor.ApplySandbox(ctx, cmd, f.pm.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to apply sandbox: %w", err)
|
|
}
|
|
|
|
if !result.ShouldRun() {
|
|
return usefulerror.Useful().
|
|
Wrap(fmt.Errorf("sandbox not supported for PTY sessions")).
|
|
WithHumanError("Sandbox executed command cannot be used with PTY session. Please use non-interactive TTY mode instead.")
|
|
}
|
|
|
|
// Extract the command executable and arguments from the sandboxed command
|
|
// for use to create the PTY session.
|
|
cmdExe := cmd.Path
|
|
cmdArgs := cmd.Args[1:]
|
|
|
|
log.Debugf("Running command with args: %s: %v", cmdExe, cmdArgs)
|
|
|
|
// Create the PTY session with the sandbox command
|
|
// This is not compatible with sandbox that executes the command directly within the sandbox
|
|
// because internally we use ptyx.Spawn() to create the process with PTY support.
|
|
sessionConfig := pty.NewSessionConfig(cmdExe, cmdArgs, env)
|
|
|
|
sess, err := pty.NewSession(ctx, sessionConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create pty session: %w", err)
|
|
}
|
|
defer sess.Close()
|
|
|
|
outputRouter, err := pty.NewOutputRouter(os.Stdout)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create output router: %w", err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
if _, err := io.Copy(outputRouter, sess.PtyReader()); err != nil {
|
|
log.Errorf("failed to copy output: %v", err)
|
|
}
|
|
})
|
|
|
|
inputRouter, err := pty.NewInputRouter(sess.PtyWriter())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create input router: %w", err)
|
|
}
|
|
|
|
promptReader, promptWriter := io.Pipe()
|
|
defer func() {
|
|
promptWriter.Close()
|
|
promptReader.Close()
|
|
}()
|
|
|
|
// Note: This goroutine cannot be cleanly cancelled because os.Stdin.Read() is
|
|
// a blocking syscall that doesn't support timeouts or cancellation. This is a
|
|
// known limitation. The goroutine will exit when the process terminates, which
|
|
// is acceptable for a CLI tool. For long-running servers, stdin reading should
|
|
// be handled differently.
|
|
go inputRouter.ReadLoop(os.Stdin)
|
|
|
|
go interceptors.HandleConfirmationRequests(
|
|
confirmationChan,
|
|
interaction,
|
|
&interceptors.ConfirmationHook{
|
|
BeforeInteraction: func(_ []*analyzer.PackageVersionAnalysisResult) error {
|
|
// Pause printing the child output
|
|
outputRouter.Pause()
|
|
|
|
// Restore "Cooked" mode so user can type normally with echo
|
|
if err := sess.SetCookedMode(); err != nil {
|
|
return fmt.Errorf("failed to set cooked mode: %w", err)
|
|
}
|
|
|
|
// Force cursor visible (ANSI escape sequence)
|
|
fmt.Fprint(os.Stdout, "\033[?25h")
|
|
|
|
// Switch Input: Route keystrokes to the Prompt Pipe
|
|
inputRouter.RouteToPrompt(promptWriter)
|
|
|
|
// Inject the Reader into the Interaction for the confirmation prompt
|
|
interaction.SetInput(promptReader)
|
|
|
|
return nil
|
|
},
|
|
AfterInteraction: func(_ []*analyzer.PackageVersionAnalysisResult, _ bool) error {
|
|
// Switch input back to PTY
|
|
inputRouter.RouteToPTY()
|
|
|
|
// Restore "Raw" mode for the PTY
|
|
if err := sess.SetRawMode(); err != nil {
|
|
return fmt.Errorf("failed to set raw mode: %w", err)
|
|
}
|
|
|
|
// Clear the interaction input (back to default)
|
|
interaction.SetInput(nil)
|
|
|
|
// Flush buffered output and resume live output
|
|
outputRouter.Resume()
|
|
|
|
return nil
|
|
},
|
|
},
|
|
)
|
|
|
|
// sessionError may contain the exit code of the command if the command exited with a non-zero code.
|
|
sessionError := sess.Wait()
|
|
|
|
// Wait for the routers to copy all the remaining data
|
|
wg.Wait()
|
|
|
|
if err := promptReader.Close(); err != nil {
|
|
log.Errorf("failed to close prompt reader: %v", err)
|
|
}
|
|
|
|
if err := promptWriter.Close(); err != nil {
|
|
log.Errorf("failed to close prompt writer: %v", err)
|
|
}
|
|
|
|
if err := sess.Close(); err != nil {
|
|
log.Errorf("failed to close session: %v", err)
|
|
}
|
|
|
|
if sessionError != nil {
|
|
return f.handlePackageManagerExecutionError(sessionError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (f *proxyFlow) handlePackageManagerExecutionError(err error) error {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
return usefulerror.Useful().
|
|
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
|
|
WithHumanError(fmt.Sprintf("Package manager command exited with code: %d", exitErr.ExitCode())).
|
|
WithHelp("Check the package manager command and its arguments").
|
|
Wrap(err)
|
|
}
|
|
|
|
if sessionError, ok := err.(*pty.ExitError); ok {
|
|
return usefulerror.Useful().
|
|
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
|
|
WithHumanError(fmt.Sprintf("Package manager command exited with code: %d", sessionError.Code)).
|
|
WithHelp("Check the package manager command and its arguments").
|
|
Wrap(sessionError.Err)
|
|
}
|
|
|
|
return usefulerror.Useful().
|
|
WithCode(usefulerror.ErrCodePackageManagerExecutionFailed).
|
|
WithHumanError("Failed to execute package manager command").
|
|
WithHelp("Check the package manager command and its arguments").
|
|
Wrap(err)
|
|
}
|