From 5b9adad9341f41123bed40990d0350a613b2a776 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 19 Nov 2025 12:54:31 +0000 Subject: [PATCH 1/2] Include user agent sniffing --- .../__snapshots__/get-build-info.test.ts.snap | 15 +++++ .../src/node/get-build-info.test.ts | 4 ++ .../detect-package-manager.test.ts | 15 ++++- .../detect-package-manager.ts | 58 ++++++++++++++++++- packages/build-info/tests/bin.test.ts | 5 ++ packages/build-info/tests/test-setup.ts | 6 +- 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap b/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap index 02862f3b95..e1f62ce41b 100644 --- a/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap +++ b/packages/build-info/src/node/__snapshots__/get-build-info.test.ts.snap @@ -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": [], @@ -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": [], @@ -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": [], diff --git a/packages/build-info/src/node/get-build-info.test.ts b/packages/build-info/src/node/get-build-info.test.ts index eaf2bbb305..665f35467d 100644 --- a/packages/build-info/src/node/get-build-info.test.ts +++ b/packages/build-info/src/node/get-build-info.test.ts @@ -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", } `) diff --git a/packages/build-info/src/package-managers/detect-package-manager.test.ts b/packages/build-info/src/package-managers/detect-package-manager.test.ts index 56eca38d94..a5d62d3209 100644 --- a/packages/build-info/src/package-managers/detect-package-manager.test.ts +++ b/packages/build-info/src/package-managers/detect-package-manager.test.ts @@ -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' @@ -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': '{}', diff --git a/packages/build-info/src/package-managers/detect-package-manager.ts b/packages/build-info/src/package-managers/detect-package-manager.ts index c4d958bb10..0ce1972a40 100644 --- a/packages/build-info/src/package-managers/detect-package-manager.ts +++ b/packages/build-info/src/package-managers/detect-package-manager.ts @@ -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 */ @@ -34,6 +38,8 @@ export const AVAILABLE_PACKAGE_MANAGERS: Record = name: PkgManager.YARN, installCommand: 'yarn install', runCommand: 'yarn run', + localPackageCommand: 'yarn', + remotePackageCommand: ['yarn', 'dlx'], lockFiles: ['yarn.lock'], forceEnvironment: 'NETLIFY_USE_YARN', }, @@ -41,6 +47,8 @@ export const AVAILABLE_PACKAGE_MANAGERS: Record = name: PkgManager.PNPM, installCommand: 'pnpm install', runCommand: 'pnpm run', + localPackageCommand: 'pnpm', + remotePackageCommand: ['pnpm', 'dlx'], lockFiles: ['pnpm-lock.yaml'], forceEnvironment: 'NETLIFY_USE_PNPM', }, @@ -48,16 +56,55 @@ export const AVAILABLE_PACKAGE_MANAGERS: Record = 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 wrangler. + * 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 @@ -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 => { + const sniffedPkgManager = sniffUserAgent() + try { const pkgPaths = await project.fs.findUpMultiple('package.json', { cwd: project.baseDirectory, @@ -82,7 +131,7 @@ export const detectPackageManager = async (project: Project): Promise { const unionFs: any = (await import('unionfs')).default @@ -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') { From 1b495d9b5c8b32b1506904987b8cdf38d35979ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Wed, 19 Nov 2025 13:09:04 +0000 Subject: [PATCH 2/2] Update packages/build-info/src/package-managers/detect-package-manager.ts Co-authored-by: Matt Kane --- .../build-info/src/package-managers/detect-package-manager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/build-info/src/package-managers/detect-package-manager.ts b/packages/build-info/src/package-managers/detect-package-manager.ts index 0ce1972a40..3a1f4c09dc 100644 --- a/packages/build-info/src/package-managers/detect-package-manager.ts +++ b/packages/build-info/src/package-managers/detect-package-manager.ts @@ -72,7 +72,7 @@ export const AVAILABLE_PACKAGE_MANAGERS: Record = /** * The environment variable `npm_config_user_agent` can be used to - * guess the package manager that was used to execute wrangler. + * 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: *