fix: fail fast when package manager is not installed (#360)

* fix: fail fast with clear error when package manager is not installed

When PMG shims intercept a package manager that isn't installed (e.g. a
clean JAMF-provisioned laptop where PMG is set up before dev tooling),
real-binary resolution failed with a generic "unknown" error and a
bug-report link, making it look like PMG itself had crashed.

Introduce a typed BinaryNotFoundError that exits with code 127 (standard
"command not found") and maps to a new PackageManagerNotFound error code
with an actionable message instead of the Unknown classification.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: improve missing package manager help message

Use a concise, dynamic install hint instead of explaining PMG's
PATH forwarding internals.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: mention PATH in missing package manager help text

Covers the common case where a package manager is installed but its bin
directory is not on PATH yet (e.g. after curl | bash install).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sahil Bansal
2026-07-06 18:31:18 +05:30
committed by GitHub
co-authored by Cursor
parent b19473945b
commit 02965143d0
11 changed files with 124 additions and 0 deletions
+1
View File
@@ -10,6 +10,7 @@ const (
Lifecycle = "Lifecycle"
Network = "Network"
PackageManagerExecutionFailed = "PackageManagerExecutionFailed"
PackageManagerNotFound = "PackageManagerNotFound"
BubblewrapNotFound = "BubblewrapNotFound"
// Package manager error codes.
+5
View File
@@ -2,6 +2,7 @@ package runner
import (
"context"
"errors"
"fmt"
"io"
"os"
@@ -85,6 +86,10 @@ func ExecuteWithOptions(ctx context.Context, pc *packagemanager.ParsedCommand, o
realBinary, err := shim.ResolveRealBinary(pc.Command.Exe)
if err != nil {
var notFound *shim.BinaryNotFoundError
if errors.As(err, &notFound) {
return notFound
}
return fmt.Errorf("failed to resolve real %s binary: %w", pc.Command.Exe, err)
}
+25
View File
@@ -3,9 +3,11 @@ package runner
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/internal/shim"
"github.com/safedep/pmg/packagemanager"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -93,3 +95,26 @@ func TestExecuteWithOptionsRunsDirectHookBeforeSandbox(t *testing.T) {
require.Error(t, err)
assert.True(t, hookCalled)
}
func TestExecuteWithOptionsMissingPackageManager(t *testing.T) {
tmpDir := t.TempDir()
pmgBin := filepath.Join(tmpDir, ".pmg", "bin")
require.NoError(t, os.MkdirAll(pmgBin, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(pmgBin, "bun"), []byte("#!/bin/sh\necho shim"), 0o755))
t.Setenv("PATH", pmgBin)
err := ExecuteWithOptions(context.Background(), &packagemanager.ParsedCommand{
Command: packagemanager.Command{
Exe: "bun",
Args: []string{"--version"},
},
}, ExecuteOptions{
PackageManagerName: "bun",
})
var notFound *shim.BinaryNotFoundError
require.ErrorAs(t, err, &notFound)
assert.Equal(t, "bun", notFound.Name)
assert.Equal(t, 127, notFound.ExitCode())
}
+19
View File
@@ -0,0 +1,19 @@
package shim
import "fmt"
const commandNotFoundExitCode = 127
// BinaryNotFoundError is returned when a package manager name resolves through
// PMG shims but the real binary is absent from PATH (after shim dirs are stripped).
type BinaryNotFoundError struct {
Name string
}
func (e *BinaryNotFoundError) Error() string {
return fmt.Sprintf("%s is not installed", e.Name)
}
func (e *BinaryNotFoundError) ExitCode() int {
return commandNotFoundExitCode
}
+14
View File
@@ -0,0 +1,14 @@
package shim
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBinaryNotFoundError(t *testing.T) {
err := &BinaryNotFoundError{Name: "bun"}
assert.Equal(t, "bun is not installed", err.Error())
assert.Equal(t, commandNotFoundExitCode, err.ExitCode())
}
+4
View File
@@ -1,6 +1,7 @@
package shim
import (
"errors"
"fmt"
"os"
"os/exec"
@@ -79,6 +80,9 @@ func ResolveRealBinary(name string) (string, error) {
resolved, err := exec.LookPath(name)
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", &BinaryNotFoundError{Name: name}
}
return "", fmt.Errorf("could not find %s in PATH (excluding pmg shims): %w", name, err)
}
+3
View File
@@ -194,6 +194,9 @@ func TestResolveRealBinary(t *testing.T) {
if tc.wantErr {
assert.Error(t, err)
var notFound *BinaryNotFoundError
assert.ErrorAs(t, err, &notFound)
assert.Equal(t, tc.binary, notFound.Name)
return
}
+17
View File
@@ -13,6 +13,7 @@ import (
"github.com/safedep/dry/usefulerror"
"github.com/safedep/pmg/errcodes"
"github.com/safedep/pmg/internal/shim"
)
// errorMatcher defines how to detect and convert a specific error type
@@ -24,6 +25,22 @@ type errorMatcher struct {
// errorMatchers is an ordered list of error matchers
// Order matters - more specific matchers should come first
var errorMatchers = []errorMatcher{
// Package manager not installed (PMG shim hit, real binary absent from PATH).
{
match: func(err error) bool {
var notFound *shim.BinaryNotFoundError
return errors.As(err, &notFound)
},
convert: func(err error) usefulerror.UsefulError {
var notFound *shim.BinaryNotFoundError
errors.As(err, &notFound)
return usefulerror.NewUsefulError().
WithCode(errcodes.PackageManagerNotFound).
WithHumanError(fmt.Sprintf("%s is not installed", notFound.Name)).
WithHelp(fmt.Sprintf("Install %s and ensure it is on your PATH, then retry.", notFound.Name)).
Wrap(notFound)
},
},
// File not found errors
{
match: func(err error) bool {
+7
View File
@@ -11,6 +11,7 @@ import (
"github.com/safedep/dry/usefulerror"
"github.com/safedep/pmg/errcodes"
"github.com/safedep/pmg/internal/shim"
"github.com/stretchr/testify/assert"
)
@@ -32,6 +33,12 @@ func Test_ErrorConverters(t *testing.T) {
wantCode: "CUSTOM",
wantHumanError: "Already useful",
},
{
name: "PackageManagerNotFound",
inputError: &shim.BinaryNotFoundError{Name: "bun"},
wantCode: errcodes.PackageManagerNotFound,
wantHumanError: "bun is not installed",
},
{
name: "FileNotExist",
inputError: &fs.PathError{Op: "open", Path: "/nonexistent/file.txt", Err: os.ErrNotExist},
+10
View File
@@ -6,6 +6,10 @@ import (
"os"
)
type exitCoder interface {
ExitCode() int
}
// transparentExit is satisfied by *runner.ChildExitError without importing it.
type transparentExit interface {
error
@@ -71,5 +75,11 @@ func ExitFromCommandError(err error) {
os.Exit(d.code)
}
var ec exitCoder
if errors.As(err, &ec) {
ErrorExitWithCode(err, ec.ExitCode())
return
}
ErrorExit(err)
}
+19
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"testing"
"github.com/safedep/pmg/internal/shim"
"github.com/stretchr/testify/assert"
)
@@ -121,3 +122,21 @@ func TestClassifyExit(t *testing.T) {
assert.False(t, d.notice)
})
}
func TestExitFromCommandErrorExitCode(t *testing.T) {
t.Run("missing package manager uses exit code 127", func(t *testing.T) {
err := &shim.BinaryNotFoundError{Name: "bun"}
var ec exitCoder
assert.True(t, errors.As(err, &ec))
assert.Equal(t, 127, ec.ExitCode())
})
t.Run("wrapped missing package manager preserves exit code 127", func(t *testing.T) {
err := fmt.Errorf("execute: %w", &shim.BinaryNotFoundError{Name: "npm"})
var ec exitCoder
assert.True(t, errors.As(err, &ec))
assert.Equal(t, 127, ec.ExitCode())
})
}