Skip to content
Open
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Project: spm (Smart Package Manager)

Go CLI that auto-detects npm/yarn/pnpm/bun and proxies commands.
Go CLI that auto-detects npm/yarn/pnpm/bun/deno and proxies commands.

**Language rule**: This is an open-source project. All code, comments, commit messages, PR descriptions, documentation, and agent output **must** be written in English.

Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Deno package manager support — detects `deno.lock` and recognizes `deno.json`/`deno.jsonc` as project markers.
- `spm run` reads tasks from `deno.json` for Deno projects (via `deno task`).
- `spm init [npm|yarn|pnpm|bun]` command — initialize a new project with the chosen package manager, with interactive selection when no PM is specified.
- Homebrew tap distribution — install via `brew install decampsrenan/tap/spm`.
- `spm audit` command — runs a security audit and normalizes output across npm, yarn (classic & Berry), and pnpm.
Expand Down
32 changes: 16 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
</h1>

<div align="center">
<strong>Smart Package Manager</strong> — One CLI to rule them all. Stop worrying about npm vs yarn vs pnpm vs bun, just run <code>spm</code> and let it figure out the rest.
<strong>Smart Package Manager</strong> — One CLI to rule them all. Stop worrying about npm vs yarn vs pnpm vs bun vs deno, just run <code>spm</code> and let it figure out the rest.
<br />
<br />
<a href="https://github.com/DecampsRenan/spm/issues/new?labels=bug&title=bug%3A+">🐛 Report a Bug</a>
Expand Down Expand Up @@ -47,11 +47,11 @@ Ever joined a project and had to check which package manager it uses before runn

**spm** detects your project's package manager automatically and translates your commands on the fly. Just type `spm install`, `spm add react`, or `spm dev` — it handles the rest.

- 🔍 **Auto-detection** via lock files (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`)
- 🔍 **Auto-detection** via lock files (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, `deno.lock`) and project markers (`package.json`, `deno.json`, `deno.jsonc`)
- 📂 **Directory walk-up** — works from any subdirectory in your project
- 🔀 **Flag pass-through** — unknown flags (e.g. `--legacy-peer-deps`) are forwarded to the underlying package manager
- 🔄 **Command translation** — maps commands to the correct syntax for each package manager
- 🎯 **Interactive script runner** — `spm run` lets you pick a script from package.json
- 🎯 **Interactive script runner** — `spm run` lets you pick a script from package.json (or a task from deno.json)
- 💬 **Interactive prompt** when multiple lock files are detected or no lock file exists
- 👀 **Dry-run mode** to preview commands without executing them
- 🎵 **Vibes mode** — play background music while installing dependencies (`--vibes`)
Expand Down Expand Up @@ -118,7 +118,7 @@ go install github.com/decampsrenan/spm@latest
## Usage

```sh
# Install dependencies (auto-detects npm/yarn/pnpm/bun)
# Install dependencies (auto-detects npm/yarn/pnpm/bun/deno)
spm install

# Pass flags through to the underlying package manager
Expand Down Expand Up @@ -213,18 +213,18 @@ spm -v

### Command mapping

| spm command | npm | yarn | pnpm | bun |
| ------------- | ----------------- | --------------- | --------------- | --------------- |
| `spm init` | `npm init -y` | `yarn init -y` | `pnpm init` | `bun init` |
| `spm install` | `npm install` | `yarn install` | `pnpm install` | `bun install` |
| `spm add foo` | `npm install foo` | `yarn add foo` | `pnpm add foo` | `bun add foo` |
| `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 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` |
| spm command | npm | yarn | pnpm | bun | deno |
| ------------- | ----------------- | --------------- | --------------- | --------------- | ----------------- |
| `spm init` | `npm init -y` | `yarn init -y` | `pnpm init` | `bun init` | `deno init` |
| `spm install` | `npm install` | `yarn install` | `pnpm install` | `bun install` | `deno install` |
| `spm add foo` | `npm install foo` | `yarn add foo` | `pnpm add foo` | `bun add foo` | `deno add foo` |
| `spm add` | *(interactive search)* | *(interactive search)* | *(interactive search)* | *(interactive search)* | *(interactive search)* |
| `spm run` | *(interactive)* | *(interactive)* | *(interactive)* | *(interactive)* | *(interactive)* |
| `spm remove foo` | `npm uninstall foo` | `yarn remove foo` | `pnpm remove foo` | `bun remove foo` | `deno remove foo` |
| `spm clean` | Removes `node_modules` (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` | `deno task dev` |

## Contributing

Expand Down
14 changes: 11 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ var rawOutput bool

var rootCmd = &cobra.Command{
Use: "spm",
Short: "Smart Package Manager — auto-detects npm/yarn/pnpm/bun and proxies commands",
Short: "Smart Package Manager — auto-detects npm/yarn/pnpm/bun/deno and proxies commands",
// Running `spm` with no args is equivalent to `spm install`
RunE: func(cmd *cobra.Command, args []string) error {
return run("install", args)
Expand Down Expand Up @@ -70,7 +70,7 @@ var addCmd = &cobra.Command{

var runCmd = &cobra.Command{
Use: "run [script]",
Short: "Run a script from package.json",
Short: "Run a script from package.json or a task from deno.json",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return run(args[0], args[1:])
Expand All @@ -87,11 +87,19 @@ var runCmd = &cobra.Command{
return err
}

scriptList, err := scripts.List(det.Dir)
var scriptList []scripts.Script
if det.PM == detector.Deno {
scriptList, err = scripts.ListDeno(det.Dir)
} else {
scriptList, err = scripts.List(det.Dir)
}
if err != nil {
return err
}
if len(scriptList) == 0 {
if det.PM == detector.Deno {
return fmt.Errorf("no tasks found in deno.json")
}
return fmt.Errorf("no scripts found in package.json")
}

Expand Down
35 changes: 26 additions & 9 deletions internal/detector/detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const (
Yarn PackageManager = "yarn"
Pnpm PackageManager = "pnpm"
Bun PackageManager = "bun"
Deno PackageManager = "deno"
)

var lockFiles = map[string]PackageManager{
Expand All @@ -21,8 +22,11 @@ var lockFiles = map[string]PackageManager{
"pnpm-lock.yaml": Pnpm,
"bun.lock": Bun,
"bun.lockb": Bun,
"deno.lock": Deno,
}

var projectMarkers = []string{"package.json", "deno.json", "deno.jsonc"}

type Detection struct {
PM PackageManager
Dir string
Expand All @@ -37,8 +41,9 @@ 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 project
// marker (package.json, deno.json, or deno.jsonc) 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()
Expand All @@ -47,11 +52,11 @@ func Detect(startDir string) ([]Detection, error) {
}

dir := startDir
var firstPackageJSONDir string
var firstProjectDir string
for {
if hasFile(dir, "package.json") {
if firstPackageJSONDir == "" {
firstPackageJSONDir = dir
if hasProjectMarker(dir) {
if firstProjectDir == "" {
firstProjectDir = dir
}
var detections []Detection
seen := make(map[PackageManager]bool)
Expand All @@ -77,10 +82,19 @@ func Detect(startDir string) ([]Detection, error) {
dir = parent
}

if firstPackageJSONDir != "" {
return nil, &ErrNoLockFile{Dir: firstPackageJSONDir}
if firstProjectDir != "" {
return nil, &ErrNoLockFile{Dir: firstProjectDir}
}
return nil, fmt.Errorf("no project (package.json / deno.json) with a lock file found (searched up to %s)", home)
}

func hasProjectMarker(dir string) bool {
for _, marker := range projectMarkers {
if hasFile(dir, marker) {
return true
}
}
return nil, fmt.Errorf("no package.json with a lock file found (searched up to %s)", home)
return false
}

// LockFileName returns the lock file name for the given package manager.
Expand All @@ -91,6 +105,9 @@ func LockFileName(pm PackageManager) string {
if pm == Bun {
return "bun.lock"
}
if pm == Deno {
return "deno.lock"
}
for name, p := range lockFiles {
if p == pm {
return name
Expand Down
83 changes: 83 additions & 0 deletions internal/detector/detector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,88 @@ func TestDetectBunBothLockFiles(t *testing.T) {
}
}

func TestDetectDeno(t *testing.T) {
dir := t.TempDir()
touch(t, dir, "package.json")
touch(t, dir, "deno.lock")

dets, err := Detect(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(dets) != 1 || dets[0].PM != Deno {
t.Fatalf("expected deno, got %v", dets)
}
}

func TestDetectDenoJson(t *testing.T) {
dir := t.TempDir()
touch(t, dir, "deno.json")
touch(t, dir, "deno.lock")

dets, err := Detect(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(dets) != 1 || dets[0].PM != Deno {
t.Fatalf("expected deno, got %v", dets)
}
}

func TestDetectDenoJsonc(t *testing.T) {
dir := t.TempDir()
touch(t, dir, "deno.jsonc")
touch(t, dir, "deno.lock")

dets, err := Detect(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(dets) != 1 || dets[0].PM != Deno {
t.Fatalf("expected deno, got %v", dets)
}
}

func TestDetectDenoJsonNoLockFile(t *testing.T) {
dir := t.TempDir()
touch(t, dir, "deno.json")

_, err := Detect(dir)
if err == nil {
t.Fatal("expected error when no lock file found")
}

var noLock *ErrNoLockFile
if !errors.As(err, &noLock) {
t.Fatalf("expected ErrNoLockFile, got %T: %v", err, err)
}
if noLock.Dir != dir {
t.Fatalf("expected dir %s, got %s", dir, noLock.Dir)
}
}

func TestDetectDenoWalksUp(t *testing.T) {
root := t.TempDir()
touch(t, root, "deno.json")
touch(t, root, "deno.lock")

sub := filepath.Join(root, "src", "components")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}

dets, err := Detect(sub)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(dets) != 1 || dets[0].PM != Deno {
t.Fatalf("expected deno from parent, got %v", dets)
}
if dets[0].Dir != root {
t.Fatalf("expected dir %s, got %s", root, dets[0].Dir)
}
}

func TestDetectWalksUp(t *testing.T) {
root := t.TempDir()
touch(t, root, "package.json")
Expand Down Expand Up @@ -188,6 +270,7 @@ func TestLockFileName(t *testing.T) {
{Yarn, "yarn.lock"},
{Pnpm, "pnpm-lock.yaml"},
{Bun, "bun.lock"},
{Deno, "deno.lock"},
{PackageManager("unknown"), ""},
}
for _, tt := range tests {
Expand Down
1 change: 1 addition & 0 deletions internal/prompt/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ func SelectFromAll(projectDir string) (detector.Detection, error) {
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(detector.Deno), string(detector.Deno)),
}

var choice string
Expand Down
4 changes: 3 additions & 1 deletion internal/resolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,12 @@ func Resolve(pm detector.PackageManager, command string, args []string) []string
}

default:
// Fallback: treat as a script run
// Fallback: treat as a script/task run
switch pm {
case detector.NPM:
return append([]string{bin, "run", command}, args...)
case detector.Deno:
return append([]string{bin, "task", command}, args...)
default:
// yarn, pnpm, and bun don't need explicit "run"
return append([]string{bin, command}, args...)
Expand Down
4 changes: 4 additions & 0 deletions internal/resolver/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func TestResolveInstall(t *testing.T) {
{detector.Yarn, []string{"yarn", "install"}},
{detector.Pnpm, []string{"pnpm", "install"}},
{detector.Bun, []string{"bun", "install"}},
{detector.Deno, []string{"deno", "install"}},
}
for _, tt := range tests {
got := Resolve(tt.pm, "install", nil)
Expand All @@ -35,6 +36,7 @@ func TestResolveAdd(t *testing.T) {
{detector.Yarn, []string{"react"}, []string{"yarn", "add", "react"}},
{detector.Pnpm, []string{"react"}, []string{"pnpm", "add", "react"}},
{detector.Bun, []string{"react"}, []string{"bun", "add", "react"}},
{detector.Deno, []string{"react"}, []string{"deno", "add", "react"}},
}
for _, tt := range tests {
got := Resolve(tt.pm, "add", tt.args)
Expand All @@ -54,6 +56,7 @@ func TestResolveRemove(t *testing.T) {
{detector.Yarn, []string{"react"}, []string{"yarn", "remove", "react"}},
{detector.Pnpm, []string{"react"}, []string{"pnpm", "remove", "react"}},
{detector.Bun, []string{"react"}, []string{"bun", "remove", "react"}},
{detector.Deno, []string{"react"}, []string{"deno", "remove", "react"}},
}
for _, tt := range tests {
got := Resolve(tt.pm, "remove", tt.args)
Expand Down Expand Up @@ -81,6 +84,7 @@ func TestResolveFallbackScript(t *testing.T) {
{detector.Yarn, "dev", []string{"yarn", "dev"}},
{detector.Pnpm, "dev", []string{"pnpm", "dev"}},
{detector.Bun, "dev", []string{"bun", "dev"}},
{detector.Deno, "dev", []string{"deno", "task", "dev"}},
}
for _, tt := range tests {
got := Resolve(tt.pm, tt.cmd, nil)
Expand Down
Loading
Loading