diff --git a/CHANGELOG.md b/CHANGELOG.md index 9182ebd..c6ff320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New `internal/ecosystem` package centralizing all package-manager-specific logic behind an `Ecosystem` interface + +### Changed + +- Refactor audit, detector, resolver, and cmd packages to use the ecosystem registry instead of hardcoded switch statements +- `spm clean` now removes ecosystem-specific artifact directories instead of hardcoded `node_modules` + +### Removed + +- Removed per-PM audit parsing and command-building files from `internal/audit/` (moved to ecosystem implementations) + ## [0.7.0] - 2026-04-01 ### Added diff --git a/README.md b/README.md index a9a94f9..6146d04 100644 --- a/README.md +++ b/README.md @@ -146,10 +146,10 @@ spm build # Pick a script interactively from package.json spm run -# Remove node_modules +# Remove artifact directories (e.g. node_modules) spm clean -# Remove node_modules and the lock file +# Remove artifact directories and the lock file spm clean --lock # Skip the confirmation prompt (useful in CI) @@ -222,7 +222,7 @@ spm -v | `spm add` | *(interactive search)* | *(interactive search)* | *(interactive search)* | *(interactive search)* | | `spm run` | *(interactive)* | *(interactive)* | *(interactive)* | *(interactive)* | | `spm remove foo` | `npm uninstall foo` | `yarn remove foo` | `pnpm remove foo` | `bun remove foo` | -| `spm clean` | Removes `node_modules` (and lock file with `--lock`) | | | +| `spm clean` | Removes artifact directories (and lock file with `--lock`) | | | | `spm audit` | `npm audit --json`| `yarn audit --json` | `pnpm audit --json` | | | `spm upgrade` | Self-updates spm via GitHub Releases | | | | `spm dev` | `npm run dev` | `yarn dev` | `pnpm dev` | `bun dev` | diff --git a/cmd/audit.go b/cmd/audit.go index f65ffe8..0d69310 100644 --- a/cmd/audit.go +++ b/cmd/audit.go @@ -9,6 +9,7 @@ import ( "github.com/decampsrenan/spm/internal/audio" "github.com/decampsrenan/spm/internal/audit" "github.com/decampsrenan/spm/internal/detector" + "github.com/decampsrenan/spm/internal/ecosystem" "github.com/decampsrenan/spm/internal/prompt" ) @@ -41,6 +42,11 @@ var auditCmd = &cobra.Command{ } } + eco := ecosystem.ForPM(det.PM) + if eco == nil { + return fmt.Errorf("unsupported package manager: %s", det.PM) + } + opts := audit.Options{ ProdOnly: auditProdOnly, JSON: auditJSON, @@ -56,7 +62,7 @@ var auditCmd = &cobra.Command{ opts.Severity = sev } - exitCode, err := audit.Run(string(det.PM), det.Dir, opts) + exitCode, err := audit.Run(eco, det.Dir, opts) if err != nil { if notify { _ = audio.PlayNotification(audio.SoundError) diff --git a/cmd/init.go b/cmd/init.go index ca2f846..0d2a97a 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" - "github.com/decampsrenan/spm/internal/detector" + "github.com/decampsrenan/spm/internal/ecosystem" "github.com/decampsrenan/spm/internal/prompt" "github.com/decampsrenan/spm/internal/resolver" "github.com/decampsrenan/spm/internal/runner" @@ -35,11 +35,11 @@ var initCmd = &cobra.Command{ }, } -var validPMs = map[string]detector.PackageManager{ - "npm": detector.NPM, - "yarn": detector.Yarn, - "pnpm": detector.Pnpm, - "bun": detector.Bun, +var validPMs = map[string]ecosystem.PackageManager{ + "npm": ecosystem.NPM, + "yarn": ecosystem.Yarn, + "pnpm": ecosystem.Pnpm, + "bun": ecosystem.Bun, } func runInit(args []string) error { @@ -50,7 +50,7 @@ func runInit(args []string) error { } // Determine PM and extra args - var pm detector.PackageManager + var pm ecosystem.PackageManager var extraArgs []string if len(args) > 0 { diff --git a/cmd/init_test.go b/cmd/init_test.go index e1e789c..cae0f10 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/decampsrenan/spm/internal/detector" + "github.com/decampsrenan/spm/internal/ecosystem" ) func TestRunInitPackageJsonExists(t *testing.T) { @@ -111,11 +111,11 @@ func TestRunInitNoArgNonTTY(t *testing.T) { } func TestValidPMs(t *testing.T) { - expected := map[string]detector.PackageManager{ - "npm": detector.NPM, - "yarn": detector.Yarn, - "pnpm": detector.Pnpm, - "bun": detector.Bun, + expected := map[string]ecosystem.PackageManager{ + "npm": ecosystem.NPM, + "yarn": ecosystem.Yarn, + "pnpm": ecosystem.Pnpm, + "bun": ecosystem.Bun, } for name, want := range expected { got, ok := validPMs[name] diff --git a/cmd/root.go b/cmd/root.go index 3cb6640..72c60d1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "github.com/decampsrenan/spm/internal/audio" "github.com/decampsrenan/spm/internal/detector" + "github.com/decampsrenan/spm/internal/ecosystem" "github.com/decampsrenan/spm/internal/progress" "github.com/decampsrenan/spm/internal/prompt" "github.com/decampsrenan/spm/internal/resolver" @@ -128,7 +129,7 @@ var removeCmd = &cobra.Command{ var cleanCmd = &cobra.Command{ Use: "clean", - Short: "Remove node_modules and optionally the lock file", + Short: "Remove artifact directories and optionally the lock file", RunE: func(cmd *cobra.Command, args []string) error { lock, _ := cmd.Flags().GetBool("lock") yes, _ := cmd.Flags().GetBool("yes") @@ -250,7 +251,7 @@ func runClean(lock bool, yes bool) error { return err } } else { - // Only need the project dir for node_modules removal. + // Only need the project dir for artifact removal. detections, err := detector.Detect(cwd) var noLock *detector.ErrNoLockFile if errors.As(err, &noLock) { @@ -267,7 +268,14 @@ func runClean(lock bool, yes bool) error { } } - targets := []string{"node_modules"} + // Build targets from ecosystem artifact dirs. + eco := ecosystem.ForPM(det.PM) + var targets []string + if eco != nil { + targets = eco.ArtifactDirs() + } else { + targets = []string{"node_modules"} + } if lock { lockFile := detector.LockFileName(det.PM) if lockFile != "" { @@ -312,7 +320,12 @@ func runClean(lock bool, yes bool) error { for _, t := range existing { path := filepath.Join(det.Dir, t) - if t == "node_modules" { + // Use RemoveAll for directories, Remove for files. + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to stat %s: %w", path, err) + } + if info.IsDir() { err = os.RemoveAll(path) } else { err = os.Remove(path) diff --git a/internal/audit/audit.go b/internal/audit/audit.go index 3827ff9..f596685 100644 --- a/internal/audit/audit.go +++ b/internal/audit/audit.go @@ -14,10 +14,17 @@ const ( ExitError = 2 ) -// Run executes the audit for the given package manager, parses the output, +// Provider abstracts the audit behavior for a given ecosystem. +// Each ecosystem implements this to build and parse its audit commands. +type Provider interface { + BuildAuditCommand(dir string, opts Options) ([]string, error) + ParseAuditOutput(dir string, data []byte) (*AuditResult, error) +} + +// Run executes the audit for the given provider, parses the output, // filters by severity, and renders the result. Returns an exit code. -func Run(pm string, dir string, opts Options) (int, error) { - args, err := buildCommand(pm, dir, opts) +func Run(provider Provider, dir string, opts Options) (int, error) { + args, err := provider.BuildAuditCommand(dir, opts) if err != nil { return ExitError, err } @@ -41,13 +48,13 @@ func Run(pm string, dir string, opts Options) (int, error) { data := stdout.Bytes() if len(data) == 0 { - return ExitError, fmt.Errorf("%s audit produced no output", pm) + return ExitError, fmt.Errorf("audit produced no output") } - // Parse based on PM. - result, err := parse(pm, dir, data) + // Parse based on provider. + result, err := provider.ParseAuditOutput(dir, data) if err != nil { - return ExitError, fmt.Errorf("failed to parse %s audit output: %w", pm, err) + return ExitError, fmt.Errorf("failed to parse audit output: %w", err) } // Filter by minimum severity. @@ -70,27 +77,6 @@ func Run(pm string, dir string, opts Options) (int, error) { return ExitClean, nil } -func parse(pm string, dir string, data []byte) (*AuditResult, error) { - switch pm { - case "npm": - return parseNPM(data) - case "yarn": - version, err := detectYarnVersion(dir) - if err != nil { - // Fall back to classic parse if we can't detect version. - return parseYarnClassic(data) - } - if version >= 2 { - return parseYarnBerry(data) - } - return parseYarnClassic(data) - case "pnpm": - return parsePnpm(data) - default: - return nil, fmt.Errorf("unsupported package manager: %s", pm) - } -} - func filterBySeverity(result *AuditResult, minSev Severity) *AuditResult { minRank := SeverityRank(minSev) filtered := &AuditResult{ diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 6451d82..ebf22b4 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -6,13 +6,24 @@ import ( "testing" ) +// stubProvider is a minimal Provider for testing. +type stubProvider struct{} + +func (s *stubProvider) BuildAuditCommand(_ string, _ Options) ([]string, error) { + return []string{"npm", "audit", "--json"}, nil +} + +func (s *stubProvider) ParseAuditOutput(_ string, _ []byte) (*AuditResult, error) { + return &AuditResult{PM: "npm", Summary: make(map[Severity]int)}, nil +} + func TestRunDryRun(t *testing.T) { // Capture stdout to verify dry-run message. old := os.Stdout r, w, _ := os.Pipe() os.Stdout = w - exitCode, err := Run("npm", t.TempDir(), Options{DryRun: true}) + exitCode, err := Run(&stubProvider{}, t.TempDir(), Options{DryRun: true}) w.Close() os.Stdout = old diff --git a/internal/audit/command.go b/internal/audit/command.go deleted file mode 100644 index 26a8d68..0000000 --- a/internal/audit/command.go +++ /dev/null @@ -1,81 +0,0 @@ -package audit - -import ( - "fmt" - "os/exec" - "strings" -) - -// buildCommand returns the audit command args for the given package manager. -func buildCommand(pm string, dir string, opts Options) ([]string, error) { - switch pm { - case "npm": - return buildNPM(opts), nil - case "yarn": - version, err := detectYarnVersion(dir) - if err != nil { - return nil, fmt.Errorf("cannot detect yarn version: %w", err) - } - if version >= 2 { - return buildYarnBerry(opts), nil - } - return buildYarnClassic(opts), nil - case "pnpm": - return buildPnpm(opts), nil - default: - return nil, fmt.Errorf("unsupported package manager: %s", pm) - } -} - -func buildNPM(opts Options) []string { - args := []string{"npm", "audit", "--json"} - if opts.ProdOnly { - args = append(args, "--omit=dev") - } - return args -} - -func buildYarnClassic(opts Options) []string { - args := []string{"yarn", "audit", "--json"} - if opts.ProdOnly { - args = append(args, "--groups", "dependencies") - } - return args -} - -func buildYarnBerry(opts Options) []string { - args := []string{"yarn", "npm", "audit", "--all", "--json"} - if opts.ProdOnly { - // Yarn Berry doesn't have a direct --prod flag for audit; - // we filter post-parse. But --severity can be passed. - } - return args -} - -func buildPnpm(opts Options) []string { - args := []string{"pnpm", "audit", "--json"} - if opts.ProdOnly { - args = append(args, "--prod") - } - return args -} - -// detectYarnVersion runs `yarn --version` in the given directory and returns -// the major version number. -func detectYarnVersion(dir string) (int, error) { - cmd := exec.Command("yarn", "--version") - cmd.Dir = dir - out, err := cmd.Output() - if err != nil { - return 0, err - } - version := strings.TrimSpace(string(out)) - if len(version) == 0 { - return 0, fmt.Errorf("empty yarn version output") - } - major := version[0] - if major < '0' || major > '9' { - return 0, fmt.Errorf("unexpected yarn version format: %s", version) - } - return int(major - '0'), nil -} diff --git a/internal/audit/command_test.go b/internal/audit/command_test.go deleted file mode 100644 index 2c5dd50..0000000 --- a/internal/audit/command_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package audit - -import ( - "reflect" - "testing" -) - -func TestBuildNPM(t *testing.T) { - tests := []struct { - name string - opts Options - want []string - }{ - {"default", Options{}, []string{"npm", "audit", "--json"}}, - {"prod-only", Options{ProdOnly: true}, []string{"npm", "audit", "--json", "--omit=dev"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildNPM(tt.opts) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - -func TestBuildYarnClassic(t *testing.T) { - tests := []struct { - name string - opts Options - want []string - }{ - {"default", Options{}, []string{"yarn", "audit", "--json"}}, - {"prod-only", Options{ProdOnly: true}, []string{"yarn", "audit", "--json", "--groups", "dependencies"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildYarnClassic(tt.opts) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - -func TestBuildYarnBerry(t *testing.T) { - got := buildYarnBerry(Options{}) - want := []string{"yarn", "npm", "audit", "--all", "--json"} - if !reflect.DeepEqual(got, want) { - t.Errorf("got %v, want %v", got, want) - } -} - -func TestBuildPnpm(t *testing.T) { - tests := []struct { - name string - opts Options - want []string - }{ - {"default", Options{}, []string{"pnpm", "audit", "--json"}}, - {"prod-only", Options{ProdOnly: true}, []string{"pnpm", "audit", "--json", "--prod"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildPnpm(tt.opts) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} diff --git a/internal/audit/parse_npm.go b/internal/audit/parse_npm.go deleted file mode 100644 index f527a57..0000000 --- a/internal/audit/parse_npm.go +++ /dev/null @@ -1,69 +0,0 @@ -package audit - -import "encoding/json" - -// npmAuditOutput matches the npm audit --json output format (npm v7+). -type npmAuditOutput struct { - Vulnerabilities map[string]npmVuln `json:"vulnerabilities"` -} - -type npmVuln struct { - Name string `json:"name"` - Severity string `json:"severity"` - Via []any `json:"via"` - Range string `json:"range"` - FixAvail any `json:"fixAvailable"` -} - -// parseNPM parses the JSON output of `npm audit --json`. -func parseNPM(data []byte) (*AuditResult, error) { - var out npmAuditOutput - if err := json.Unmarshal(data, &out); err != nil { - return nil, err - } - - result := &AuditResult{ - Summary: make(map[Severity]int), - PM: "npm", - } - - for name, v := range out.Vulnerabilities { - sev := Severity(v.Severity) - title := extractNPMTitle(v.Via) - url := extractNPMURL(v.Via) - result.Vulnerabilities = append(result.Vulnerabilities, Vulnerability{ - Name: name, - Severity: sev, - Title: title, - URL: url, - Range: v.Range, - }) - result.Summary[sev]++ - } - - return result, nil -} - -// extractNPMTitle gets the title from the first advisory object in the via array. -func extractNPMTitle(via []any) string { - for _, v := range via { - if m, ok := v.(map[string]any); ok { - if t, ok := m["title"].(string); ok { - return t - } - } - } - return "" -} - -// extractNPMURL gets the URL from the first advisory object in the via array. -func extractNPMURL(via []any) string { - for _, v := range via { - if m, ok := v.(map[string]any); ok { - if u, ok := m["url"].(string); ok { - return u - } - } - } - return "" -} diff --git a/internal/audit/parse_npm_test.go b/internal/audit/parse_npm_test.go deleted file mode 100644 index d879a65..0000000 --- a/internal/audit/parse_npm_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package audit - -import ( - "os" - "testing" -) - -func TestParseNPM_Vulns(t *testing.T) { - data, err := os.ReadFile("testdata/npm_vulns.json") - if err != nil { - t.Fatal(err) - } - - result, err := parseNPM(data) - if err != nil { - t.Fatal(err) - } - - if result.PM != "npm" { - t.Errorf("PM = %q, want %q", result.PM, "npm") - } - if len(result.Vulnerabilities) != 3 { - t.Fatalf("got %d vulns, want 3", len(result.Vulnerabilities)) - } - - // Check summary counts. - if result.Summary[SeverityCritical] != 1 { - t.Errorf("critical = %d, want 1", result.Summary[SeverityCritical]) - } - if result.Summary[SeverityHigh] != 1 { - t.Errorf("high = %d, want 1", result.Summary[SeverityHigh]) - } - if result.Summary[SeverityLow] != 1 { - t.Errorf("low = %d, want 1", result.Summary[SeverityLow]) - } - - // Check a specific vuln has title and URL from the via array. - found := false - for _, v := range result.Vulnerabilities { - if v.Name == "minimist" { - found = true - if v.Title != "Prototype Pollution" { - t.Errorf("title = %q, want %q", v.Title, "Prototype Pollution") - } - if v.URL == "" { - t.Error("expected URL to be set") - } - } - } - if !found { - t.Error("minimist vulnerability not found") - } -} - -func TestParseNPM_Clean(t *testing.T) { - data, err := os.ReadFile("testdata/npm_clean.json") - if err != nil { - t.Fatal(err) - } - - result, err := parseNPM(data) - if err != nil { - t.Fatal(err) - } - - if len(result.Vulnerabilities) != 0 { - t.Errorf("got %d vulns, want 0", len(result.Vulnerabilities)) - } -} diff --git a/internal/audit/parse_pnpm.go b/internal/audit/parse_pnpm.go deleted file mode 100644 index 18980ed..0000000 --- a/internal/audit/parse_pnpm.go +++ /dev/null @@ -1,44 +0,0 @@ -package audit - -import "encoding/json" - -// pnpmAuditOutput matches the pnpm audit --json output format. -type pnpmAuditOutput struct { - Advisories map[string]pnpmAdvisory `json:"advisories"` -} - -type pnpmAdvisory struct { - ModuleName string `json:"module_name"` - Severity string `json:"severity"` - Title string `json:"title"` - URL string `json:"url"` - Range string `json:"vulnerable_versions"` - Patched string `json:"patched_versions"` -} - -func parsePnpm(data []byte) (*AuditResult, error) { - var out pnpmAuditOutput - if err := json.Unmarshal(data, &out); err != nil { - return nil, err - } - - result := &AuditResult{ - Summary: make(map[Severity]int), - PM: "pnpm", - } - - for _, adv := range out.Advisories { - sev := Severity(adv.Severity) - result.Vulnerabilities = append(result.Vulnerabilities, Vulnerability{ - Name: adv.ModuleName, - Severity: sev, - Title: adv.Title, - URL: adv.URL, - Range: adv.Range, - Fixed: adv.Patched, - }) - result.Summary[sev]++ - } - - return result, nil -} diff --git a/internal/audit/parse_pnpm_test.go b/internal/audit/parse_pnpm_test.go deleted file mode 100644 index 205372c..0000000 --- a/internal/audit/parse_pnpm_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package audit - -import ( - "os" - "testing" -) - -func TestParsePnpm_Vulns(t *testing.T) { - data, err := os.ReadFile("testdata/pnpm_vulns.json") - if err != nil { - t.Fatal(err) - } - - result, err := parsePnpm(data) - if err != nil { - t.Fatal(err) - } - - if result.PM != "pnpm" { - t.Errorf("PM = %q, want %q", result.PM, "pnpm") - } - if len(result.Vulnerabilities) != 2 { - t.Fatalf("got %d vulns, want 2", len(result.Vulnerabilities)) - } - if result.Summary[SeverityHigh] != 1 { - t.Errorf("high = %d, want 1", result.Summary[SeverityHigh]) - } - if result.Summary[SeverityModerate] != 1 { - t.Errorf("moderate = %d, want 1", result.Summary[SeverityModerate]) - } - - // Verify fixed version is parsed. - for _, v := range result.Vulnerabilities { - if v.Name == "qs" { - if v.Fixed != ">=6.5.3" { - t.Errorf("fixed = %q, want %q", v.Fixed, ">=6.5.3") - } - } - } -} - -func TestParsePnpm_Clean(t *testing.T) { - data, err := os.ReadFile("testdata/pnpm_clean.json") - if err != nil { - t.Fatal(err) - } - - result, err := parsePnpm(data) - if err != nil { - t.Fatal(err) - } - - if len(result.Vulnerabilities) != 0 { - t.Errorf("got %d vulns, want 0", len(result.Vulnerabilities)) - } -} diff --git a/internal/audit/parse_yarn.go b/internal/audit/parse_yarn.go deleted file mode 100644 index a0bb128..0000000 --- a/internal/audit/parse_yarn.go +++ /dev/null @@ -1,105 +0,0 @@ -package audit - -import ( - "bufio" - "bytes" - "encoding/json" -) - -// --- Yarn Classic (v1) --- -// Yarn v1 outputs NDJSON lines. We care about lines with type "auditAdvisory". - -type yarnClassicLine struct { - Type string `json:"type"` - Data json.RawMessage `json:"data"` -} - -type yarnClassicAdvisory struct { - Advisory struct { - ModuleName string `json:"module_name"` - Severity string `json:"severity"` - Title string `json:"title"` - URL string `json:"url"` - Range string `json:"vulnerable_versions"` - Patched string `json:"patched_versions"` - } `json:"advisory"` -} - -func parseYarnClassic(data []byte) (*AuditResult, error) { - result := &AuditResult{ - Summary: make(map[Severity]int), - PM: "yarn", - } - - scanner := bufio.NewScanner(bytes.NewReader(data)) - for scanner.Scan() { - var line yarnClassicLine - if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { - continue // skip non-JSON lines - } - if line.Type != "auditAdvisory" { - continue - } - - var adv yarnClassicAdvisory - if err := json.Unmarshal(line.Data, &adv); err != nil { - continue - } - - sev := Severity(adv.Advisory.Severity) - result.Vulnerabilities = append(result.Vulnerabilities, Vulnerability{ - Name: adv.Advisory.ModuleName, - Severity: sev, - Title: adv.Advisory.Title, - URL: adv.Advisory.URL, - Range: adv.Advisory.Range, - Fixed: adv.Advisory.Patched, - }) - result.Summary[sev]++ - } - - return result, scanner.Err() -} - -// --- Yarn Berry (v2+) --- -// Yarn Berry `yarn npm audit --all --json` outputs a JSON object with advisories. - -type yarnBerryOutput struct { - Advisories map[string]yarnBerryAdvisory `json:"advisories"` -} - -type yarnBerryAdvisory struct { - ModuleName string `json:"module_name"` - Severity string `json:"severity"` - Title string `json:"title"` - URL string `json:"url"` - Range string `json:"vulnerable_versions"` - Patched string `json:"patched_versions"` -} - -func parseYarnBerry(data []byte) (*AuditResult, error) { - var out yarnBerryOutput - if err := json.Unmarshal(data, &out); err != nil { - return nil, err - } - - result := &AuditResult{ - Summary: make(map[Severity]int), - PM: "yarn", - } - - for _, adv := range out.Advisories { - sev := Severity(adv.Severity) - result.Vulnerabilities = append(result.Vulnerabilities, Vulnerability{ - Name: adv.ModuleName, - Severity: sev, - Title: adv.Title, - URL: adv.URL, - Range: adv.Range, - Fixed: adv.Patched, - }) - result.Summary[sev]++ - } - - return result, nil -} diff --git a/internal/audit/parse_yarn_test.go b/internal/audit/parse_yarn_test.go deleted file mode 100644 index f41ecd1..0000000 --- a/internal/audit/parse_yarn_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package audit - -import ( - "os" - "testing" -) - -func TestParseYarnClassic_Vulns(t *testing.T) { - data, err := os.ReadFile("testdata/yarn_classic_vulns.ndjson") - if err != nil { - t.Fatal(err) - } - - result, err := parseYarnClassic(data) - if err != nil { - t.Fatal(err) - } - - if result.PM != "yarn" { - t.Errorf("PM = %q, want %q", result.PM, "yarn") - } - if len(result.Vulnerabilities) != 2 { - t.Fatalf("got %d vulns, want 2", len(result.Vulnerabilities)) - } - if result.Summary[SeverityHigh] != 1 { - t.Errorf("high = %d, want 1", result.Summary[SeverityHigh]) - } - if result.Summary[SeverityModerate] != 1 { - t.Errorf("moderate = %d, want 1", result.Summary[SeverityModerate]) - } -} - -func TestParseYarnClassic_Clean(t *testing.T) { - data, err := os.ReadFile("testdata/yarn_classic_clean.ndjson") - if err != nil { - t.Fatal(err) - } - - result, err := parseYarnClassic(data) - if err != nil { - t.Fatal(err) - } - - if len(result.Vulnerabilities) != 0 { - t.Errorf("got %d vulns, want 0", len(result.Vulnerabilities)) - } -} - -func TestParseYarnBerry_Vulns(t *testing.T) { - data, err := os.ReadFile("testdata/yarn_berry_vulns.json") - if err != nil { - t.Fatal(err) - } - - result, err := parseYarnBerry(data) - if err != nil { - t.Fatal(err) - } - - if len(result.Vulnerabilities) != 1 { - t.Fatalf("got %d vulns, want 1", len(result.Vulnerabilities)) - } - - v := result.Vulnerabilities[0] - if v.Name != "node-fetch" { - t.Errorf("name = %q, want %q", v.Name, "node-fetch") - } - if v.Severity != SeverityHigh { - t.Errorf("severity = %q, want %q", v.Severity, SeverityHigh) - } -} - -func TestParseYarnBerry_Clean(t *testing.T) { - data, err := os.ReadFile("testdata/yarn_berry_clean.json") - if err != nil { - t.Fatal(err) - } - - result, err := parseYarnBerry(data) - if err != nil { - t.Fatal(err) - } - - if len(result.Vulnerabilities) != 0 { - t.Errorf("got %d vulns, want 0", len(result.Vulnerabilities)) - } -} diff --git a/internal/detector/detector.go b/internal/detector/detector.go index 850807c..162284b 100644 --- a/internal/detector/detector.go +++ b/internal/detector/detector.go @@ -4,31 +4,33 @@ import ( "fmt" "os" "path/filepath" + + "github.com/decampsrenan/spm/internal/ecosystem" ) -type PackageManager string +// lockFileMap is built from the registered ecosystems at init time. +var lockFileMap map[string]ecosystem.PackageManager -const ( - NPM PackageManager = "npm" - Yarn PackageManager = "yarn" - Pnpm PackageManager = "pnpm" - Bun PackageManager = "bun" -) +// manifestFiles is the set of manifest files across all ecosystems. +var manifestFiles map[string]bool -var lockFiles = map[string]PackageManager{ - "package-lock.json": NPM, - "yarn.lock": Yarn, - "pnpm-lock.yaml": Pnpm, - "bun.lock": Bun, - "bun.lockb": Bun, +func init() { + lockFileMap = make(map[string]ecosystem.PackageManager) + manifestFiles = make(map[string]bool) + for _, eco := range ecosystem.All() { + for _, lf := range eco.LockFiles() { + lockFileMap[lf] = eco.Name() + } + manifestFiles[eco.ManifestFile()] = true + } } type Detection struct { - PM PackageManager + PM ecosystem.PackageManager Dir string } -// ErrNoLockFile is returned when a package.json is found but no lock file exists. +// ErrNoLockFile is returned when a manifest is found but no lock file exists. type ErrNoLockFile struct { Dir string } @@ -37,8 +39,8 @@ func (e *ErrNoLockFile) Error() string { return fmt.Sprintf("no lock file found in %s", e.Dir) } -// Detect walks up from startDir looking for a directory containing package.json -// and at least one known lock file. It stops at $HOME. +// Detect walks up from startDir looking for a directory containing a manifest +// file and at least one known lock file. It stops at $HOME. // Returns all detected package managers in the first matching directory. func Detect(startDir string) ([]Detection, error) { home, err := os.UserHomeDir() @@ -47,15 +49,15 @@ func Detect(startDir string) ([]Detection, error) { } dir := startDir - var firstPackageJSONDir string + var firstManifestDir string for { - if hasFile(dir, "package.json") { - if firstPackageJSONDir == "" { - firstPackageJSONDir = dir + if hasManifest(dir) { + if firstManifestDir == "" { + firstManifestDir = dir } var detections []Detection - seen := make(map[PackageManager]bool) - for lock, pm := range lockFiles { + seen := make(map[ecosystem.PackageManager]bool) + for lock, pm := range lockFileMap { if hasFile(dir, lock) && !seen[pm] { seen[pm] = true detections = append(detections, Detection{PM: pm, Dir: dir}) @@ -77,26 +79,34 @@ func Detect(startDir string) ([]Detection, error) { dir = parent } - if firstPackageJSONDir != "" { - return nil, &ErrNoLockFile{Dir: firstPackageJSONDir} + if firstManifestDir != "" { + return nil, &ErrNoLockFile{Dir: firstManifestDir} } - return nil, fmt.Errorf("no package.json with a lock file found (searched up to %s)", home) + return nil, fmt.Errorf("no project manifest with a lock file found (searched up to %s)", home) } -// LockFileName returns the lock file name for the given package manager. -func LockFileName(pm PackageManager) string { - // Bun has two lock files (bun.lock and legacy bun.lockb) in the map. - // Go map iteration is non-deterministic, so we return the recommended - // modern format explicitly to avoid returning the legacy binary one. - if pm == Bun { - return "bun.lock" +// LockFileName returns the preferred lock file name for the given package manager. +func LockFileName(pm ecosystem.PackageManager) string { + eco := ecosystem.ForPM(pm) + if eco == nil { + return "" } - for name, p := range lockFiles { - if p == pm { - return name + locks := eco.LockFiles() + if len(locks) == 0 { + return "" + } + // Return the first (preferred) lock file. + return locks[0] +} + +// hasManifest checks if any known manifest file exists in the directory. +func hasManifest(dir string) bool { + for mf := range manifestFiles { + if hasFile(dir, mf) { + return true } } - return "" + return false } func hasFile(dir, name string) bool { diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go index c947b25..4ffcff0 100644 --- a/internal/detector/detector_test.go +++ b/internal/detector/detector_test.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/decampsrenan/spm/internal/ecosystem" ) func TestDetectNPM(t *testing.T) { @@ -16,7 +18,7 @@ func TestDetectNPM(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(dets) != 1 || dets[0].PM != NPM { + if len(dets) != 1 || dets[0].PM != ecosystem.NPM { t.Fatalf("expected npm, got %v", dets) } } @@ -30,7 +32,7 @@ func TestDetectYarn(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(dets) != 1 || dets[0].PM != Yarn { + if len(dets) != 1 || dets[0].PM != ecosystem.Yarn { t.Fatalf("expected yarn, got %v", dets) } } @@ -44,7 +46,7 @@ func TestDetectPnpm(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(dets) != 1 || dets[0].PM != Pnpm { + if len(dets) != 1 || dets[0].PM != ecosystem.Pnpm { t.Fatalf("expected pnpm, got %v", dets) } } @@ -58,7 +60,7 @@ func TestDetectBun(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(dets) != 1 || dets[0].PM != Bun { + if len(dets) != 1 || dets[0].PM != ecosystem.Bun { t.Fatalf("expected bun, got %v", dets) } } @@ -72,7 +74,7 @@ func TestDetectBunLegacy(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(dets) != 1 || dets[0].PM != Bun { + if len(dets) != 1 || dets[0].PM != ecosystem.Bun { t.Fatalf("expected bun, got %v", dets) } } @@ -87,7 +89,7 @@ func TestDetectBunBothLockFiles(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(dets) != 1 || dets[0].PM != Bun { + if len(dets) != 1 || dets[0].PM != ecosystem.Bun { t.Fatalf("expected single bun detection, got %v", dets) } } @@ -106,7 +108,7 @@ func TestDetectWalksUp(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(dets) != 1 || dets[0].PM != Yarn { + if len(dets) != 1 || dets[0].PM != ecosystem.Yarn { t.Fatalf("expected yarn from parent, got %v", dets) } if dets[0].Dir != root { @@ -162,7 +164,7 @@ func TestDetectWalksUpPastPackageJSONWithoutLockFile(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if len(dets) != 1 || dets[0].PM != Yarn { + if len(dets) != 1 || dets[0].PM != ecosystem.Yarn { t.Fatalf("expected yarn from root, got %v", dets) } if dets[0].Dir != root { @@ -181,14 +183,14 @@ func TestDetectNoPackageJSON(t *testing.T) { func TestLockFileName(t *testing.T) { tests := []struct { - pm PackageManager + pm ecosystem.PackageManager want string }{ - {NPM, "package-lock.json"}, - {Yarn, "yarn.lock"}, - {Pnpm, "pnpm-lock.yaml"}, - {Bun, "bun.lock"}, - {PackageManager("unknown"), ""}, + {ecosystem.NPM, "package-lock.json"}, + {ecosystem.Yarn, "yarn.lock"}, + {ecosystem.Pnpm, "pnpm-lock.yaml"}, + {ecosystem.Bun, "bun.lock"}, + {ecosystem.PackageManager("unknown"), ""}, } for _, tt := range tests { t.Run(string(tt.pm), func(t *testing.T) { diff --git a/internal/ecosystem/bun.go b/internal/ecosystem/bun.go new file mode 100644 index 0000000..18b68d4 --- /dev/null +++ b/internal/ecosystem/bun.go @@ -0,0 +1,38 @@ +package ecosystem + +import ( + "fmt" + + "github.com/decampsrenan/spm/internal/audit" +) + +type bunEcosystem struct{} + +func (b *bunEcosystem) Name() PackageManager { return Bun } +func (b *bunEcosystem) ManifestFile() string { return "package.json" } +func (b *bunEcosystem) LockFiles() []string { return []string{"bun.lock", "bun.lockb"} } +func (b *bunEcosystem) ArtifactDirs() []string { return []string{"node_modules"} } +func (b *bunEcosystem) HasCommand(_ string) bool { return true } + +func (b *bunEcosystem) Resolve(cmd string, args []string) []string { + switch cmd { + case "init": + return append([]string{"bun", "init"}, args...) + case "install", "i": + return append([]string{"bun", "install"}, args...) + case "add": + return append([]string{"bun", "add"}, args...) + case "remove": + return append([]string{"bun", "remove"}, args...) + default: + return append([]string{"bun", cmd}, args...) + } +} + +func (b *bunEcosystem) BuildAuditCommand(_ string, _ audit.Options) ([]string, error) { + return nil, fmt.Errorf("bun does not have a built-in audit command") +} + +func (b *bunEcosystem) ParseAuditOutput(_ string, _ []byte) (*audit.AuditResult, error) { + return nil, fmt.Errorf("bun does not have a built-in audit command") +} diff --git a/internal/ecosystem/bun_test.go b/internal/ecosystem/bun_test.go new file mode 100644 index 0000000..ea8c36a --- /dev/null +++ b/internal/ecosystem/bun_test.go @@ -0,0 +1,68 @@ +package ecosystem + +import ( + "reflect" + "testing" + + "github.com/decampsrenan/spm/internal/audit" +) + +func TestBunResolve(t *testing.T) { + eco := &bunEcosystem{} + tests := []struct { + name string + cmd string + args []string + want []string + }{ + {"init", "init", nil, []string{"bun", "init"}}, + {"install", "install", nil, []string{"bun", "install"}}, + {"install-shorthand", "i", nil, []string{"bun", "install"}}, + {"add", "add", []string{"foo"}, []string{"bun", "add", "foo"}}, + {"remove", "remove", []string{"foo"}, []string{"bun", "remove", "foo"}}, + {"arbitrary", "dev", nil, []string{"bun", "dev"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := eco.Resolve(tt.cmd, tt.args) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestBunBuildAuditCommand(t *testing.T) { + eco := &bunEcosystem{} + _, err := eco.BuildAuditCommand("", audit.Options{}) + if err == nil { + t.Error("expected error for bun audit, got nil") + } +} + +func TestBunParseAuditOutput(t *testing.T) { + eco := &bunEcosystem{} + _, err := eco.ParseAuditOutput("", []byte("{}")) + if err == nil { + t.Error("expected error for bun parse, got nil") + } +} + +func TestBunMetadata(t *testing.T) { + eco := &bunEcosystem{} + if eco.Name() != Bun { + t.Errorf("Name() = %q, want %q", eco.Name(), Bun) + } + if eco.ManifestFile() != "package.json" { + t.Errorf("ManifestFile() = %q, want %q", eco.ManifestFile(), "package.json") + } + if !reflect.DeepEqual(eco.LockFiles(), []string{"bun.lock", "bun.lockb"}) { + t.Errorf("LockFiles() = %v", eco.LockFiles()) + } + if !reflect.DeepEqual(eco.ArtifactDirs(), []string{"node_modules"}) { + t.Errorf("ArtifactDirs() = %v", eco.ArtifactDirs()) + } + if !eco.HasCommand("anything") { + t.Error("HasCommand() should return true") + } +} diff --git a/internal/ecosystem/ecosystem.go b/internal/ecosystem/ecosystem.go new file mode 100644 index 0000000..d0b762c --- /dev/null +++ b/internal/ecosystem/ecosystem.go @@ -0,0 +1,44 @@ +package ecosystem + +import "github.com/decampsrenan/spm/internal/audit" + +// PackageManager identifies a package manager by name. +type PackageManager string + +const ( + NPM PackageManager = "npm" + Yarn PackageManager = "yarn" + Pnpm PackageManager = "pnpm" + Bun PackageManager = "bun" +) + +// Ecosystem abstracts the behavior of a package manager ecosystem. +// Each implementation knows how to resolve commands, build audit commands, +// parse audit output, and describe its manifest/lock/artifact files. +type Ecosystem interface { + // Name returns the package manager identifier. + Name() PackageManager + + // ManifestFile returns the main project manifest (e.g. "package.json"). + ManifestFile() string + + // LockFiles returns all possible lock file names for this ecosystem. + LockFiles() []string + + // ArtifactDirs returns directories that can be safely cleaned + // (e.g. "node_modules", "target"). + ArtifactDirs() []string + + // Resolve translates an spm command + args into the native PM command. + // Returns the full command slice (binary + args). + Resolve(cmd string, args []string) []string + + // HasCommand reports whether this ecosystem supports the given spm command. + HasCommand(cmd string) bool + + // BuildAuditCommand returns the command to run a security audit. + BuildAuditCommand(dir string, opts audit.Options) ([]string, error) + + // ParseAuditOutput parses the raw audit command output into a normalized result. + ParseAuditOutput(dir string, data []byte) (*audit.AuditResult, error) +} diff --git a/internal/ecosystem/npm.go b/internal/ecosystem/npm.go new file mode 100644 index 0000000..30c9675 --- /dev/null +++ b/internal/ecosystem/npm.go @@ -0,0 +1,102 @@ +package ecosystem + +import ( + "encoding/json" + + "github.com/decampsrenan/spm/internal/audit" +) + +type npmEcosystem struct{} + +func (n *npmEcosystem) Name() PackageManager { return NPM } +func (n *npmEcosystem) ManifestFile() string { return "package.json" } +func (n *npmEcosystem) LockFiles() []string { return []string{"package-lock.json"} } +func (n *npmEcosystem) ArtifactDirs() []string { return []string{"node_modules"} } +func (n *npmEcosystem) HasCommand(_ string) bool { return true } + +func (n *npmEcosystem) Resolve(cmd string, args []string) []string { + switch cmd { + case "init": + return append([]string{"npm", "init", "-y"}, args...) + case "install", "i": + return append([]string{"npm", "install"}, args...) + case "add": + return append([]string{"npm", "install"}, args...) + case "remove": + return append([]string{"npm", "uninstall"}, args...) + default: + return append([]string{"npm", "run", cmd}, args...) + } +} + +func (n *npmEcosystem) BuildAuditCommand(_ string, opts audit.Options) ([]string, error) { + args := []string{"npm", "audit", "--json"} + if opts.ProdOnly { + args = append(args, "--omit=dev") + } + return args, nil +} + +func (n *npmEcosystem) ParseAuditOutput(_ string, data []byte) (*audit.AuditResult, error) { + var out npmAuditOutput + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + + result := &audit.AuditResult{ + Summary: make(map[audit.Severity]int), + PM: "npm", + } + + for name, v := range out.Vulnerabilities { + sev := audit.Severity(v.Severity) + title := extractNPMTitle(v.Via) + url := extractNPMURL(v.Via) + result.Vulnerabilities = append(result.Vulnerabilities, audit.Vulnerability{ + Name: name, + Severity: sev, + Title: title, + URL: url, + Range: v.Range, + }) + result.Summary[sev]++ + } + + return result, nil +} + +// npm audit JSON types + +type npmAuditOutput struct { + Vulnerabilities map[string]npmVuln `json:"vulnerabilities"` +} + +type npmVuln struct { + Name string `json:"name"` + Severity string `json:"severity"` + Via []any `json:"via"` + Range string `json:"range"` + FixAvail any `json:"fixAvailable"` +} + +func extractNPMTitle(via []any) string { + for _, v := range via { + if m, ok := v.(map[string]any); ok { + if t, ok := m["title"].(string); ok { + return t + } + } + } + return "" +} + +func extractNPMURL(via []any) string { + for _, v := range via { + if m, ok := v.(map[string]any); ok { + if u, ok := m["url"].(string); ok { + return u + } + } + } + return "" +} diff --git a/internal/ecosystem/npm_test.go b/internal/ecosystem/npm_test.go new file mode 100644 index 0000000..4c087a1 --- /dev/null +++ b/internal/ecosystem/npm_test.go @@ -0,0 +1,94 @@ +package ecosystem + +import ( + "os" + "reflect" + "testing" + + "github.com/decampsrenan/spm/internal/audit" +) + +func TestNPMBuildAuditCommand(t *testing.T) { + eco := &npmEcosystem{} + tests := []struct { + name string + opts audit.Options + want []string + }{ + {"default", audit.Options{}, []string{"npm", "audit", "--json"}}, + {"prod-only", audit.Options{ProdOnly: true}, []string{"npm", "audit", "--json", "--omit=dev"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := eco.BuildAuditCommand("", tt.opts) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestNPMParseAuditOutput_Vulns(t *testing.T) { + eco := &npmEcosystem{} + data, err := os.ReadFile("testdata/npm_vulns.json") + if err != nil { + t.Fatal(err) + } + + result, err := eco.ParseAuditOutput("", data) + if err != nil { + t.Fatal(err) + } + + if result.PM != "npm" { + t.Errorf("PM = %q, want %q", result.PM, "npm") + } + if len(result.Vulnerabilities) != 3 { + t.Fatalf("got %d vulns, want 3", len(result.Vulnerabilities)) + } + if result.Summary[audit.SeverityCritical] != 1 { + t.Errorf("critical = %d, want 1", result.Summary[audit.SeverityCritical]) + } + if result.Summary[audit.SeverityHigh] != 1 { + t.Errorf("high = %d, want 1", result.Summary[audit.SeverityHigh]) + } + if result.Summary[audit.SeverityLow] != 1 { + t.Errorf("low = %d, want 1", result.Summary[audit.SeverityLow]) + } + + found := false + for _, v := range result.Vulnerabilities { + if v.Name == "minimist" { + found = true + if v.Title != "Prototype Pollution" { + t.Errorf("title = %q, want %q", v.Title, "Prototype Pollution") + } + if v.URL == "" { + t.Error("expected URL to be set") + } + } + } + if !found { + t.Error("minimist vulnerability not found") + } +} + +func TestNPMParseAuditOutput_Clean(t *testing.T) { + eco := &npmEcosystem{} + data, err := os.ReadFile("testdata/npm_clean.json") + if err != nil { + t.Fatal(err) + } + + result, err := eco.ParseAuditOutput("", data) + if err != nil { + t.Fatal(err) + } + + if len(result.Vulnerabilities) != 0 { + t.Errorf("got %d vulns, want 0", len(result.Vulnerabilities)) + } +} diff --git a/internal/ecosystem/pnpm.go b/internal/ecosystem/pnpm.go new file mode 100644 index 0000000..a732c70 --- /dev/null +++ b/internal/ecosystem/pnpm.go @@ -0,0 +1,78 @@ +package ecosystem + +import ( + "encoding/json" + + "github.com/decampsrenan/spm/internal/audit" +) + +type pnpmEcosystem struct{} + +func (p *pnpmEcosystem) Name() PackageManager { return Pnpm } +func (p *pnpmEcosystem) ManifestFile() string { return "package.json" } +func (p *pnpmEcosystem) LockFiles() []string { return []string{"pnpm-lock.yaml"} } +func (p *pnpmEcosystem) ArtifactDirs() []string { return []string{"node_modules"} } +func (p *pnpmEcosystem) HasCommand(_ string) bool { return true } + +func (p *pnpmEcosystem) Resolve(cmd string, args []string) []string { + switch cmd { + case "init": + return append([]string{"pnpm", "init"}, args...) + case "install", "i": + return append([]string{"pnpm", "install"}, args...) + case "add": + return append([]string{"pnpm", "add"}, args...) + case "remove": + return append([]string{"pnpm", "remove"}, args...) + default: + return append([]string{"pnpm", cmd}, args...) + } +} + +func (p *pnpmEcosystem) BuildAuditCommand(_ string, opts audit.Options) ([]string, error) { + args := []string{"pnpm", "audit", "--json"} + if opts.ProdOnly { + args = append(args, "--prod") + } + return args, nil +} + +func (p *pnpmEcosystem) ParseAuditOutput(_ string, data []byte) (*audit.AuditResult, error) { + var out pnpmAuditOutput + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + + result := &audit.AuditResult{ + Summary: make(map[audit.Severity]int), + PM: "pnpm", + } + + for _, adv := range out.Advisories { + sev := audit.Severity(adv.Severity) + result.Vulnerabilities = append(result.Vulnerabilities, audit.Vulnerability{ + Name: adv.ModuleName, + Severity: sev, + Title: adv.Title, + URL: adv.URL, + Range: adv.Range, + Fixed: adv.Patched, + }) + result.Summary[sev]++ + } + + return result, nil +} + +type pnpmAuditOutput struct { + Advisories map[string]pnpmAdvisory `json:"advisories"` +} + +type pnpmAdvisory struct { + ModuleName string `json:"module_name"` + Severity string `json:"severity"` + Title string `json:"title"` + URL string `json:"url"` + Range string `json:"vulnerable_versions"` + Patched string `json:"patched_versions"` +} diff --git a/internal/ecosystem/pnpm_test.go b/internal/ecosystem/pnpm_test.go new file mode 100644 index 0000000..2efba88 --- /dev/null +++ b/internal/ecosystem/pnpm_test.go @@ -0,0 +1,83 @@ +package ecosystem + +import ( + "os" + "reflect" + "testing" + + "github.com/decampsrenan/spm/internal/audit" +) + +func TestPnpmBuildAuditCommand(t *testing.T) { + eco := &pnpmEcosystem{} + tests := []struct { + name string + opts audit.Options + want []string + }{ + {"default", audit.Options{}, []string{"pnpm", "audit", "--json"}}, + {"prod-only", audit.Options{ProdOnly: true}, []string{"pnpm", "audit", "--json", "--prod"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := eco.BuildAuditCommand("", tt.opts) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestPnpmParseAuditOutput_Vulns(t *testing.T) { + eco := &pnpmEcosystem{} + data, err := os.ReadFile("testdata/pnpm_vulns.json") + if err != nil { + t.Fatal(err) + } + + result, err := eco.ParseAuditOutput("", data) + if err != nil { + t.Fatal(err) + } + + if result.PM != "pnpm" { + t.Errorf("PM = %q, want %q", result.PM, "pnpm") + } + if len(result.Vulnerabilities) != 2 { + t.Fatalf("got %d vulns, want 2", len(result.Vulnerabilities)) + } + if result.Summary[audit.SeverityHigh] != 1 { + t.Errorf("high = %d, want 1", result.Summary[audit.SeverityHigh]) + } + if result.Summary[audit.SeverityModerate] != 1 { + t.Errorf("moderate = %d, want 1", result.Summary[audit.SeverityModerate]) + } + + for _, v := range result.Vulnerabilities { + if v.Name == "qs" { + if v.Fixed != ">=6.5.3" { + t.Errorf("fixed = %q, want %q", v.Fixed, ">=6.5.3") + } + } + } +} + +func TestPnpmParseAuditOutput_Clean(t *testing.T) { + eco := &pnpmEcosystem{} + data, err := os.ReadFile("testdata/pnpm_clean.json") + if err != nil { + t.Fatal(err) + } + + result, err := eco.ParseAuditOutput("", data) + if err != nil { + t.Fatal(err) + } + + if len(result.Vulnerabilities) != 0 { + t.Errorf("got %d vulns, want 0", len(result.Vulnerabilities)) + } +} diff --git a/internal/ecosystem/registry.go b/internal/ecosystem/registry.go new file mode 100644 index 0000000..2f13b90 --- /dev/null +++ b/internal/ecosystem/registry.go @@ -0,0 +1,29 @@ +package ecosystem + +var all []Ecosystem + +var byPM map[PackageManager]Ecosystem + +func init() { + all = []Ecosystem{ + &npmEcosystem{}, + &yarnEcosystem{}, + &pnpmEcosystem{}, + &bunEcosystem{}, + } + + byPM = make(map[PackageManager]Ecosystem, len(all)) + for _, e := range all { + byPM[e.Name()] = e + } +} + +// All returns every registered ecosystem. +func All() []Ecosystem { + return all +} + +// ForPM returns the ecosystem for a given package manager, or nil if unknown. +func ForPM(pm PackageManager) Ecosystem { + return byPM[pm] +} diff --git a/internal/audit/testdata/npm_clean.json b/internal/ecosystem/testdata/npm_clean.json similarity index 100% rename from internal/audit/testdata/npm_clean.json rename to internal/ecosystem/testdata/npm_clean.json diff --git a/internal/audit/testdata/npm_vulns.json b/internal/ecosystem/testdata/npm_vulns.json similarity index 100% rename from internal/audit/testdata/npm_vulns.json rename to internal/ecosystem/testdata/npm_vulns.json diff --git a/internal/audit/testdata/pnpm_clean.json b/internal/ecosystem/testdata/pnpm_clean.json similarity index 100% rename from internal/audit/testdata/pnpm_clean.json rename to internal/ecosystem/testdata/pnpm_clean.json diff --git a/internal/audit/testdata/pnpm_vulns.json b/internal/ecosystem/testdata/pnpm_vulns.json similarity index 100% rename from internal/audit/testdata/pnpm_vulns.json rename to internal/ecosystem/testdata/pnpm_vulns.json diff --git a/internal/audit/testdata/yarn_berry_clean.json b/internal/ecosystem/testdata/yarn_berry_clean.json similarity index 100% rename from internal/audit/testdata/yarn_berry_clean.json rename to internal/ecosystem/testdata/yarn_berry_clean.json diff --git a/internal/audit/testdata/yarn_berry_vulns.json b/internal/ecosystem/testdata/yarn_berry_vulns.json similarity index 100% rename from internal/audit/testdata/yarn_berry_vulns.json rename to internal/ecosystem/testdata/yarn_berry_vulns.json diff --git a/internal/audit/testdata/yarn_classic_clean.ndjson b/internal/ecosystem/testdata/yarn_classic_clean.ndjson similarity index 100% rename from internal/audit/testdata/yarn_classic_clean.ndjson rename to internal/ecosystem/testdata/yarn_classic_clean.ndjson diff --git a/internal/audit/testdata/yarn_classic_vulns.ndjson b/internal/ecosystem/testdata/yarn_classic_vulns.ndjson similarity index 100% rename from internal/audit/testdata/yarn_classic_vulns.ndjson rename to internal/ecosystem/testdata/yarn_classic_vulns.ndjson diff --git a/internal/ecosystem/yarn.go b/internal/ecosystem/yarn.go new file mode 100644 index 0000000..375fa7a --- /dev/null +++ b/internal/ecosystem/yarn.go @@ -0,0 +1,193 @@ +package ecosystem + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/decampsrenan/spm/internal/audit" +) + +type yarnEcosystem struct{} + +func (y *yarnEcosystem) Name() PackageManager { return Yarn } +func (y *yarnEcosystem) ManifestFile() string { return "package.json" } +func (y *yarnEcosystem) LockFiles() []string { return []string{"yarn.lock"} } +func (y *yarnEcosystem) ArtifactDirs() []string { return []string{"node_modules"} } +func (y *yarnEcosystem) HasCommand(_ string) bool { return true } + +func (y *yarnEcosystem) Resolve(cmd string, args []string) []string { + switch cmd { + case "init": + // yarn classic needs -y; yarn Berry ignores it harmlessly + return append([]string{"yarn", "init", "-y"}, args...) + case "install", "i": + return append([]string{"yarn", "install"}, args...) + case "add": + return append([]string{"yarn", "add"}, args...) + case "remove": + return append([]string{"yarn", "remove"}, args...) + default: + // yarn doesn't need explicit "run" + return append([]string{"yarn", cmd}, args...) + } +} + +func (y *yarnEcosystem) BuildAuditCommand(dir string, opts audit.Options) ([]string, error) { + version, err := detectYarnVersion(dir) + if err != nil { + return nil, fmt.Errorf("cannot detect yarn version: %w", err) + } + if version >= 2 { + return buildYarnBerry(opts), nil + } + return buildYarnClassic(opts), nil +} + +func (y *yarnEcosystem) ParseAuditOutput(dir string, data []byte) (*audit.AuditResult, error) { + version, err := detectYarnVersion(dir) + if err != nil { + // Fall back to classic parse if we can't detect version. + return parseYarnClassic(data) + } + if version >= 2 { + return parseYarnBerry(data) + } + return parseYarnClassic(data) +} + +// detectYarnVersion runs `yarn --version` in the given directory and returns +// the major version number. +func detectYarnVersion(dir string) (int, error) { + cmd := exec.Command("yarn", "--version") + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + return 0, err + } + version := strings.TrimSpace(string(out)) + if len(version) == 0 { + return 0, fmt.Errorf("empty yarn version output") + } + major := version[0] + if major < '0' || major > '9' { + return 0, fmt.Errorf("unexpected yarn version format: %s", version) + } + return int(major - '0'), nil +} + +func buildYarnClassic(opts audit.Options) []string { + args := []string{"yarn", "audit", "--json"} + if opts.ProdOnly { + args = append(args, "--groups", "dependencies") + } + return args +} + +func buildYarnBerry(opts audit.Options) []string { + args := []string{"yarn", "npm", "audit", "--all", "--json"} + if opts.ProdOnly { + // Yarn Berry doesn't have a direct --prod flag for audit; + // we filter post-parse. + } + return args +} + +// --- Yarn Classic (v1) --- + +type yarnClassicLine struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +type yarnClassicAdvisory struct { + Advisory struct { + ModuleName string `json:"module_name"` + Severity string `json:"severity"` + Title string `json:"title"` + URL string `json:"url"` + Range string `json:"vulnerable_versions"` + Patched string `json:"patched_versions"` + } `json:"advisory"` +} + +func parseYarnClassic(data []byte) (*audit.AuditResult, error) { + result := &audit.AuditResult{ + Summary: make(map[audit.Severity]int), + PM: "yarn", + } + + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + var line yarnClassicLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + continue + } + if line.Type != "auditAdvisory" { + continue + } + + var adv yarnClassicAdvisory + if err := json.Unmarshal(line.Data, &adv); err != nil { + continue + } + + sev := audit.Severity(adv.Advisory.Severity) + result.Vulnerabilities = append(result.Vulnerabilities, audit.Vulnerability{ + Name: adv.Advisory.ModuleName, + Severity: sev, + Title: adv.Advisory.Title, + URL: adv.Advisory.URL, + Range: adv.Advisory.Range, + Fixed: adv.Advisory.Patched, + }) + result.Summary[sev]++ + } + + return result, scanner.Err() +} + +// --- Yarn Berry (v2+) --- + +type yarnBerryOutput struct { + Advisories map[string]yarnBerryAdvisory `json:"advisories"` +} + +type yarnBerryAdvisory struct { + ModuleName string `json:"module_name"` + Severity string `json:"severity"` + Title string `json:"title"` + URL string `json:"url"` + Range string `json:"vulnerable_versions"` + Patched string `json:"patched_versions"` +} + +func parseYarnBerry(data []byte) (*audit.AuditResult, error) { + var out yarnBerryOutput + if err := json.Unmarshal(data, &out); err != nil { + return nil, err + } + + result := &audit.AuditResult{ + Summary: make(map[audit.Severity]int), + PM: "yarn", + } + + for _, adv := range out.Advisories { + sev := audit.Severity(adv.Severity) + result.Vulnerabilities = append(result.Vulnerabilities, audit.Vulnerability{ + Name: adv.ModuleName, + Severity: sev, + Title: adv.Title, + URL: adv.URL, + Range: adv.Range, + Fixed: adv.Patched, + }) + result.Summary[sev]++ + } + + return result, nil +} diff --git a/internal/ecosystem/yarn_test.go b/internal/ecosystem/yarn_test.go new file mode 100644 index 0000000..19ab22b --- /dev/null +++ b/internal/ecosystem/yarn_test.go @@ -0,0 +1,117 @@ +package ecosystem + +import ( + "os" + "reflect" + "testing" + + "github.com/decampsrenan/spm/internal/audit" +) + +func TestYarnBuildAuditCommandClassic(t *testing.T) { + tests := []struct { + name string + opts audit.Options + want []string + }{ + {"default", audit.Options{}, []string{"yarn", "audit", "--json"}}, + {"prod-only", audit.Options{ProdOnly: true}, []string{"yarn", "audit", "--json", "--groups", "dependencies"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildYarnClassic(tt.opts) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestYarnBuildAuditCommandBerry(t *testing.T) { + got := buildYarnBerry(audit.Options{}) + want := []string{"yarn", "npm", "audit", "--all", "--json"} + if !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestYarnClassicParseAuditOutput_Vulns(t *testing.T) { + data, err := os.ReadFile("testdata/yarn_classic_vulns.ndjson") + if err != nil { + t.Fatal(err) + } + + result, err := parseYarnClassic(data) + if err != nil { + t.Fatal(err) + } + + if result.PM != "yarn" { + t.Errorf("PM = %q, want %q", result.PM, "yarn") + } + if len(result.Vulnerabilities) != 2 { + t.Fatalf("got %d vulns, want 2", len(result.Vulnerabilities)) + } + if result.Summary[audit.SeverityHigh] != 1 { + t.Errorf("high = %d, want 1", result.Summary[audit.SeverityHigh]) + } + if result.Summary[audit.SeverityModerate] != 1 { + t.Errorf("moderate = %d, want 1", result.Summary[audit.SeverityModerate]) + } +} + +func TestYarnClassicParseAuditOutput_Clean(t *testing.T) { + data, err := os.ReadFile("testdata/yarn_classic_clean.ndjson") + if err != nil { + t.Fatal(err) + } + + result, err := parseYarnClassic(data) + if err != nil { + t.Fatal(err) + } + + if len(result.Vulnerabilities) != 0 { + t.Errorf("got %d vulns, want 0", len(result.Vulnerabilities)) + } +} + +func TestYarnBerryParseAuditOutput_Vulns(t *testing.T) { + data, err := os.ReadFile("testdata/yarn_berry_vulns.json") + if err != nil { + t.Fatal(err) + } + + result, err := parseYarnBerry(data) + if err != nil { + t.Fatal(err) + } + + if len(result.Vulnerabilities) != 1 { + t.Fatalf("got %d vulns, want 1", len(result.Vulnerabilities)) + } + + v := result.Vulnerabilities[0] + if v.Name != "node-fetch" { + t.Errorf("name = %q, want %q", v.Name, "node-fetch") + } + if v.Severity != audit.SeverityHigh { + t.Errorf("severity = %q, want %q", v.Severity, audit.SeverityHigh) + } +} + +func TestYarnBerryParseAuditOutput_Clean(t *testing.T) { + data, err := os.ReadFile("testdata/yarn_berry_clean.json") + if err != nil { + t.Fatal(err) + } + + result, err := parseYarnBerry(data) + if err != nil { + t.Fatal(err) + } + + if len(result.Vulnerabilities) != 0 { + t.Errorf("got %d vulns, want 0", len(result.Vulnerabilities)) + } +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 491f584..d0eb508 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -8,6 +8,7 @@ import ( "github.com/mattn/go-isatty" "github.com/decampsrenan/spm/internal/detector" + "github.com/decampsrenan/spm/internal/ecosystem" "github.com/decampsrenan/spm/internal/ui" ) @@ -115,16 +116,16 @@ func SelectScript(scriptNames []string, scriptCmds []string) (string, error) { } // SelectPM asks the user to pick a package manager (used by spm init). -func SelectPM() (detector.PackageManager, error) { +func SelectPM() (ecosystem.PackageManager, error) { if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) { return "", fmt.Errorf("no package manager specified and stdin is not a TTY — pass it as argument: spm init ") } options := []huh.Option[string]{ - huh.NewOption(string(detector.NPM), string(detector.NPM)), - huh.NewOption(string(detector.Yarn), string(detector.Yarn)), - huh.NewOption(string(detector.Pnpm), string(detector.Pnpm)), - huh.NewOption(string(detector.Bun), string(detector.Bun)), + huh.NewOption(string(ecosystem.NPM), string(ecosystem.NPM)), + huh.NewOption(string(ecosystem.Yarn), string(ecosystem.Yarn)), + huh.NewOption(string(ecosystem.Pnpm), string(ecosystem.Pnpm)), + huh.NewOption(string(ecosystem.Bun), string(ecosystem.Bun)), } var choice string @@ -138,7 +139,7 @@ func SelectPM() (detector.PackageManager, error) { return "", err } - return detector.PackageManager(choice), nil + return ecosystem.PackageManager(choice), nil } // SelectFromAll asks the user to pick a package manager when no lock file is found. @@ -148,10 +149,10 @@ func SelectFromAll(projectDir string) (detector.Detection, error) { } options := []huh.Option[string]{ - huh.NewOption(string(detector.NPM), string(detector.NPM)), - huh.NewOption(string(detector.Yarn), string(detector.Yarn)), - huh.NewOption(string(detector.Pnpm), string(detector.Pnpm)), - huh.NewOption(string(detector.Bun), string(detector.Bun)), + huh.NewOption(string(ecosystem.NPM), string(ecosystem.NPM)), + huh.NewOption(string(ecosystem.Yarn), string(ecosystem.Yarn)), + huh.NewOption(string(ecosystem.Pnpm), string(ecosystem.Pnpm)), + huh.NewOption(string(ecosystem.Bun), string(ecosystem.Bun)), } var choice string @@ -165,5 +166,5 @@ func SelectFromAll(projectDir string) (detector.Detection, error) { return detector.Detection{}, err } - return detector.Detection{PM: detector.PackageManager(choice), Dir: projectDir}, nil + return detector.Detection{PM: ecosystem.PackageManager(choice), Dir: projectDir}, nil } diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go index 4d27c1d..7e238c8 100644 --- a/internal/prompt/prompt_test.go +++ b/internal/prompt/prompt_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/decampsrenan/spm/internal/detector" + "github.com/decampsrenan/spm/internal/ecosystem" ) func TestConfirmNonTTY(t *testing.T) { @@ -17,8 +18,8 @@ func TestConfirmNonTTY(t *testing.T) { func TestSelectNonTTY(t *testing.T) { // In a test environment, stdin is not a TTY, so Select should return an error. detections := []detector.Detection{ - {PM: detector.NPM, Dir: "/tmp"}, - {PM: detector.Yarn, Dir: "/tmp"}, + {PM: ecosystem.NPM, Dir: "/tmp"}, + {PM: ecosystem.Yarn, Dir: "/tmp"}, } _, err := Select(detections) diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index a136423..6c6f7e0 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -1,54 +1,15 @@ package resolver -import "github.com/decampsrenan/spm/internal/detector" +import "github.com/decampsrenan/spm/internal/ecosystem" // Resolve translates an spm command + args into the actual package manager command. // command is the spm verb (install, add, or a script name). // args are extra arguments (package names, flags, etc.). -func Resolve(pm detector.PackageManager, command string, args []string) []string { - bin := string(pm) - - switch command { - case "init": - switch pm { - case detector.Pnpm, detector.Bun: - // pnpm and bun init are already non-interactive - return append([]string{bin, "init"}, args...) - case detector.Yarn: - // yarn classic needs -y; yarn Berry ignores it harmlessly - return append([]string{bin, "init", "-y"}, args...) - default: - // npm needs -y for non-interactive init - return append([]string{bin, "init", "-y"}, args...) - } - - case "install", "i": - return append([]string{bin, "install"}, args...) - - case "add": - switch pm { - case detector.NPM: - return append([]string{bin, "install"}, args...) - default: - return append([]string{bin, "add"}, args...) - } - - case "remove": - switch pm { - case detector.NPM: - return append([]string{bin, "uninstall"}, args...) - default: - return append([]string{bin, "remove"}, args...) - } - - default: - // Fallback: treat as a script run - switch pm { - case detector.NPM: - return append([]string{bin, "run", command}, args...) - default: - // yarn, pnpm, and bun don't need explicit "run" - return append([]string{bin, command}, args...) - } +func Resolve(pm ecosystem.PackageManager, command string, args []string) []string { + eco := ecosystem.ForPM(pm) + if eco == nil { + // Unknown PM — best effort: use PM name as binary. + return append([]string{string(pm), command}, args...) } + return eco.Resolve(command, args) } diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go index 70a025a..b8cf2d4 100644 --- a/internal/resolver/resolver_test.go +++ b/internal/resolver/resolver_test.go @@ -4,18 +4,18 @@ import ( "reflect" "testing" - "github.com/decampsrenan/spm/internal/detector" + "github.com/decampsrenan/spm/internal/ecosystem" ) func TestResolveInstall(t *testing.T) { tests := []struct { - pm detector.PackageManager + pm ecosystem.PackageManager want []string }{ - {detector.NPM, []string{"npm", "install"}}, - {detector.Yarn, []string{"yarn", "install"}}, - {detector.Pnpm, []string{"pnpm", "install"}}, - {detector.Bun, []string{"bun", "install"}}, + {ecosystem.NPM, []string{"npm", "install"}}, + {ecosystem.Yarn, []string{"yarn", "install"}}, + {ecosystem.Pnpm, []string{"pnpm", "install"}}, + {ecosystem.Bun, []string{"bun", "install"}}, } for _, tt := range tests { got := Resolve(tt.pm, "install", nil) @@ -27,14 +27,14 @@ func TestResolveInstall(t *testing.T) { func TestResolveAdd(t *testing.T) { tests := []struct { - pm detector.PackageManager + pm ecosystem.PackageManager args []string want []string }{ - {detector.NPM, []string{"react"}, []string{"npm", "install", "react"}}, - {detector.Yarn, []string{"react"}, []string{"yarn", "add", "react"}}, - {detector.Pnpm, []string{"react"}, []string{"pnpm", "add", "react"}}, - {detector.Bun, []string{"react"}, []string{"bun", "add", "react"}}, + {ecosystem.NPM, []string{"react"}, []string{"npm", "install", "react"}}, + {ecosystem.Yarn, []string{"react"}, []string{"yarn", "add", "react"}}, + {ecosystem.Pnpm, []string{"react"}, []string{"pnpm", "add", "react"}}, + {ecosystem.Bun, []string{"react"}, []string{"bun", "add", "react"}}, } for _, tt := range tests { got := Resolve(tt.pm, "add", tt.args) @@ -46,14 +46,14 @@ func TestResolveAdd(t *testing.T) { func TestResolveRemove(t *testing.T) { tests := []struct { - pm detector.PackageManager + pm ecosystem.PackageManager args []string want []string }{ - {detector.NPM, []string{"react"}, []string{"npm", "uninstall", "react"}}, - {detector.Yarn, []string{"react"}, []string{"yarn", "remove", "react"}}, - {detector.Pnpm, []string{"react"}, []string{"pnpm", "remove", "react"}}, - {detector.Bun, []string{"react"}, []string{"bun", "remove", "react"}}, + {ecosystem.NPM, []string{"react"}, []string{"npm", "uninstall", "react"}}, + {ecosystem.Yarn, []string{"react"}, []string{"yarn", "remove", "react"}}, + {ecosystem.Pnpm, []string{"react"}, []string{"pnpm", "remove", "react"}}, + {ecosystem.Bun, []string{"react"}, []string{"bun", "remove", "react"}}, } for _, tt := range tests { got := Resolve(tt.pm, "remove", tt.args) @@ -64,7 +64,7 @@ func TestResolveRemove(t *testing.T) { } func TestResolveRemoveWithExtraFlags(t *testing.T) { - got := Resolve(detector.NPM, "remove", []string{"react", "--save-dev"}) + got := Resolve(ecosystem.NPM, "remove", []string{"react", "--save-dev"}) want := []string{"npm", "uninstall", "react", "--save-dev"} if !reflect.DeepEqual(got, want) { t.Errorf("got %v, want %v", got, want) @@ -73,14 +73,14 @@ func TestResolveRemoveWithExtraFlags(t *testing.T) { func TestResolveFallbackScript(t *testing.T) { tests := []struct { - pm detector.PackageManager + pm ecosystem.PackageManager cmd string want []string }{ - {detector.NPM, "dev", []string{"npm", "run", "dev"}}, - {detector.Yarn, "dev", []string{"yarn", "dev"}}, - {detector.Pnpm, "dev", []string{"pnpm", "dev"}}, - {detector.Bun, "dev", []string{"bun", "dev"}}, + {ecosystem.NPM, "dev", []string{"npm", "run", "dev"}}, + {ecosystem.Yarn, "dev", []string{"yarn", "dev"}}, + {ecosystem.Pnpm, "dev", []string{"pnpm", "dev"}}, + {ecosystem.Bun, "dev", []string{"bun", "dev"}}, } for _, tt := range tests { got := Resolve(tt.pm, tt.cmd, nil) @@ -92,14 +92,14 @@ func TestResolveFallbackScript(t *testing.T) { func TestResolveInit(t *testing.T) { tests := []struct { - pm detector.PackageManager + pm ecosystem.PackageManager args []string want []string }{ - {detector.NPM, nil, []string{"npm", "init", "-y"}}, - {detector.Yarn, nil, []string{"yarn", "init", "-y"}}, - {detector.Pnpm, nil, []string{"pnpm", "init"}}, - {detector.Bun, nil, []string{"bun", "init"}}, + {ecosystem.NPM, nil, []string{"npm", "init", "-y"}}, + {ecosystem.Yarn, nil, []string{"yarn", "init", "-y"}}, + {ecosystem.Pnpm, nil, []string{"pnpm", "init"}}, + {ecosystem.Bun, nil, []string{"bun", "init"}}, } for _, tt := range tests { got := Resolve(tt.pm, "init", tt.args) @@ -111,14 +111,14 @@ func TestResolveInit(t *testing.T) { func TestResolveInitWithExtraFlags(t *testing.T) { tests := []struct { - pm detector.PackageManager + pm ecosystem.PackageManager args []string want []string }{ - {detector.NPM, []string{"--scope=@myorg"}, []string{"npm", "init", "-y", "--scope=@myorg"}}, - {detector.Bun, []string{"--react"}, []string{"bun", "init", "--react"}}, - {detector.Pnpm, []string{"--react"}, []string{"pnpm", "init", "--react"}}, - {detector.Yarn, []string{"--scope=@myorg"}, []string{"yarn", "init", "-y", "--scope=@myorg"}}, + {ecosystem.NPM, []string{"--scope=@myorg"}, []string{"npm", "init", "-y", "--scope=@myorg"}}, + {ecosystem.Bun, []string{"--react"}, []string{"bun", "init", "--react"}}, + {ecosystem.Pnpm, []string{"--react"}, []string{"pnpm", "init", "--react"}}, + {ecosystem.Yarn, []string{"--scope=@myorg"}, []string{"yarn", "init", "-y", "--scope=@myorg"}}, } for _, tt := range tests { got := Resolve(tt.pm, "init", tt.args) @@ -129,9 +129,7 @@ func TestResolveInitWithExtraFlags(t *testing.T) { } func TestResolveInitYarnClassicNeedsNonInteractiveFlag(t *testing.T) { - // Yarn Classic (v1) requires -y to skip interactive prompts. - // Yarn Berry (v2+) ignores -y harmlessly, so we always pass it. - got := Resolve(detector.Yarn, "init", nil) + got := Resolve(ecosystem.Yarn, "init", nil) want := []string{"yarn", "init", "-y"} if !reflect.DeepEqual(got, want) { t.Errorf("Yarn init should include -y for Classic compatibility: got %v, want %v", got, want) @@ -139,20 +137,18 @@ func TestResolveInitYarnClassicNeedsNonInteractiveFlag(t *testing.T) { } func TestResolveInitNonInteractivePMsOmitFlag(t *testing.T) { - // pnpm and bun init are non-interactive by default; -y must NOT be passed. tests := []struct { - pm detector.PackageManager + pm ecosystem.PackageManager want []string }{ - {detector.Pnpm, []string{"pnpm", "init"}}, - {detector.Bun, []string{"bun", "init"}}, + {ecosystem.Pnpm, []string{"pnpm", "init"}}, + {ecosystem.Bun, []string{"bun", "init"}}, } for _, tt := range tests { got := Resolve(tt.pm, "init", nil) if !reflect.DeepEqual(got, tt.want) { t.Errorf("Resolve(%s, init) should not include -y: got %v, want %v", tt.pm, got, tt.want) } - // Verify -y is NOT present for _, arg := range got { if arg == "-y" { t.Errorf("Resolve(%s, init) must not include -y, but got %v", tt.pm, got) @@ -162,20 +158,18 @@ func TestResolveInitNonInteractivePMsOmitFlag(t *testing.T) { } func TestResolveInitAllPMsPassthroughExtraArgs(t *testing.T) { - // Every PM must forward extra arguments after their init command. tests := []struct { - pm detector.PackageManager + pm ecosystem.PackageManager args []string - wantTail []string // expected args at the end of the resolved command + wantTail []string }{ - {detector.NPM, []string{"--scope=@myorg", "--yes"}, []string{"--scope=@myorg", "--yes"}}, - {detector.Yarn, []string{"--scope=@myorg", "--private"}, []string{"--scope=@myorg", "--private"}}, - {detector.Pnpm, []string{"--react", "--typescript"}, []string{"--react", "--typescript"}}, - {detector.Bun, []string{"--react", "--open"}, []string{"--react", "--open"}}, + {ecosystem.NPM, []string{"--scope=@myorg", "--yes"}, []string{"--scope=@myorg", "--yes"}}, + {ecosystem.Yarn, []string{"--scope=@myorg", "--private"}, []string{"--scope=@myorg", "--private"}}, + {ecosystem.Pnpm, []string{"--react", "--typescript"}, []string{"--react", "--typescript"}}, + {ecosystem.Bun, []string{"--react", "--open"}, []string{"--react", "--open"}}, } for _, tt := range tests { got := Resolve(tt.pm, "init", tt.args) - // Check that all extra args appear at the tail of the resolved command tail := got[len(got)-len(tt.wantTail):] if !reflect.DeepEqual(tail, tt.wantTail) { t.Errorf("Resolve(%s, init, %v): extra args not forwarded correctly, got tail %v, want %v", tt.pm, tt.args, tail, tt.wantTail) @@ -184,7 +178,7 @@ func TestResolveInitAllPMsPassthroughExtraArgs(t *testing.T) { } func TestResolveWithExtraFlags(t *testing.T) { - got := Resolve(detector.NPM, "add", []string{"react", "--save-dev"}) + got := Resolve(ecosystem.NPM, "add", []string{"react", "--save-dev"}) want := []string{"npm", "install", "react", "--save-dev"} if !reflect.DeepEqual(got, want) { t.Errorf("got %v, want %v", got, want)