Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
6 changes: 6 additions & 0 deletions .changeset/real-papers-cut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@pnpm/pnpmfile": minor
"pnpm": minor
---

Added support for pnpmfiles written in ESM. They should have the `.mjs` extension: `.pnpmfile.mjs` [#9730](https://github.com/pnpm/pnpm/pull/9730).
2 changes: 1 addition & 1 deletion cli/cli-utils/src/getConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function getConfig (
const configModulesDir = path.join(config.lockfileDir ?? config.rootProjectManifestDir, 'node_modules/.pnpm-config')
pnpmfiles.unshift(...calcPnpmfilePathsOfPluginDeps(configModulesDir, config.configDependencies))
}
const { hooks, finders, resolvedPnpmfilePaths } = requireHooks(config.lockfileDir ?? config.dir, {
const { hooks, finders, resolvedPnpmfilePaths } = await requireHooks(config.lockfileDir ?? config.dir, {
globalPnpmfile: config.globalPnpmfile,
pnpmfiles,
tryLoadDefaultPnpmfile: config.tryLoadDefaultPnpmfile,
Expand Down
10 changes: 5 additions & 5 deletions hooks/pnpmfile/src/requireHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ export interface RequireHooksResult {
resolvedPnpmfilePaths: string[]
}

export function requireHooks (
export async function requireHooks (
prefix: string,
opts: {
globalPnpmfile?: string
pnpmfiles?: string[]
tryLoadDefaultPnpmfile?: boolean
}
): RequireHooksResult {
): Promise<RequireHooksResult> {
const pnpmfiles: PnpmfileEntry[] = []
if (opts.globalPnpmfile) {
pnpmfiles.push({
Expand All @@ -77,11 +77,11 @@ export function requireHooks (
}
const entries: PnpmfileEntryLoaded[] = []
const loadedFiles: string[] = []
for (const { path, includeInChecksum, optional } of pnpmfiles) {
await Promise.all(pnpmfiles.map(async ({ path, includeInChecksum, optional }) => {
const file = pathAbsolute(path, prefix)
if (!loadedFiles.includes(file)) {
loadedFiles.push(file)
const requirePnpmfileResult = requirePnpmfile(file, prefix)
const requirePnpmfileResult = await requirePnpmfile(file, prefix)
if (requirePnpmfileResult != null) {
entries.push({
file,
Expand All @@ -93,7 +93,7 @@ export function requireHooks (
throw new PnpmError('PNPMFILE_NOT_FOUND', `pnpmfile at "${file}" is not found`)
}
}
}
}))

const mergedFinders: Finders = {}
const cookedHooks: CookedHooks & Required<Pick<CookedHooks, 'readPackage' | 'preResolution' | 'afterAllResolved' | 'filterLog' | 'updateConfig'>> = {
Expand Down
18 changes: 15 additions & 3 deletions hooks/pnpmfile/src/requirePnpmfile.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import assert from 'assert'
import fs from 'fs'
import path from 'path'
import util from 'util'
import { pathToFileURL } from 'url'
import { createRequire } from 'module'
import { PnpmError } from '@pnpm/error'
import { logger } from '@pnpm/logger'
Expand Down Expand Up @@ -37,9 +39,18 @@ export interface Pnpmfile {
finders?: Finders
}

export function requirePnpmfile (pnpmFilePath: string, prefix: string): { pnpmfileModule: Pnpmfile | undefined } | undefined {
export async function requirePnpmfile (pnpmFilePath: string, prefix: string): Promise<{ pnpmfileModule: Pnpmfile | undefined } | undefined> {
try {
const pnpmfile: Pnpmfile = require(pnpmFilePath)
let pnpmfile: Pnpmfile
// Check if it's an ESM module (ends with .mjs)
if (pnpmFilePath.endsWith('.mjs')) {
const url = pathToFileURL(path.resolve(pnpmFilePath)).href
const module = await import(url)
pnpmfile = module.default || module
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

The fallback logic module.default || module may be unclear. Consider being more explicit about when to use default (for default exports) vs the module itself (for named exports like export const hooks = {}). The current logic could mask issues if both exist.

Suggested change
pnpmfile = module.default || module
if ('default' in module && Object.keys(module).length > 1) {
throw new PnpmFileFailError(
pnpmFilePath,
new Error(
'The pnpmfile exports both a default export and named exports. This is ambiguous. Please export either a default or named exports, not both.'
)
)
}
pnpmfile = 'default' in module ? module.default : module

Copilot uses AI. Check for mistakes.
} else {
// Use require for CommonJS modules
pnpmfile = require(pnpmFilePath)
}
if (typeof pnpmfile === 'undefined') {
logger.warn({
message: `Ignoring the pnpmfile at "${pnpmFilePath}". It exports "undefined".`,
Expand Down Expand Up @@ -89,8 +100,9 @@ export function requirePnpmfile (pnpmFilePath: string, prefix: string): { pnpmfi
}

function pnpmFileExistsSync (pnpmFilePath: string): boolean {
const pnpmFileRealName = pnpmFilePath.endsWith('.cjs')
const pnpmFileRealName = pnpmFilePath.endsWith('.cjs') || pnpmFilePath.endsWith('.mjs')
? pnpmFilePath
: `${pnpmFilePath}.cjs`
return fs.existsSync(pnpmFileRealName)
Comment on lines +102 to 105
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

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

The function pnpmFileExistsSync only falls back to .cjs extension when neither .cjs nor .mjs extensions are present. This means .mjs files without explicit extension won't be found, which could be inconsistent behavior. Consider adding fallback logic for .mjs files as well.

Suggested change
const pnpmFileRealName = pnpmFilePath.endsWith('.cjs') || pnpmFilePath.endsWith('.mjs')
? pnpmFilePath
: `${pnpmFilePath}.cjs`
return fs.existsSync(pnpmFileRealName)
if (pnpmFilePath.endsWith('.cjs') || pnpmFilePath.endsWith('.mjs')) {
return fs.existsSync(pnpmFilePath)
}
return fs.existsSync(`${pnpmFilePath}.cjs`) || fs.existsSync(`${pnpmFilePath}.mjs`)

Copilot uses AI. Check for mistakes.
}

38 changes: 19 additions & 19 deletions hooks/pnpmfile/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,32 @@ import { requirePnpmfile } from '../src/requirePnpmfile.js'
const defaultHookContext: HookContext = { log () {} }
const f = fixtures(import.meta.dirname)

test('ignoring a pnpmfile that exports undefined', () => {
const { pnpmfileModule: pnpmfile } = requirePnpmfile(path.join(import.meta.dirname, '__fixtures__/undefined.js'), import.meta.dirname)!
test('ignoring a pnpmfile that exports undefined', async () => {
const { pnpmfileModule: pnpmfile } = (await requirePnpmfile(path.join(import.meta.dirname, '__fixtures__/undefined.js'), import.meta.dirname))!
expect(pnpmfile).toBeUndefined()
})

test('readPackage hook run fails when returns undefined', () => {
test('readPackage hook run fails when returns undefined', async () => {
const pnpmfilePath = path.join(import.meta.dirname, '__fixtures__/readPackageNoReturn.js')
const { pnpmfileModule: pnpmfile } = requirePnpmfile(pnpmfilePath, import.meta.dirname)!
const { pnpmfileModule: pnpmfile } = (await requirePnpmfile(pnpmfilePath, import.meta.dirname))!

return expect(
pnpmfile!.hooks!.readPackage!({}, defaultHookContext)
).rejects.toEqual(new BadReadPackageHookError(pnpmfilePath, 'readPackage hook did not return a package manifest object.'))
})

test('readPackage hook run fails when returned dependencies is not an object', () => {
test('readPackage hook run fails when returned dependencies is not an object', async () => {
const pnpmfilePath = path.join(import.meta.dirname, '__fixtures__/readPackageNoObject.js')
const { pnpmfileModule: pnpmfile } = requirePnpmfile(pnpmfilePath, import.meta.dirname)!
const { pnpmfileModule: pnpmfile } = (await requirePnpmfile(pnpmfilePath, import.meta.dirname))!
return expect(
pnpmfile!.hooks!.readPackage!({}, defaultHookContext)
).rejects.toEqual(new BadReadPackageHookError(pnpmfilePath, 'readPackage hook returned package manifest object\'s property \'dependencies\' must be an object.'))
})

test('filterLog hook combines with the global hook', () => {
test('filterLog hook combines with the global hook', async () => {
const globalPnpmfile = path.join(import.meta.dirname, '__fixtures__/globalFilterLog.js')
const pnpmfile = path.join(import.meta.dirname, '__fixtures__/filterLog.js')
const { hooks } = requireHooks(import.meta.dirname, { globalPnpmfile, pnpmfiles: [pnpmfile] })
const { hooks } = await requireHooks(import.meta.dirname, { globalPnpmfile, pnpmfiles: [pnpmfile] })

expect(hooks.filterLog).toBeDefined()
expect(hooks.filterLog!).toHaveLength(2)
Expand All @@ -49,55 +49,55 @@ test('filterLog hook combines with the global hook', () => {
})).toBeFalsy()
})

test('ignoring the default pnpmfile if tryLoadDefaultPnpmfile is not set', () => {
const { hooks } = requireHooks(path.join(import.meta.dirname, '__fixtures__/default'), {})
test('ignoring the default pnpmfile if tryLoadDefaultPnpmfile is not set', async () => {
const { hooks } = await requireHooks(path.join(import.meta.dirname, '__fixtures__/default'), {})
expect(hooks.readPackage?.length).toBe(0)
})

test('loading the default pnpmfile if tryLoadDefaultPnpmfile is set to true', () => {
const { hooks } = requireHooks(path.join(import.meta.dirname, '__fixtures__/default'), { tryLoadDefaultPnpmfile: true })
test('loading the default pnpmfile if tryLoadDefaultPnpmfile is set to true', async () => {
const { hooks } = await requireHooks(path.join(import.meta.dirname, '__fixtures__/default'), { tryLoadDefaultPnpmfile: true })
expect(hooks.readPackage?.length).toBe(1)
})

test('calculatePnpmfileChecksum is undefined when pnpmfile does not exist', async () => {
const { hooks } = requireHooks(import.meta.dirname, {})
const { hooks } = await requireHooks(import.meta.dirname, {})
expect(hooks.calculatePnpmfileChecksum).toBeUndefined()
})

test('calculatePnpmfileChecksum resolves to hash string for existing pnpmfile', async () => {
const pnpmfile = path.join(import.meta.dirname, '__fixtures__/readPackageNoObject.js')
const { hooks } = requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
const { hooks } = await requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
expect(typeof await hooks.calculatePnpmfileChecksum?.()).toBe('string')
})

test('calculatePnpmfileChecksum is undefined if pnpmfile even when it exports undefined', async () => {
const pnpmfile = path.join(import.meta.dirname, '__fixtures__/undefined.js')
const { hooks } = requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
const { hooks } = await requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
expect(hooks.calculatePnpmfileChecksum).toBeUndefined()
})

test('updateConfig throws an error if it returns undefined', async () => {
const pnpmfile = path.join(import.meta.dirname, '__fixtures__/updateConfigReturnsUndefined.js')
const { hooks } = requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
const { hooks } = await requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
expect(() => hooks.updateConfig![0]!({})).toThrow('The updateConfig hook returned undefined')
})

test('requireHooks throw an error if one of the specified pnpmfiles does not exist', async () => {
expect(() => requireHooks(import.meta.dirname, { pnpmfiles: ['does-not-exist.cjs'] })).toThrow('is not found')
await expect(requireHooks(import.meta.dirname, { pnpmfiles: ['does-not-exist.cjs'] })).rejects.toThrow('is not found')
})

test('requireHooks throws an error if there are two finders with the same name', async () => {
const findersDir = f.find('finders')
const pnpmfile1 = path.join(findersDir, 'finderFoo1.js')
const pnpmfile2 = path.join(findersDir, 'finderFoo2.js')
expect(() => requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })).toThrow('Finder "foo" defined in both')
await expect(requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })).rejects.toThrow('Finder "foo" defined in both')
})

test('requireHooks merges all the finders', async () => {
const findersDir = f.find('finders')
const pnpmfile1 = path.join(findersDir, 'finderFoo1.js')
const pnpmfile2 = path.join(findersDir, 'finderBar.js')
const { finders } = requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })
const { finders } = await requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })
expect(finders.foo).toBeDefined()
expect(finders.bar).toBeDefined()
})
4 changes: 2 additions & 2 deletions pkg-manager/plugin-commands-installation/src/recursive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,8 @@ export async function recursive (
limitInstallation(async () => {
const hooks = opts.ignorePnpmfile
? {}
: (() => {
const { hooks: pnpmfileHooks } = requireHooks(rootDir, opts)
: await (async () => {
const { hooks: pnpmfileHooks } = await requireHooks(rootDir, opts)
return {
...opts.hooks,
...pnpmfileHooks,
Expand Down
19 changes: 19 additions & 0 deletions pnpm/test/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,25 @@ module.exports = {
expect(nodeModulesFiles).toContain('is-number')
})

test('loading an ESM pnpmfile', async () => {
prepare()

fs.writeFileSync('.pnpmfile.mjs', `
export const hooks = {
updateConfig: (config) => ({
...config,
nodeLinker: 'hoisted',
}),
}`, 'utf8')
writeYamlFile('pnpm-workspace.yaml', { pnpmfile: ['.pnpmfile.mjs'] })

await execPnpm(['add', 'is-odd@1.0.0'])

const nodeModulesFiles = fs.readdirSync('node_modules')
expect(nodeModulesFiles).toContain('kind-of')
expect(nodeModulesFiles).toContain('is-number')
})

test('loading multiple pnpmfiles', async () => {
prepare()

Expand Down
Loading