Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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` |
Expand Down
8 changes: 7 additions & 1 deletion cmd/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
14 changes: 7 additions & 7 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import (
"strings"
"testing"

"github.com/decampsrenan/spm/internal/detector"
"github.com/decampsrenan/spm/internal/ecosystem"
)

func TestRunInitPackageJsonExists(t *testing.T) {
Expand Down Expand Up @@ -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]
Expand Down
21 changes: 17 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 14 additions & 28 deletions internal/audit/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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.
Expand All @@ -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{
Expand Down
13 changes: 12 additions & 1 deletion internal/audit/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 0 additions & 81 deletions internal/audit/command.go

This file was deleted.

Loading
Loading