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) + } + }) + } +}