diff --git a/AGENTS.md b/AGENTS.md
index 6ece575..78cf803 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0fb22a..74f0aa6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index 1fbda76..b91bfa7 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
-
Smart Package Manager — One CLI to rule them all. Stop worrying about npm vs yarn vs pnpm vs bun, just run
spm and let it figure out the rest.
+
Smart Package Manager — One CLI to rule them all. Stop worrying about npm vs yarn vs pnpm vs bun vs deno, just run
spm and let it figure out the rest.
🐛 Report a Bug
@@ -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`)
@@ -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
@@ -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
diff --git a/cmd/root.go b/cmd/root.go
index 1978107..e3c9a69 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -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)
@@ -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:])
@@ -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")
}
diff --git a/internal/detector/detector.go b/internal/detector/detector.go
index 850807c..8930091 100644
--- a/internal/detector/detector.go
+++ b/internal/detector/detector.go
@@ -13,6 +13,7 @@ const (
Yarn PackageManager = "yarn"
Pnpm PackageManager = "pnpm"
Bun PackageManager = "bun"
+ Deno PackageManager = "deno"
)
var lockFiles = map[string]PackageManager{
@@ -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
@@ -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()
@@ -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)
@@ -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.
@@ -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
diff --git a/internal/detector/detector_test.go b/internal/detector/detector_test.go
index c947b25..7064262 100644
--- a/internal/detector/detector_test.go
+++ b/internal/detector/detector_test.go
@@ -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")
@@ -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 {
diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go
index 605af49..1084477 100644
--- a/internal/prompt/prompt.go
+++ b/internal/prompt/prompt.go
@@ -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
diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go
index a136423..25f34e0 100644
--- a/internal/resolver/resolver.go
+++ b/internal/resolver/resolver.go
@@ -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...)
diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go
index 70a025a..794090e 100644
--- a/internal/resolver/resolver_test.go
+++ b/internal/resolver/resolver_test.go
@@ -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)
@@ -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)
@@ -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)
@@ -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)
diff --git a/internal/scripts/scripts.go b/internal/scripts/scripts.go
index d870971..516b08d 100644
--- a/internal/scripts/scripts.go
+++ b/internal/scripts/scripts.go
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"sort"
+ "strings"
)
// Script holds a script name and its command string.
@@ -41,3 +42,88 @@ func List(dir string) ([]Script, error) {
})
return scripts, nil
}
+
+// ListDeno reads deno.json (or deno.jsonc) from dir and returns sorted tasks.
+func ListDeno(dir string) ([]Script, error) {
+ data, err := os.ReadFile(filepath.Join(dir, "deno.json"))
+ if err != nil {
+ // Fall back to deno.jsonc.
+ data, err = os.ReadFile(filepath.Join(dir, "deno.jsonc"))
+ if err != nil {
+ return nil, fmt.Errorf("cannot read deno.json or deno.jsonc: %w", err)
+ }
+ data = stripJSONComments(data)
+ }
+
+ var cfg struct {
+ Tasks map[string]string `json:"tasks"`
+ }
+ if err := json.Unmarshal(data, &cfg); err != nil {
+ return nil, fmt.Errorf("cannot parse deno.json: %w", err)
+ }
+
+ if len(cfg.Tasks) == 0 {
+ return nil, nil
+ }
+
+ scripts := make([]Script, 0, len(cfg.Tasks))
+ for name, cmd := range cfg.Tasks {
+ scripts = append(scripts, Script{Name: name, Command: cmd})
+ }
+ sort.Slice(scripts, func(i, j int) bool {
+ return scripts[i].Name < scripts[j].Name
+ })
+ return scripts, nil
+}
+
+// stripJSONComments removes single-line (//) and multi-line (/* */) comments
+// from JSONC content while preserving strings that contain comment-like sequences.
+func stripJSONComments(data []byte) []byte {
+ s := string(data)
+ var b strings.Builder
+ b.Grow(len(s))
+
+ i := 0
+ for i < len(s) {
+ // String literal — copy verbatim including any comment-like content.
+ if s[i] == '"' {
+ b.WriteByte(s[i])
+ i++
+ for i < len(s) {
+ b.WriteByte(s[i])
+ if s[i] == '\\' {
+ i++
+ if i < len(s) {
+ b.WriteByte(s[i])
+ }
+ } else if s[i] == '"' {
+ i++
+ break
+ }
+ i++
+ }
+ continue
+ }
+ // Single-line comment.
+ if i+1 < len(s) && s[i] == '/' && s[i+1] == '/' {
+ for i < len(s) && s[i] != '\n' {
+ i++
+ }
+ continue
+ }
+ // Multi-line comment.
+ if i+1 < len(s) && s[i] == '/' && s[i+1] == '*' {
+ i += 2
+ for i+1 < len(s) && !(s[i] == '*' && s[i+1] == '/') {
+ i++
+ }
+ if i+1 < len(s) {
+ i += 2
+ }
+ continue
+ }
+ b.WriteByte(s[i])
+ i++
+ }
+ return []byte(b.String())
+}
diff --git a/internal/scripts/scripts_test.go b/internal/scripts/scripts_test.go
index 0393545..33c4157 100644
--- a/internal/scripts/scripts_test.go
+++ b/internal/scripts/scripts_test.go
@@ -53,3 +53,133 @@ func TestListNoPackageJSON(t *testing.T) {
t.Fatal("expected error when package.json is missing")
}
}
+
+func TestListDeno(t *testing.T) {
+ dir := t.TempDir()
+ cfg := `{"tasks":{"dev":"deno run --watch main.ts","build":"deno compile main.ts","test":"deno test"}}`
+ os.WriteFile(filepath.Join(dir, "deno.json"), []byte(cfg), 0644)
+
+ scripts, err := ListDeno(dir)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ want := []Script{
+ {Name: "build", Command: "deno compile main.ts"},
+ {Name: "dev", Command: "deno run --watch main.ts"},
+ {Name: "test", Command: "deno test"},
+ }
+ if len(scripts) != len(want) {
+ t.Fatalf("got %d scripts, want %d", len(scripts), len(want))
+ }
+ for i, s := range scripts {
+ if s.Name != want[i].Name || s.Command != want[i].Command {
+ t.Errorf("scripts[%d] = %+v, want %+v", i, s, want[i])
+ }
+ }
+}
+
+func TestListDenoJsonc(t *testing.T) {
+ dir := t.TempDir()
+ cfg := `{
+ // Development tasks
+ "tasks": {
+ "dev": "deno run --watch main.ts",
+ /* build task */
+ "build": "deno compile main.ts"
+ }
+}`
+ os.WriteFile(filepath.Join(dir, "deno.jsonc"), []byte(cfg), 0644)
+
+ scripts, err := ListDeno(dir)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ want := []Script{
+ {Name: "build", Command: "deno compile main.ts"},
+ {Name: "dev", Command: "deno run --watch main.ts"},
+ }
+ if len(scripts) != len(want) {
+ t.Fatalf("got %d scripts, want %d", len(scripts), len(want))
+ }
+ for i, s := range scripts {
+ if s.Name != want[i].Name || s.Command != want[i].Command {
+ t.Errorf("scripts[%d] = %+v, want %+v", i, s, want[i])
+ }
+ }
+}
+
+func TestListDenoFallbackToJsonc(t *testing.T) {
+ dir := t.TempDir()
+ // No deno.json, only deno.jsonc
+ cfg := `{"tasks":{"dev":"deno run main.ts"}}`
+ os.WriteFile(filepath.Join(dir, "deno.jsonc"), []byte(cfg), 0644)
+
+ scripts, err := ListDeno(dir)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(scripts) != 1 || scripts[0].Name != "dev" {
+ t.Errorf("expected 1 script 'dev', got %v", scripts)
+ }
+}
+
+func TestListDenoNoTasks(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "deno.json"), []byte(`{"imports":{}}`), 0644)
+
+ scripts, err := ListDeno(dir)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if scripts != nil {
+ t.Errorf("expected nil, got %v", scripts)
+ }
+}
+
+func TestListDenoNoFile(t *testing.T) {
+ dir := t.TempDir()
+
+ _, err := ListDeno(dir)
+ if err == nil {
+ t.Fatal("expected error when deno.json is missing")
+ }
+}
+
+func TestStripJSONComments(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "single-line comment",
+ input: "{\n// comment\n\"a\": 1\n}",
+ want: "{\n\n\"a\": 1\n}",
+ },
+ {
+ name: "multi-line comment",
+ input: "{/* comment */\"a\": 1}",
+ want: "{\"a\": 1}",
+ },
+ {
+ name: "comment-like in string",
+ input: `{"url": "https://example.com"}`,
+ want: `{"url": "https://example.com"}`,
+ },
+ {
+ name: "escaped quote in string",
+ input: `{"a": "he said \"// not a comment\""}`,
+ want: `{"a": "he said \"// not a comment\""}`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := string(stripJSONComments([]byte(tt.input)))
+ if got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}