Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ pnpm-debug.log*

# TypeScript cache
*.tsbuildinfo

# TLDR cache
.tldr/
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|----------|----------|-----------------|
Expand All @@ -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.

<br/>

Expand Down
30 changes: 30 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
78 changes: 78 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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": {
Expand All @@ -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"
}
}
129 changes: 129 additions & 0 deletions src/lib.test.ts
Original file line number Diff line number Diff line change
@@ -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, string>): 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"]);
});
});
Loading
Loading