airi 项目的 pnpm Hooks(.pnpmfile.mjs)实战指南:定制依赖解析、配置与拉取行为
本篇围绕 pnpm 的 .pnpmfile.mjs 钩子机制展开:它声明在哪里、有哪些钩子(readPackage、updateConfig、beforePacking 等)、如何编写 Finders 与自定义 resolver/fetcher,以及它与 pnpm-workspace.yaml 中 overrides 的分工。读完后你能在大仓 monorepo 中用 JavaScript 条件逻辑修复第三方依赖的 manifest 问题、程序化调整 pnpm 设置、定制发布产物清单,并理解 airi 这类以 pnpm 11 驱动的工作区(根目录 package.json 中 "packageManager": "pnpm@11.24.0")为什么大量使用声明式配置而把 hooks 留作"条件逻辑兜底"。
pnpmfile 的位置与格式
pnpm hooks 用于定制安装过程。钩子声明在 .pnpmfile.mjs(ESM,推荐)或 .pnpmfile.cjs(CommonJS)中,文件必须放在 lockfile 旁边——对 monorepo 来说就是工作区根目录。
- 现代写法使用 ESM 的
export const hooks = { ... }; - 旧的 CommonJS
module.exports = { hooks }仍可在.pnpmfile.cjs中使用。
最简骨架如下:
export const hooks = {
readPackage,
afterAllResolved,
updateConfig,
beforePacking,
}
提示:
--ignore-scripts不会禁用 pnpmfile;要让 pnpm 完全忽略它,需要用下面的ignorePnpmfile设置。
钩子总览:6 个钩子分别在什么时机触发
| 钩子 | 触发时机 | 典型用途 |
|---|---|---|
readPackage(pkg, ctx) |
依赖 manifest 被解析之后 | 修改依赖的 package.json(影响解析结果) |
afterAllResolved(lockfile, ctx) |
依赖解析完成之后 | 在 lockfile 落盘前修改 lockfile |
updateConfig(config) |
安装开始之前 | 修改 pnpm 自身设置(与 config dependency 搭配威力最大) |
beforePacking(pkg) |
pnpm pack/publish 生成 tarball 之前 |
只定制即将发布的 manifest |
preResolution(opts) |
读取 lockfile 之后、开始解析之前 | 查看/修改 lockfile 对象 |
importPackage(dir, opts) |
向 node_modules 写入时 | 改变包的链接方式 |
下面逐一看最常用的几个钩子的完整写法。
readPackage:在解析前修改依赖清单
readPackage 在解析前对每个包调用,是修复第三方包 manifest 缺陷的主力。常见用法包括:补齐缺失的 peer 依赖、钉住某个传递依赖版本、删掉有问题的 optional 依赖、替换已废弃的包:
function readPackage(pkg, context) {
// Add a missing peer dependency
if (pkg.name === 'some-broken-package') {
pkg.peerDependencies = { ...pkg.peerDependencies, react: '*' }
}
// Pin a transitive version
if (pkg.dependencies?.lodash) pkg.dependencies.lodash = '^4.17.21'
// Drop a problematic optional dep
delete pkg.optionalDependencies?.fsevents
// Replace a deprecated dep
if (pkg.dependencies?.['old-pkg']) {
pkg.dependencies['new-pkg'] = pkg.dependencies['old-pkg']
delete pkg.dependencies['old-pkg']
}
return pkg
}
export const hooks = { readPackage }
这里有几个必须清楚的行为边界:
- 修改不会写回磁盘,只影响本次解析。对已经被锁定的依赖重新解析,需要删除
pnpm-lock.yaml; - 在这里删掉依赖的
scripts并不能阻止其构建脚本执行——控制安装期构建应使用allowBuilds设置; - 如果想把对某个依赖文件修改持久化下来,应使用
pnpm patch。
这三点在 airi 仓库里都能找到对应的落地形态。airi 的 pnpm-workspace.yaml 使用 allowBuilds 白名单精确控制哪些包允许执行安装期脚本(如 electron、esbuild、sharp、uiohook-napi 等设为 true,@prisma/client、better-sqlite3 设为 false),而不是靠删 scripts;它通过 patchedDependencies 持久化了对 5 个第三方包的补丁(见 pnpm-workspace.yaml),对应的补丁文件就在 patches/ 目录下(mineflayer-pathfinder、pixi-live2d-display、sponsorkit@17.1.0、tab-election@4.6.2、uiohook-napi@1.5.5)。这正是文档中"删 scripts 不生效,用 allowBuilds;持久化修改用 pnpm patch"两条告诫的真实用例。
另外注意:readPackage 能做的"改 manifest"操作,很多有更轻的声明式替代方案。airi 的 pnpm-workspace.yaml 用 overrides 完成了 axios: npm:feaxios@^0.0.23、isarray: npm:@nolyfill/isarray@^1.0.44 这类"包替换",并在 packageExtensions 段 里声明式地为 @formkit/auto-animate、vitepress、@tresjs/core 等补充了缺失的 peerDependencies——后者正是 readPackage 中"补齐 peer 依赖"示例的无 JS 版本。相关的声明式能力可参阅 Overrides 参考 与 Patches 参考。
updateConfig:程序化修改 pnpm 配置
updateConfig 在安装前修改 pnpm 自身的设置,最强大的场景是把它打进一个 config dependency 里,让多个仓库共享同一份设置:
export const hooks = {
updateConfig(config) {
return Object.assign(config, {
enablePrePostScripts: false,
optimisticRepeatInstall: true,
resolutionMode: 'lowest-direct',
verifyDepsBeforeRun: 'install',
})
}
}
也可以由插件向工作区注入 catalog 条目——消费者此后就能用 pnpm add is-odd@catalog: 安装到注入的版本:
// Add a catalog entry from a plugin
export const hooks = {
updateConfig(config) {
config.catalogs.default ??= {}
config.catalogs.default['is-odd'] = '1.0.0'
return config
}
}
airi 工作区本身就是 catalog 的重度用户:pnpm-workspace.yaml 中 catalog:(默认目录,400+ 条目)与命名目录 catalogs.vitest、catalogs.xsai 统一收敛版本,catalogMode: prefer 则让子包声明版本时优先对齐目录。而 airi 在仓库内自带了 config dependency 的完整参考文档 features-config-dependencies:它说明了 configDependencies 声明于 pnpm-workspace.yaml、在常规依赖之前安装、不允许常规 dependencies 与 lifecycle scripts,且 pnpm-plugin-* / @pnpm/plugin-* 命名的包其 pnpmfile.mjs 会被自动加载——这正是与 updateConfig 配合共享设置与 catalog 的推荐路径。
beforePacking 与 afterAllResolved
beforePacking 用于定制最终进入发布 tarball 的 manifest,而不必改动本地 package.json:
export const hooks = {
beforePacking(pkg) {
delete pkg.devDependencies
pkg.main = './dist/index.js'
return pkg
}
}
afterAllResolved 在依赖解析全部完成后触发,适合在 lockfile 写入前做审计、日志或修正:
export const hooks = {
afterAllResolved(lockfile, context) {
context.log(`Resolved ${Object.keys(lockfile.packages || {}).length} packages`)
return lockfile
}
}
Finders:给 pnpm list / why 注册自定义判定
Finders 是自定义谓词函数,通过命令行 --find-by 使用。例如定义一个"找出声明了 react@^17.0.0 peer 依赖的包"的 finder:
export const finders = {
react17: (ctx) => ctx.readManifest().peerDependencies?.react === '^17.0.0'
}
然后:
pnpm why --find-by=react17
这对排查大型 monorepo 中"到底是谁声明了某个版本的 peer 依赖"非常实用——比 airi 这样包含 apps/**、packages/**、plugins/**、integrations/**、services/**、engines/**、server/** 多组 glob 的工作区(见 pnpm-workspace.yaml 的 packages 段)更容易失控。
高级用法:自定义 resolvers 与 fetchers
注册顶层 resolvers/fetchers 可以支持新的包方案(如 my-protocol:pkg)。每个都是带廉价守卫 canResolve/canFetch 加上 resolve/fetch 的对象。自定义 resolver 先于内置 resolver 执行;自定义解析结果的 type 字段必须使用 custom: 前缀:
const resolver = {
canResolve: (dep) => dep.alias.startsWith('@company/'),
resolve: async (dep) => ({
id: `${dep.alias}@${dep.bareSpecifier}`,
resolution: { type: 'custom:cdn', cdnUrl: '...' },
}),
}
const fetcher = {
canFetch: (id, res) => res.type === 'custom:cdn',
fetch: (cafs, res, opts, fetchers) =>
fetchers.remoteTarball(cafs, { tarball: res.cdnUrl, integrity: res.integrity }, opts),
}
module.exports = { resolvers: [resolver], fetchers: [fetcher] }
版本注意:
hooks.fetchers在 pnpm v11 已被移除——请改用顶层fetchers导出。airi 根目录package.json声明的packageManager为pnpm@11.24.0,属于 v11 行为,采用本节的顶层导出写法。
pnpmfile 相关设置
ignorePnpmfile: false # ignore the pnpmfile entirely
pnpmfile: ['.pnpmfile.mjs'] # local pnpmfile location(s)
globalPnpmfile: ~/.pnpm/global_pnpmfile.mjs
ignorePnpmfile:完全忽略 pnpmfile(注意与--ignore-scripts的区别);pnpmfile:指定本地 pnpmfile 的位置,可列多个;globalPnpmfile:全局生效的 pnpmfile 路径。
Hooks vs Overrides:如何选择
| Hooks (.pnpmfile) | Overrides (pnpm-workspace.yaml) | |
|---|---|---|
| 逻辑形式 | JavaScript | 声明式 |
| 作用范围 | 任意 manifest 字段、配置、lockfile、打包 | 版本号 |
| 适用场景 | 条件/复杂修复 | 简单的版本钉住 |
原则是:简单情况优先用 overrides/packageExtensions,条件逻辑、跨仓库配置共享、打包定制才上 hooks。
从源码结构看,airi 当前仓库并没有提交任何 .pnpmfile.* 文件(全仓检索无结果),其依赖治理完全落在 pnpm-workspace.yaml 的声明式设施上:overrides 做包替换与版本钉住、packageExtensions 补 peer 依赖、patchedDependencies 持久化补丁、allowBuilds 控制安装期脚本、catalog/catalogs 收敛版本。这与文档的选型建议完全吻合:声明式能覆盖的就没有引入 JS 钩子的必要;而当出现"按包名条件分支修改 manifest""把配置以 config dependency 形式分发给其他仓库"这类需求时,才是 .pnpmfile.mjs 的用武之地。
要点回顾
- 优先使用
.pnpmfile.mjs加export const hooks/finders/resolvers/fetchers; - 新钩子:
updateConfig(改设置)、beforePacking(发布 manifest)、preResolution、importPackage; - 把
updateConfig与 config dependencies 配合,可跨仓库共享设置与 catalog; --ignore-scripts不能禁用 pnpmfile,请用ignorePnpmfile;readPackage的修改不落盘、不阻止构建脚本;持久化修改依赖文件请用pnpm patch(airi 的 patches/ 目录即是其产物)。
延伸阅读:Hooks 参考原文、Overrides 参考、Config Dependencies 参考、Patches 参考、pnpm skill 总入口 SKILL.md。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00