From d77ce2a38310c6c60eb70c018bebf095ca75523e Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 3 Jun 2026 16:38:24 -0300 Subject: [PATCH 1/2] fix: correct package-manager detection and harden the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The monorepo traversal never actually walked up the directory tree — it only checked the starting directory, so running omnes from a sub-package fell back to npm instead of finding the root lockfile. Detection now walks to the filesystem root, honors a Corepack "packageManager" field, and matches the lockfile priority documented in the README (yarn before npm). Also fixes: --version reported a hardcoded "0.1.0" (now read from package.json), --help/--version print to stdout instead of stderr, the "Using" line shows the fully resolved command, and a missing package manager exits 127 as documented. Pure logic is extracted into src/lib.ts with unit tests covering traversal, Corepack, priority, and the npm arg transform. Adds Biome, typecheck/test/lint scripts, and a GitHub Actions CI workflow. --- .github/workflows/ci.yml | 20 ++++ .gitignore | 3 + README.md | 7 +- biome.json | 30 +++++ bun.lock | 78 +++++++++++++ package.json | 10 +- src/lib.test.ts | 129 +++++++++++++++++++++ src/lib.ts | 239 +++++++++++++++++++++++++++++++++++++++ src/omnes.ts | 234 ++++++-------------------------------- tsconfig.json | 2 +- tsconfig.test.json | 8 ++ 11 files changed, 556 insertions(+), 204 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 biome.json create mode 100644 src/lib.test.ts create mode 100644 src/lib.ts create mode 100644 tsconfig.test.json 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..72048e0 100644 --- a/package.json +++ b/package.json @@ -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"] +} From f3db1f150fcf965871353c0c51c66fb2b3c9326a Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 3 Jun 2026 16:43:40 -0300 Subject: [PATCH 2/2] chore: bump version to 0.1.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 72048e0..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": {