Skip to content

Commit 4dcfab3

Browse files
bluwyantfu
andauthored
feat: add strategies option for detect API (#42)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent a2aa63b commit 4dcfab3

File tree

17 files changed

+176
-30
lines changed

17 files changed

+176
-30
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,6 @@ _storage.json
8383

8484
# System files
8585
.DS_Store
86+
87+
# Allow node_modules in install-metadata fixture to test installation setups
88+
!test/fixtures/install-metadata/*/node_modules

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
[![JSDocs][jsdocs-src]][jsdocs-href]
66
[![License][license-src]][license-href]
77

8-
Package manager detector is based on lock files and the `packageManager` field in the current project's `package.json` file.
8+
Package manager detector is based on lock files, the `package.json` `packageManager` field, and installation metadata to detect the package manager used in a project.
99

10-
It will detect your `yarn.lock` / `pnpm-lock.yaml` / `package-lock.json` / `bun.lock` / `bun.lockb` / `deno.lock` to know the current package manager and use the `packageManager` field in your `package.json` if present.
10+
It supports `npm`, `yarn`, `pnpm`, `deno`, and `bun`.
1111

1212
## Install
1313

src/constants.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ export const LOCKS: Record<string, AgentName> = {
2121
'npm-shrinkwrap.json': 'npm',
2222
}
2323

24+
// the order here matters, more specific one comes first
25+
export const INSTALL_METADATA: Record<string, AgentName> = {
26+
'node_modules/.deno/': 'deno',
27+
'node_modules/.pnpm/': 'pnpm',
28+
'node_modules/.yarn-state.yml': 'yarn', // yarn v2+ (node-modules)
29+
'node_modules/.yarn_integrity': 'yarn', // yarn v1
30+
'node_modules/.package-lock.json': 'npm',
31+
'.pnp.cjs': 'yarn', // yarn v3+ (pnp)
32+
'.pnp.js': 'yarn', // yarn v2 (pnp)
33+
'bun.lock': 'bun',
34+
'bun.lockb': 'bun',
35+
}
36+
2437
export const INSTALL_PAGE: Record<Agent, string> = {
2538
'bun': 'https://bun.sh',
2639
'deno': 'https://deno.com',

src/detect.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import type { Agent, AgentName, DetectOptions, DetectResult } from './types'
22
import fs from 'node:fs'
33
import path from 'node:path'
44
import process from 'node:process'
5-
import { AGENTS, LOCKS } from './constants'
5+
import { AGENTS, INSTALL_METADATA, LOCKS } from './constants'
66

7-
async function isFile(path: string) {
7+
function pathExists(path: string, type: 'file' | 'dir') {
88
try {
9-
return (await fs.promises.stat(path)).isFile()
9+
const stat = fs.statSync(path)
10+
return type === 'file' ? stat.isFile() : stat.isDirectory()
1011
}
1112
catch {
1213
return false
@@ -39,8 +40,11 @@ function* lookup(cwd: string = process.cwd()): Generator<string> {
3940
}
4041
}
4142

42-
async function parsePackageJson(filepath: string, onUnknown: DetectOptions['onUnknown']): Promise<DetectResult | null> {
43-
return (!filepath || !await isFile(filepath))
43+
async function parsePackageJson(
44+
filepath: string,
45+
onUnknown: DetectOptions['onUnknown'],
46+
): Promise<DetectResult | null> {
47+
return (!filepath || !pathExists(filepath, 'file'))
4448
? null
4549
: await handlePackageManager(filepath, onUnknown)
4650
}
@@ -51,24 +55,48 @@ async function parsePackageJson(filepath: string, onUnknown: DetectOptions['onUn
5155
* @returns {Promise<DetectResult | null>} The detected package manager or `null` if not found.
5256
*/
5357
export async function detect(options: DetectOptions = {}): Promise<DetectResult | null> {
54-
const { cwd, onUnknown } = options
58+
const { cwd, strategies = ['lockfile', 'packageManager-field'], onUnknown } = options
5559

5660
for (const directory of lookup(cwd)) {
57-
// Look up for lock files
58-
for (const lock of Object.keys(LOCKS)) {
59-
if (await isFile(path.join(directory, lock))) {
60-
const name = LOCKS[lock]
61-
const result = await parsePackageJson(path.join(directory, 'package.json'), onUnknown)
62-
if (result)
63-
return result
64-
else
65-
return { name, agent: name }
61+
for (const strategy of strategies) {
62+
switch (strategy) {
63+
case 'lockfile': {
64+
// Look up for lock files
65+
for (const lock of Object.keys(LOCKS)) {
66+
if (await pathExists(path.join(directory, lock), 'file')) {
67+
const name = LOCKS[lock]
68+
const result = await parsePackageJson(path.join(directory, 'package.json'), onUnknown)
69+
if (result)
70+
return result
71+
else
72+
return { name, agent: name }
73+
}
74+
}
75+
break
76+
}
77+
case 'packageManager-field': {
78+
// Look up for package.json
79+
const result = await parsePackageJson(path.join(directory, 'package.json'), onUnknown)
80+
if (result)
81+
return result
82+
break
83+
}
84+
case 'install-metadata': {
85+
// Look up for installation metadata files
86+
for (const metadata of Object.keys(INSTALL_METADATA)) {
87+
const fileOrDir = metadata.endsWith('/') ? 'dir' : 'file'
88+
if (await pathExists(path.join(directory, metadata), fileOrDir)) {
89+
const name = INSTALL_METADATA[metadata]
90+
const agent = name === 'yarn'
91+
? isMetadataYarnClassic(metadata) ? 'yarn' : 'yarn@berry'
92+
: name
93+
return { name, agent }
94+
}
95+
}
96+
break
97+
}
6698
}
6799
}
68-
// Look up for package.json
69-
const result = await parsePackageJson(path.join(directory, 'package.json'), onUnknown)
70-
if (result)
71-
return result
72100
}
73101

74102
return null
@@ -107,3 +135,7 @@ async function handlePackageManager(
107135
catch {}
108136
return null
109137
}
138+
139+
function isMetadataYarnClassic(metadataPath: string) {
140+
return metadataPath.endsWith('.yarn_integrity')
141+
}

src/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,26 @@ export interface ResolvedCommand {
3131
args: string[]
3232
}
3333

34+
export type DetectStrategy = 'lockfile' | 'packageManager-field' | 'install-metadata'
35+
3436
export interface DetectOptions {
3537
/**
3638
* Current working directory to start looking up for package manager.
3739
* @default `process.cwd()`
3840
*/
3941
cwd?: string
42+
/**
43+
* The strategies to use for detecting the package manager. The strategies
44+
* are executed in the order it's specified for every directory that it iterates
45+
* upwards from the `cwd`.
46+
*
47+
* - `lockfile`: Look up for lock files.
48+
* - `packageManager-field`: Look up for the `packageManager` field in package.json.
49+
* - `install-metadata`: Look up for installation metadata added by package managers.
50+
*
51+
* @default ['lockfile', 'packageManager-field']
52+
*/
53+
strategies?: DetectStrategy[]
4054
/**
4155
* Callback when unknown package manager from package.json.
4256
* @param packageManager - The `packageManager` value from package.json file.

test/__snapshots__/detect.spec.ts.snap

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,63 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`install-metadata > bun 1`] = `
4+
{
5+
"agent": "bun",
6+
"name": "bun",
7+
}
8+
`;
9+
10+
exports[`install-metadata > deno 1`] = `
11+
{
12+
"agent": "deno",
13+
"name": "deno",
14+
}
15+
`;
16+
17+
exports[`install-metadata > npm 1`] = `
18+
{
19+
"agent": "npm",
20+
"name": "npm",
21+
}
22+
`;
23+
24+
exports[`install-metadata > pnpm 1`] = `
25+
{
26+
"agent": "pnpm",
27+
"name": "pnpm",
28+
}
29+
`;
30+
31+
exports[`install-metadata > unknown 1`] = `null`;
32+
33+
exports[`install-metadata > yarn 1`] = `
34+
{
35+
"agent": "yarn",
36+
"name": "yarn",
37+
}
38+
`;
39+
40+
exports[`install-metadata > yarn@berry 1`] = `
41+
{
42+
"agent": "yarn@berry",
43+
"name": "yarn",
44+
}
45+
`;
46+
47+
exports[`install-metadata > yarn@berry_pnp-v2 1`] = `
48+
{
49+
"agent": "yarn@berry",
50+
"name": "yarn",
51+
}
52+
`;
53+
54+
exports[`install-metadata > yarn@berry_pnp-v3 1`] = `
55+
{
56+
"agent": "yarn@berry",
57+
"name": "yarn",
58+
}
59+
`;
60+
361
exports[`lockfile > bun 1`] = `
462
{
563
"agent": "bun",
@@ -91,6 +149,14 @@ exports[`packager > pnpm@6 1`] = `
91149
}
92150
`;
93151

152+
exports[`packager > pnpm-version-range 1`] = `
153+
{
154+
"agent": "pnpm",
155+
"name": "pnpm",
156+
"version": "8.0.0",
157+
}
158+
`;
159+
94160
exports[`packager > unknown 1`] = `null`;
95161

96162
exports[`packager > yarn 1`] = `

test/detect.spec.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import type { MockInstance } from 'vitest'
2+
import type { DetectOptions } from '../src'
23
import { tmpdir } from 'node:os'
34
import path from 'node:path'
45
import fs from 'fs-extra'
56
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
6-
import { AGENTS, detect } from '../src'
7+
import { detect } from '../src'
78

89
let basicLog: MockInstance, errorLog: MockInstance, warnLog: MockInstance, infoLog: MockInstance
910

10-
function detectTest(fixture: string, agent: string) {
11+
function detectTest(fixture: string, agent: string, options?: DetectOptions) {
1112
return async () => {
1213
const cwd = await fs.mkdtemp(path.join(tmpdir(), 'ni-'))
1314
const dir = path.join(__dirname, 'fixtures', fixture, agent)
1415
await fs.copy(dir, cwd)
1516

16-
expect(await detect({ cwd })).toMatchSnapshot()
17+
expect(await detect({ cwd, ...options })).toMatchSnapshot()
1718
}
1819
}
1920

@@ -28,17 +29,29 @@ afterAll(() => {
2829
vi.resetAllMocks()
2930
})
3031

31-
const agents = [...AGENTS, 'unknown']
32-
const fixtures = ['lockfile', 'packager']
32+
const fixtures = ['lockfile', 'packager', 'install-metadata']
3333

34-
// matrix testing of: fixtures x agents
35-
fixtures.forEach(fixture => describe(fixture, () => agents.forEach((agent) => {
36-
it(agent, detectTest(fixture, agent))
34+
fixtures.forEach(fixture => describe(fixture, () => {
35+
const fixtureDirs = getFixtureDirs(fixture)
36+
37+
fixtureDirs.forEach((dir) => {
38+
let options: DetectOptions | undefined
39+
if (fixture === 'install-metadata') {
40+
options = { strategies: ['install-metadata', 'lockfile', 'packageManager-field'] }
41+
}
42+
it(dir, detectTest(fixture, dir, options))
43+
})
3744

3845
it('no logs', () => {
3946
expect(basicLog).not.toHaveBeenCalled()
4047
expect(warnLog).not.toHaveBeenCalled()
4148
expect(errorLog).not.toHaveBeenCalled()
4249
expect(infoLog).not.toHaveBeenCalled()
4350
})
44-
})))
51+
}))
52+
53+
function getFixtureDirs(fixture: string) {
54+
const fixtureDir = path.join(__dirname, 'fixtures', fixture)
55+
const items = fs.readdirSync(fixtureDir)
56+
return items.filter(item => fs.statSync(path.join(fixtureDir, item)).isDirectory())
57+
}

test/exports.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ it('exports-snapshot', async () => {
1212
".": {
1313
"AGENTS": "object",
1414
"COMMANDS": "object",
15+
"INSTALL_METADATA": "object",
1516
"INSTALL_PAGE": "object",
1617
"LOCKS": "object",
1718
"constructCommand": "function",
@@ -26,6 +27,7 @@ it('exports-snapshot', async () => {
2627
},
2728
"./constants": {
2829
"AGENTS": "object",
30+
"INSTALL_METADATA": "object",
2931
"INSTALL_PAGE": "object",
3032
"LOCKS": "object",
3133
},

test/fixtures/install-metadata/bun/bun.lockb

Whitespace-only changes.

test/fixtures/install-metadata/deno/node_modules/.deno/.gitkeep

Whitespace-only changes.

0 commit comments

Comments
 (0)