diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e0d8c84
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,20 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+ - run: bun install --frozen-lockfile
+ - run: bun run typecheck
+ - run: bun run lint
+ - run: bun run test
+ - run: bun run build
diff --git a/.gitignore b/.gitignore
index 6dcf000..db14d68 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,3 +29,6 @@ pnpm-debug.log*
# TypeScript cache
*.tsbuildinfo
+
+# TLDR cache
+.tldr/
diff --git a/README.md b/README.md
index 2fdfdd9..3e45716 100644
--- a/README.md
+++ b/README.md
@@ -110,7 +110,10 @@ omnes run dev
## Detection
-Omnes identifies the package manager by lockfile presence. Detection order matters — first match wins.
+Omnes walks up from the current directory to the filesystem root. In each directory it checks two signals, in order, and the **closest** directory with a match wins:
+
+1. **Corepack `packageManager` field** in `package.json` (e.g. `"packageManager": "pnpm@9.1.0"`). This is the most explicit signal, so it takes precedence over a lockfile in the same directory.
+2. **Lockfile presence.** When several lockfiles sit in one directory, the first by priority wins:
| Priority | Lockfile | Package Manager |
|----------|----------|-----------------|
@@ -120,7 +123,7 @@ Omnes identifies the package manager by lockfile presence. Detection order matte
| 4 | `yarn.lock` | yarn |
| 5 | `package-lock.json` | npm |
-No lockfile found? Falls back to npm.
+No signal found anywhere up the tree? Falls back to npm.
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..8f73b5d
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "includes": ["src/**/*.ts"]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 2,
+ "lineWidth": 80
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true
+ }
+ },
+ "assist": {
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ }
+}
diff --git a/bun.lock b/bun.lock
index 2ab46a2..15354a1 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,14 +5,92 @@
"": {
"name": "omnes-cli",
"devDependencies": {
+ "@biomejs/biome": "^2.4.16",
"@types/node": "^22.0.0",
+ "tsx": "^4.22.4",
"typescript": "^5.7.0",
},
},
},
"packages": {
+ "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="],
+
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="],
+
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="],
+
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="],
+
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="],
+
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="],
+
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="],
+
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="],
+
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
+
"@types/node": ["@types/node@22.19.6", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ=="],
+ "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
+
+ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
+ "tsx": ["tsx@4.22.4", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
diff --git a/package.json b/package.json
index cb5b7df..dc2812c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "omnes-cli",
- "version": "0.1.2",
+ "version": "0.1.3",
"description": "Universal package manager CLI - automatically detects and uses the right package manager for your project",
"type": "module",
"bin": {
@@ -11,8 +11,12 @@
"dist"
],
"scripts": {
- "build": "tsc",
- "dev": "tsc --watch",
+ "build": "tsc -p tsconfig.json",
+ "dev": "tsc -p tsconfig.json --watch",
+ "typecheck": "tsc -p tsconfig.test.json",
+ "test": "node --import tsx --test \"src/**/*.test.ts\"",
+ "lint": "biome check src",
+ "format": "biome check --write src",
"prepublishOnly": "npm run build"
},
"repository": {
@@ -39,7 +43,9 @@
"node": ">=18.0.0"
},
"devDependencies": {
+ "@biomejs/biome": "^2.4.16",
"@types/node": "^22.0.0",
+ "tsx": "^4.22.4",
"typescript": "^5.7.0"
}
}
diff --git a/src/lib.test.ts b/src/lib.test.ts
new file mode 100644
index 0000000..c557bdf
--- /dev/null
+++ b/src/lib.test.ts
@@ -0,0 +1,129 @@
+import assert from "node:assert/strict";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { after, describe, test } from "node:test";
+import {
+ detectPackageManager,
+ parsePackageManagerField,
+ resolveArgs,
+ transformArgsForNpm,
+} from "./lib.js";
+
+// Track temp roots so we can clean them up after the suite.
+const tempRoots: string[] = [];
+
+function makeTree(layout: Record): string {
+ const root = mkdtempSync(join(tmpdir(), "omnes-test-"));
+ tempRoots.push(root);
+ for (const [relPath, contents] of Object.entries(layout)) {
+ const full = join(root, relPath);
+ mkdirSync(join(full, ".."), { recursive: true });
+ writeFileSync(full, contents);
+ }
+ return root;
+}
+
+after(() => {
+ for (const root of tempRoots) {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+describe("parsePackageManagerField", () => {
+ test("extracts the manager name from a Corepack field", () => {
+ assert.equal(parsePackageManagerField("pnpm@9.1.0"), "pnpm");
+ assert.equal(parsePackageManagerField("yarn@3.6.0+sha512.abc123"), "yarn");
+ assert.equal(parsePackageManagerField("bun@1.1.0"), "bun");
+ assert.equal(parsePackageManagerField("npm@10.2.0"), "npm");
+ });
+
+ test("accepts a bare manager name without a version", () => {
+ assert.equal(parsePackageManagerField("pnpm"), "pnpm");
+ });
+
+ test("rejects unknown or non-string values", () => {
+ assert.equal(parsePackageManagerField("deno@1.0.0"), undefined);
+ assert.equal(parsePackageManagerField(""), undefined);
+ assert.equal(parsePackageManagerField(undefined), undefined);
+ assert.equal(parsePackageManagerField(42), undefined);
+ });
+});
+
+describe("detectPackageManager", () => {
+ test("walks up to a parent lockfile (monorepo support)", () => {
+ const root = makeTree({
+ "pnpm-lock.yaml": "",
+ "packages/api/package.json": "{}",
+ });
+ assert.equal(detectPackageManager(join(root, "packages", "api")), "pnpm");
+ });
+
+ test("closest lockfile wins over an ancestor lockfile", () => {
+ const root = makeTree({
+ "pnpm-lock.yaml": "",
+ "packages/web/yarn.lock": "",
+ "packages/web/src/package.json": "{}",
+ });
+ assert.equal(
+ detectPackageManager(join(root, "packages", "web", "src")),
+ "yarn",
+ );
+ });
+
+ test("Corepack packageManager field beats a sibling lockfile", () => {
+ const root = makeTree({
+ "package.json": JSON.stringify({ packageManager: "bun@1.1.0" }),
+ "yarn.lock": "",
+ });
+ assert.equal(detectPackageManager(root), "bun");
+ });
+
+ test("respects documented lockfile priority (yarn before npm)", () => {
+ const root = makeTree({
+ "yarn.lock": "",
+ "package-lock.json": "",
+ });
+ assert.equal(detectPackageManager(root), "yarn");
+ });
+
+ test("defaults to npm when no signal is found", () => {
+ const root = makeTree({ "src/index.ts": "" });
+ assert.equal(detectPackageManager(join(root, "src")), "npm");
+ });
+});
+
+describe("transformArgsForNpm", () => {
+ test("prefixes custom scripts with run", () => {
+ assert.deepEqual(transformArgsForNpm(["dev"]), ["run", "dev"]);
+ assert.deepEqual(transformArgsForNpm(["build", "--flag"]), [
+ "run",
+ "build",
+ "--flag",
+ ]);
+ });
+
+ test("passes built-in commands through untouched", () => {
+ assert.deepEqual(transformArgsForNpm(["test"]), ["test"]);
+ assert.deepEqual(transformArgsForNpm(["install"]), ["install"]);
+ assert.deepEqual(transformArgsForNpm(["run", "dev"]), ["run", "dev"]);
+ });
+
+ test("passes aliases through untouched", () => {
+ assert.deepEqual(transformArgsForNpm(["add", "react"]), ["add", "react"]);
+ assert.deepEqual(transformArgsForNpm(["i"]), ["i"]);
+ });
+
+ test("returns an empty array for empty input", () => {
+ assert.deepEqual(transformArgsForNpm([]), []);
+ });
+});
+
+describe("resolveArgs", () => {
+ test("only npm gets the run-prefix transform", () => {
+ assert.deepEqual(resolveArgs("npm", ["dev"]), ["run", "dev"]);
+ assert.deepEqual(resolveArgs("bun", ["dev"]), ["dev"]);
+ assert.deepEqual(resolveArgs("pnpm", ["build"]), ["build"]);
+ assert.deepEqual(resolveArgs("yarn", ["dev"]), ["dev"]);
+ });
+});
diff --git a/src/lib.ts b/src/lib.ts
new file mode 100644
index 0000000..9f2d551
--- /dev/null
+++ b/src/lib.ts
@@ -0,0 +1,239 @@
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+// Supported package managers
+export type PackageManager = "bun" | "pnpm" | "npm" | "yarn";
+
+// Lockfile → package manager. Key order is the detection priority: the first
+// match within a directory wins. Mirrors the table in the README.
+export const LOCKFILES: Record = {
+ "bun.lockb": "bun",
+ "bun.lock": "bun",
+ "pnpm-lock.yaml": "pnpm",
+ "yarn.lock": "yarn",
+ "package-lock.json": "npm",
+};
+
+const PACKAGE_MANAGERS: ReadonlySet = new Set([
+ "bun",
+ "pnpm",
+ "npm",
+ "yarn",
+]);
+
+// npm built-in commands that don't require a "run" prefix
+export const NPM_BUILTIN_COMMANDS = new Set([
+ "access",
+ "adduser",
+ "audit",
+ "bugs",
+ "cache",
+ "ci",
+ "completion",
+ "config",
+ "dedupe",
+ "deprecate",
+ "diff",
+ "dist-tag",
+ "docs",
+ "doctor",
+ "edit",
+ "exec",
+ "explain",
+ "explore",
+ "find-dupes",
+ "fund",
+ "get",
+ "help",
+ "help-search",
+ "hook",
+ "init",
+ "install",
+ "install-ci-test",
+ "install-test",
+ "link",
+ "ll",
+ "login",
+ "logout",
+ "ls",
+ "org",
+ "outdated",
+ "owner",
+ "pack",
+ "ping",
+ "pkg",
+ "prefix",
+ "profile",
+ "prune",
+ "publish",
+ "query",
+ "rebuild",
+ "repo",
+ "restart",
+ "root",
+ "run",
+ "run-script",
+ "search",
+ "set",
+ "shrinkwrap",
+ "star",
+ "stars",
+ "start",
+ "stop",
+ "team",
+ "test",
+ "token",
+ "uninstall",
+ "unpublish",
+ "unstar",
+ "update",
+ "version",
+ "view",
+ "whoami",
+]);
+
+// Short aliases for npm commands
+export const NPM_COMMAND_ALIASES = new Set([
+ "i", // install
+ "in", // install
+ "ins", // install
+ "inst", // install
+ "isnt", // install (npm tolerates this typo as an alias)
+ "it", // install-test
+ "cit", // install-ci-test
+ "add", // install
+ "rm", // uninstall
+ "remove", // uninstall
+ "un", // uninstall
+ "unlink", // uninstall
+ "ln", // link
+ "t", // test
+ "tst", // test
+ "up", // update
+ "c", // config
+ "s", // search
+ "se", // search
+ "rb", // rebuild
+ "x", // exec
+]);
+
+/**
+ * Parse Corepack's `packageManager` field (e.g. "pnpm@9.1.0+sha512.abc") and
+ * return the package-manager name if it is one we support, otherwise undefined.
+ */
+export function parsePackageManagerField(
+ value: unknown,
+): PackageManager | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+ const name = value.split("@", 1)[0]?.trim();
+ if (name !== undefined && PACKAGE_MANAGERS.has(name)) {
+ return name as PackageManager;
+ }
+ return undefined;
+}
+
+/**
+ * Read a valid Corepack `packageManager` hint from the package.json in `dir`,
+ * if one exists. Malformed JSON is ignored so detection falls back to lockfiles.
+ */
+function corepackHint(dir: string): PackageManager | undefined {
+ const pkgPath = join(dir, "package.json");
+ if (!existsSync(pkgPath)) {
+ return undefined;
+ }
+ try {
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
+ packageManager?: unknown;
+ };
+ return parsePackageManagerField(pkg.packageManager);
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Detect the package manager by walking up from `startDir` to the filesystem
+ * root. This supports monorepos where the lockfile lives in a parent directory.
+ *
+ * Within each directory an explicit Corepack `packageManager` field wins over a
+ * lockfile, and the closest directory with any signal wins overall. Falls back
+ * to npm when nothing is found.
+ */
+export function detectPackageManager(startDir: string): PackageManager {
+ let currentDir = startDir;
+
+ while (true) {
+ const hint = corepackHint(currentDir);
+ if (hint !== undefined) {
+ return hint;
+ }
+
+ for (const [lockfile, pm] of Object.entries(LOCKFILES)) {
+ if (existsSync(join(currentDir, lockfile))) {
+ return pm;
+ }
+ }
+
+ const parentDir = dirname(currentDir);
+ // Reached the filesystem root (dirname is idempotent there).
+ if (parentDir === currentDir) {
+ break;
+ }
+ currentDir = parentDir;
+ }
+
+ return "npm";
+}
+
+/**
+ * Transform arguments for npm to handle built-in vs script commands. npm
+ * requires a "run" prefix for custom scripts, but not for built-in commands.
+ */
+export function transformArgsForNpm(args: readonly string[]): string[] {
+ const command = args[0];
+ if (command === undefined) {
+ return [];
+ }
+
+ // "run"/"run-script" and built-in commands or aliases pass through as-is.
+ if (
+ command === "run" ||
+ command === "run-script" ||
+ NPM_BUILTIN_COMMANDS.has(command) ||
+ NPM_COMMAND_ALIASES.has(command)
+ ) {
+ return [...args];
+ }
+
+ // Otherwise, prepend "run" for custom scripts.
+ return ["run", ...args];
+}
+
+/**
+ * Resolve the final argument vector to hand to the package manager. Only npm
+ * needs the script/built-in disambiguation; the others run scripts directly.
+ */
+export function resolveArgs(
+ packageManager: PackageManager,
+ args: readonly string[],
+): string[] {
+ return packageManager === "npm" ? transformArgsForNpm(args) : [...args];
+}
+
+/**
+ * Read the CLI version from the package.json shipped alongside this module.
+ * Resolves correctly from both `dist/lib.js` and `src/lib.ts` (both are one
+ * directory below the package root).
+ */
+export function readVersion(): string {
+ try {
+ const pkg = JSON.parse(
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
+ ) as { version?: unknown };
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
+ } catch {
+ return "0.0.0";
+ }
+}
diff --git a/src/omnes.ts b/src/omnes.ts
index 219357a..1a7a8eb 100644
--- a/src/omnes.ts
+++ b/src/omnes.ts
@@ -1,122 +1,20 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
-import { existsSync } from "node:fs";
-import { dirname, join } from "node:path";
+import {
+ detectPackageManager,
+ type PackageManager,
+ readVersion,
+ resolveArgs,
+} from "./lib.js";
-// Package version - synced with package.json
-const VERSION = "0.1.0";
-
-// Supported package managers
-type PackageManager = "bun" | "pnpm" | "npm" | "yarn";
-
-// Lockfile to package manager mapping
-const LOCKFILES: Record = {
- "bun.lockb": "bun",
- "bun.lock": "bun",
- "pnpm-lock.yaml": "pnpm",
- "package-lock.json": "npm",
- "yarn.lock": "yarn",
-};
-
-// npm built-in commands that don't require "run" prefix
-const NPM_BUILTIN_COMMANDS = new Set([
- "access",
- "adduser",
- "audit",
- "bugs",
- "cache",
- "ci",
- "completion",
- "config",
- "dedupe",
- "deprecate",
- "diff",
- "dist-tag",
- "docs",
- "doctor",
- "edit",
- "exec",
- "explain",
- "explore",
- "find-dupes",
- "fund",
- "get",
- "help",
- "help-search",
- "hook",
- "init",
- "install",
- "install-ci-test",
- "install-test",
- "link",
- "ll",
- "login",
- "logout",
- "ls",
- "org",
- "outdated",
- "owner",
- "pack",
- "ping",
- "pkg",
- "prefix",
- "profile",
- "prune",
- "publish",
- "query",
- "rebuild",
- "repo",
- "restart",
- "root",
- "run",
- "run-script",
- "search",
- "set",
- "shrinkwrap",
- "star",
- "stars",
- "start",
- "stop",
- "team",
- "test",
- "token",
- "uninstall",
- "unpublish",
- "unstar",
- "update",
- "version",
- "view",
- "whoami",
-]);
-
-// Short aliases for npm commands
-const NPM_COMMAND_ALIASES = new Set([
- "i", // install
- "it", // install-test
- "cit", // install-ci-test
- "add", // install
- "rm", // uninstall
- "remove", // uninstall
- "un", // uninstall
- "unlink", // uninstall
- "ln", // link
- "t", // test
- "tst", // test
- "up", // update
- "c", // config
- "s", // search
- "se", // search
- "r", // uninstall
- "rb", // rebuild
- "x", // exec
-]);
+const VERSION = readVersion();
/**
- * Print help message to stderr
+ * Print the help message to stdout (it is explicitly requested output).
*/
function printHelp(): void {
- console.error(`
+ console.log(`
omnes v${VERSION} - Universal package manager CLI
USAGE:
@@ -129,9 +27,10 @@ OPTIONS:
DESCRIPTION:
Automatically detects and uses the correct package manager for your project
- by looking for lockfiles (bun.lockb, pnpm-lock.yaml, package-lock.json, yarn.lock).
+ by reading a Corepack "packageManager" field or finding a lockfile
+ (bun.lockb, pnpm-lock.yaml, yarn.lock, package-lock.json).
- Supports monorepo setups by traversing parent directories to find lockfiles.
+ Supports monorepo setups by traversing parent directories to find a lockfile.
EXAMPLES:
omnes install Install dependencies
@@ -143,148 +42,85 @@ EXAMPLES:
SUPPORTED PACKAGE MANAGERS:
- bun (bun.lockb, bun.lock)
- pnpm (pnpm-lock.yaml)
- - npm (package-lock.json)
- yarn (yarn.lock)
+ - npm (package-lock.json)
If no lockfile is found, defaults to npm.
`);
}
/**
- * Print version to stderr
+ * Print the version to stdout (it is explicitly requested output).
*/
function printVersion(): void {
- console.error(`omnes v${VERSION}`);
-}
-
-/**
- * Detect package manager by traversing up the directory tree looking for lockfiles.
- * This supports monorepo setups where the lockfile is in a parent directory.
- */
-function detectPackageManager(startDir: string): PackageManager {
- let currentDir = startDir;
- const root = dirname(currentDir);
-
- // Traverse up the directory tree
- while (currentDir !== root) {
- for (const [lockfile, pm] of Object.entries(LOCKFILES)) {
- const lockfilePath = join(currentDir, lockfile);
- if (existsSync(lockfilePath)) {
- return pm;
- }
- }
-
- const parentDir = dirname(currentDir);
- // Prevent infinite loop at filesystem root
- if (parentDir === currentDir) {
- break;
- }
- currentDir = parentDir;
- }
-
- // Default to npm if no lockfile is found
- return "npm";
+ console.log(`omnes v${VERSION}`);
}
/**
- * Transform arguments for npm to handle built-in vs script commands.
- * npm requires "run" prefix for custom scripts, but not for built-in commands.
- */
-function transformArgsForNpm(args: readonly string[]): string[] {
- if (args.length === 0) {
- return [];
- }
-
- const [command, ...rest] = args;
-
- // If the command is already "run" or "run-script", pass through as-is
- if (command === "run" || command === "run-script") {
- return [...args];
- }
-
- // If it's a built-in command or alias, pass through as-is
- if (
- command !== undefined &&
- (NPM_BUILTIN_COMMANDS.has(command) || NPM_COMMAND_ALIASES.has(command))
- ) {
- return [...args];
- }
-
- // Otherwise, prepend "run" for custom scripts
- return ["run", ...args];
-}
-
-/**
- * Run the detected package manager with the given arguments.
- * Uses spawn without shell: true for security and proper argument handling.
+ * Run the detected package manager with the given arguments. Uses spawn without
+ * `shell: true` for security and correct argument handling.
*/
function runCommand(
packageManager: PackageManager,
- args: readonly string[]
+ args: readonly string[],
): void {
- // Transform args for npm if needed
- const finalArgs =
- packageManager === "npm" ? transformArgsForNpm(args) : [...args];
+ const finalArgs = resolveArgs(packageManager, args);
+
+ // Informational message on stderr (so it never pollutes piped stdout). Shows
+ // the fully resolved command so the proxying is transparent.
+ console.error(
+ `Using ${packageManager}: ${packageManager} ${finalArgs.join(" ")}`,
+ );
- // Spawn the package manager process
const child = spawn(packageManager, finalArgs, {
stdio: "inherit",
- // No shell: true - pass args directly for security and proper handling
+ // No shell: true - pass args directly for security and proper handling.
});
- // Handle spawn errors (e.g., ENOENT when package manager is not installed)
+ // Handle spawn errors (e.g. ENOENT when the package manager is not installed).
child.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") {
console.error(
- `Error: Package manager "${packageManager}" is not installed or not in PATH.`
+ `Error: "${packageManager}" is not installed or not in PATH.`,
);
- console.error(`Please install ${packageManager} and try again.`);
- } else {
- console.error(`Error spawning ${packageManager}: ${error.message}`);
+ console.error(`Install ${packageManager} and try again.`);
+ // 127 is the conventional "command not found" exit code.
+ process.exit(127);
}
+ console.error(`Error spawning ${packageManager}: ${error.message}`);
process.exit(1);
});
- // Exit with the same code as the child process
+ // Exit with the same code as the child process.
child.on("close", (code: number | null) => {
process.exit(code ?? 1);
});
}
/**
- * Main entry point
+ * Main entry point.
*/
function main(): void {
- // Get arguments without node and script path
const args = process.argv.slice(2);
const firstArg = args[0];
- // Handle --help flag (only if it's the first argument)
+ // Handle --help / --version only when they are the first argument.
if (firstArg === "-h" || firstArg === "--help") {
printHelp();
process.exit(0);
}
-
- // Handle --version flag (only if it's the first argument)
if (firstArg === "-v" || firstArg === "--version") {
printVersion();
process.exit(0);
}
- // Require at least one command
if (args.length === 0) {
console.error("Error: Please provide a command to run.");
console.error('Run "omnes --help" for usage information.');
process.exit(1);
}
- // Detect package manager from current working directory
const packageManager = detectPackageManager(process.cwd());
-
- // Informational message to stderr (so it doesn't interfere with stdout pipes)
- console.error(`Using: ${packageManager}`);
-
- // Run the command
runCommand(packageManager, args);
}
diff --git a/tsconfig.json b/tsconfig.json
index 9dba332..05e3fc8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,5 +15,5 @@
"rootDir": "src"
},
"include": ["src/**/*"],
- "exclude": ["node_modules", "dist"]
+ "exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
diff --git a/tsconfig.test.json b/tsconfig.test.json
new file mode 100644
index 0000000..10ef9f4
--- /dev/null
+++ b/tsconfig.test.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}