Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,15 @@ exports[`should retrieve the build info for providing a rootDir 1`] = `
"packageManager": {
"forceEnvironment": "NETLIFY_USE_PNPM",
"installCommand": "pnpm install",
"localPackageCommand": "pnpm",
"lockFiles": [
"pnpm-lock.yaml",
],
"name": "pnpm",
"remotePackageCommand": [
"pnpm",
"dlx",
],
"runCommand": "pnpm run",
"version": SemVer {
"build": [],
Expand Down Expand Up @@ -167,10 +172,15 @@ exports[`should retrieve the build info for providing a rootDir and a nested pro
"packageManager": {
"forceEnvironment": "NETLIFY_USE_PNPM",
"installCommand": "pnpm install",
"localPackageCommand": "pnpm",
"lockFiles": [
"pnpm-lock.yaml",
],
"name": "pnpm",
"remotePackageCommand": [
"pnpm",
"dlx",
],
"runCommand": "pnpm run",
"version": SemVer {
"build": [],
Expand Down Expand Up @@ -243,10 +253,15 @@ exports[`should retrieve the build info for providing a rootDir and the same pro
"packageManager": {
"forceEnvironment": "NETLIFY_USE_PNPM",
"installCommand": "pnpm install",
"localPackageCommand": "pnpm",
"lockFiles": [
"pnpm-lock.yaml",
],
"name": "pnpm",
"remotePackageCommand": [
"pnpm",
"dlx",
],
"runCommand": "pnpm run",
"version": SemVer {
"build": [],
Expand Down
4 changes: 4 additions & 0 deletions packages/build-info/src/node/get-build-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,14 @@ test('should not crash on invalid projects', async (ctx) => {
expect(packageManager).toMatchInlineSnapshot(`
{
"installCommand": "npm install",
"localPackageCommand": "npx",
"lockFiles": [
"package-lock.json",
],
"name": "npm",
"remotePackageCommand": [
"npx",
],
"runCommand": "npm run",
}
`)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { join } from 'path'

import { beforeEach, describe, expect, test } from 'vitest'
import { beforeEach, describe, expect, test, vi } from 'vitest'

import { mockFileSystem } from '../../tests/mock-file-system.js'
import { NodeFS } from '../node/file-system.js'
Expand Down Expand Up @@ -80,6 +80,19 @@ test('should fallback to npm if just a package.json is present there', async ({
expect(pkgManager?.name).toBe('npm')
})

describe.each([{ pm: 'npm' }, { pm: 'yarn' }, { pm: 'pnpm' }, { pm: 'bun' }])(
'should fallback to user agent if present',
({ pm }) => {
test(`fallback ${pm}`, async ({ fs }) => {
vi.stubEnv('npm_config_user_agent', pm)
const cwd = mockFileSystem({})
const project = new Project(fs, cwd)
const pkgManager = await detectPackageManager(project)
expect(pkgManager?.name).toBe(pm)
})
},
)

test('should use yarn if there is a yarn.lock in the root', async ({ fs }) => {
const cwd = mockFileSystem({
'package.json': '{}',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export type PkgManagerFields = {
installCommand: string
/** The package managers run command prefix */
runCommand: string
/** The package managers command prefix for running a command in a locally installed package */
localPackageCommand: string
/** The package managers command prefix(s) for running a command in a non-installed package. This is sometimes the same as `localPackageCommand` */
remotePackageCommand: string[]
/** The lock files a package manager is using */
lockFiles: string[]
/** Environment variable that can be used to force the usage of a package manager even though there is no lock file or a different lock file */
Expand All @@ -34,30 +38,73 @@ export const AVAILABLE_PACKAGE_MANAGERS: Record<PkgManager, PkgManagerFields> =
name: PkgManager.YARN,
installCommand: 'yarn install',
runCommand: 'yarn run',
localPackageCommand: 'yarn',
remotePackageCommand: ['yarn', 'dlx'],
lockFiles: ['yarn.lock'],
forceEnvironment: 'NETLIFY_USE_YARN',
},
[PkgManager.PNPM]: {
name: PkgManager.PNPM,
installCommand: 'pnpm install',
runCommand: 'pnpm run',
localPackageCommand: 'pnpm',
remotePackageCommand: ['pnpm', 'dlx'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about pnpx?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pnpx was deprecated in pnpm v6, I believe, although it still seems to work as an alias

Copy link
Contributor

@ascorbic ascorbic Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it was un-deprecated. It's no longer documented as such. I guess it's safer to keep as this.

lockFiles: ['pnpm-lock.yaml'],
forceEnvironment: 'NETLIFY_USE_PNPM',
},
[PkgManager.NPM]: {
name: PkgManager.NPM,
installCommand: 'npm install',
runCommand: 'npm run',
localPackageCommand: 'npx',
remotePackageCommand: ['npx'],
lockFiles: ['package-lock.json'],
},
[PkgManager.BUN]: {
name: PkgManager.BUN,
installCommand: 'bun install',
runCommand: 'bun run',
localPackageCommand: 'bunx',
remotePackageCommand: ['bunx'],
lockFiles: ['bun.lockb', 'bun.lock'],
},
}

/**
* The environment variable `npm_config_user_agent` can be used to
* guess the package manager that was used to execute a script.
* It's imperfect (just like regular user agent sniffing!)
* but the package managers we support all set this property:
*
* - [npm](https://github.com/npm/cli/blob/1415b4bdeeaabb6e0ba12b6b1b0cc56502bd64ab/lib/utils/config/definitions.js#L1945-L1979)
* - [pnpm](https://github.com/pnpm/pnpm/blob/cd4f9341e966eb8b411462b48ff0c0612e0a51a7/packages/plugin-commands-script-runners/src/makeEnv.ts#L14)
* - [yarn](https://yarnpkg.com/advanced/lifecycle-scripts#environment-variables)
* - [bun](https://github.com/oven-sh/bun/blob/550522e99b303d8172b7b16c5750d458cb056434/src/Global.zig#L205)
*/
export function sniffUserAgent(): PkgManager | undefined {
const userAgent = process.env.npm_config_user_agent
if (userAgent === undefined) {
return undefined
}

if (userAgent.includes('yarn')) {
return PkgManager.YARN
}

if (userAgent.includes('pnpm')) {
return PkgManager.PNPM
}

if (userAgent.includes('bun')) {
return PkgManager.BUN
}

// npm should come last as it is included in the user agent strings of other package managers
if (userAgent.includes('npm')) {
return PkgManager.NPM
}
}

/**
* generate a map out of key is lock file and value the package manager
* this is to reduce the complexity in loops
Expand All @@ -74,6 +121,8 @@ const lockFileMap = Object.values(AVAILABLE_PACKAGE_MANAGERS).reduce(
* 3. a lock file that is present in this directory or up in the tree for workspaces
*/
export const detectPackageManager = async (project: Project): Promise<PkgManagerFields | null> => {
const sniffedPkgManager = sniffUserAgent()

try {
const pkgPaths = await project.fs.findUpMultiple('package.json', {
cwd: project.baseDirectory,
Expand All @@ -82,7 +131,7 @@ export const detectPackageManager = async (project: Project): Promise<PkgManager

// if there is no package json than there is no package manager to detect
if (!pkgPaths.length) {
return null
return sniffedPkgManager ? AVAILABLE_PACKAGE_MANAGERS[sniffedPkgManager] : null
}

for (const pkgPath of pkgPaths) {
Expand Down Expand Up @@ -122,7 +171,10 @@ export const detectPackageManager = async (project: Project): Promise<PkgManager
} catch (error) {
project.report(error)
}
// always default to npm
// TODO: add some reporting here to log that we fall backed
if (sniffedPkgManager) {
return AVAILABLE_PACKAGE_MANAGERS[sniffedPkgManager]
}

// TODO: add some reporting here to log that we fall backe to NPM
return AVAILABLE_PACKAGE_MANAGERS[PkgManager.NPM]
}
5 changes: 5 additions & 0 deletions packages/build-info/tests/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ test('CLI does not print js-workspaces if given a project without it', async (ct
"name": "pnpm",
"installCommand": "pnpm install",
"runCommand": "pnpm run",
"localPackageCommand": "pnpm",
"remotePackageCommand": [
"pnpm",
"dlx"
],
"lockFiles": [
"pnpm-lock.yaml"
],
Expand Down
6 changes: 5 additions & 1 deletion packages/build-info/tests/test-setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'fs'

import { afterEach, vi } from 'vitest'
import { afterEach, beforeEach, vi } from 'vitest'

vi.mock('fs', async () => {
const unionFs: any = (await import('unionfs')).default
Expand All @@ -13,6 +13,10 @@ vi.mock('fs', async () => {
return { default: united, ...united }
})

beforeEach(() => {
vi.stubEnv('npm_config_user_agent', undefined)
})

// cleanup after each test as a fallback if someone forgot to call it
afterEach(async ({ cleanup }) => {
if (typeof cleanup === 'function') {
Expand Down
Loading