npm 的 postinstall 到底在做什么?从我写的两个 CLI 讲起
前两篇分别介绍了 shane-new-post 和 shane-new-doc 怎么用,这篇往底层挖一点,讲讲它们能”装完就用、不用手动配置”这件事背后靠的是什么机制——npm 的生命周期脚本,具体来说是 postinstall。
生命周期脚本是什么
npm、pnpm、yarn 这些包管理器都支持在 package.json 里声明一批”生命周期脚本”,在安装过程的特定节点自动触发,常见的几个:
| 脚本名 | 触发时机 |
|---|---|
preinstall | 依赖安装之前 |
install | 依赖安装过程中(有原生模块编译需求时常用) |
postinstall | 依赖安装完成之后 |
postpack | 打包成 tarball 之后 |
prepare | 发布前 / 本地 npm install 后(用途较杂) |
这套机制不是哪个具体项目发明的,是包管理器本身的内置能力,任何一个 npm 包只要在自己的 package.json 里写了对应字段,装它的人就会自动触发这些脚本——这也是为什么原生模块(比如需要编译 C++ 绑定的那些包)能在 npm install 之后自动完成编译,靠的就是这套机制。
shane-new-post 和 shane-new-doc 用的正是 postinstall:
{
"scripts": {
"postinstall": "node bin/postinstall.js"
}
}
依赖装完的那一刻,bin/postinstall.js 就会自动跑一遍,帮你把脚本文件、配置文件、package.json 里的命令都准备好——这也是为什么用户装完直接就能 pnpm new-doc,不需要任何额外的手动配置步骤。
postinstall.js 具体做了哪些事
拿 shane-new-doc 举例(shane-new-post 走的是一模一样的流程,只是文件名、字段名不一样),按执行顺序大致是这样:
- 找到真正的项目根目录。脚本自己躺在
node_modules/shane-new-doc/bin/深处,离项目根目录隔了好几层,所以第一步是读process.env.INIT_CWD——这是 npm / pnpm 在执行安装命令时自动设置的环境变量,指向”你实际敲下安装命令的那个目录”,读不到就退回process.cwd()。 - 读项目自己的
package.json,读不到就直接退出,不做任何事——没有项目就没有配置的对象。 - 看看有没有已经存在的配置文件(
.shane-new-doc.json),先读出来,方便后面判断语言这些设置有没有被保存过。 - 决定接下来所有提示信息用什么语言,按优先级依次判断:命令行传的
--lang参数、SHANE_CLI_LANG环境变量、配置文件里存的lang字段、操作系统 / 终端的 locale,最后兜底成英文。只有最后这个”猜地区”的方式才会触发交互式提问,其它几种来源都被认为”足够确定”,不需要再多问一句。 - 检查是不是在包自己的仓库里跑。如果
package.json里的包名就是shane-new-doc本身(意味着这是这个包自己在开发环境里跑pnpm install),直接跳过,防止工具把自己的源码目录当成”目标项目”来生成文件。 - 检查目标项目是不是用得上这个工具。
shane-new-doc会去扫dependencies、devDependencies、optionalDependencies里有没有@astrojs/starlight(shane-new-post检查的是astro),没有就打印一句”这不是 Starlight 项目”然后退出——后面的步骤全部不会执行。这一步存在的意义是:生成出来的脚本是写死按 Starlight 目录结构和 frontmatter 格式来的,装进无关项目里只会是一堆没用的死代码。 - 确定脚本要放的目录,固定是
<项目根目录>/scripts。 - 扫描这个目录,看有没有同类型的脚本已经存在,按一份固定的候选文件名列表依次检查(
new-doc.js、new docs.js、newdoc.js等好几种常见写法),命中第一个存在的文件就复用它作为目标——这是为了照顾那种”你之前手写过一个功能类似的脚本”的情况,不会平白无故再造一个重复文件出来。 - 判断目标文件是不是已经被这个工具”接管”过。读文件开头 200 个字符,找一个固定的标记字符串
// @generated by shane-new-doc。找到了,说明这个文件是之前装的时候生成的,脚本本身不会再被覆盖(保护你后续手动做的任何修改),但package.json里的scripts字段仍然会被重新检查并按需补上——因为这部分即使被你手滑删掉了,重新写回去也是安全的。 - 如果还没被接管过,问最后一个问题——
shane-new-doc问的是图片文件夹的路径(默认public/img),shane-new-post问的是默认作者名(默认留空)。只要当前不是交互式终端(process.stdin.isTTY为假),这一步会自动跳过、直接用默认值,CI 环境或者脚本化安装不会被卡住。 - 写脚本文件:创建
scripts/目录(如果还不存在);如果目标路径上已经有一个同名但还没被标记接管的文件(说明是你自己之前放的),先备份成<文件名>.js.bak再动手;把包内置的模板脚本内容写进去,并且把标记字符串插在 shebang 那一行之后(不能插在前面,不然这个文件就没法再直接当可执行脚本跑了)。 - 写配置文件(
.shane-new-doc.json)。如果这个文件已经存在,这一步直接跳过——不管里面的内容是工具自己之前写的还是你手动改过的,都不会被覆盖。 - 往
package.json的scripts字段里注入一条命令(比如"new-doc": "node scripts/new-doc.js"),如果这一条已经存在就什么都不做,不会重复写入或者打印多余的提示。
为什么现在很多包管理器要拦截它
新版 pnpm(v9/v10)默认会阻止新依赖执行安装脚本,包括 postinstall。这不是针对某个具体的包,而是一个安全层面的通用防护:postinstall 本质上是”装了这个包,作者就能在你电脑上跑任意代码”,历史上出现过不少供应链攻击就是钻了这个空子——发布一个正常的包,然后在某次更新里往 postinstall 里塞恶意代码,等着依赖它的项目自动执行。
pnpm 的做法是默认拦截,需要显式允许:
pnpm approve-builds
或者提前在 package.json 里写好白名单:
"pnpm": { "onlyBuiltDependencies": ["shane-new-post", "shane-new-doc"] }
npm 和经典版 yarn(v1)目前还是默认放行的,但也支持通过 --ignore-scripts 全局关闭这个行为。
如果装完 pnpm new-doc 提示脚本不存在,八成就是这个原因——包管理器把 postinstall 拦下来了,scripts/new-doc.js 根本没被生成。手动跑一遍 node node_modules/shane-new-doc/bin/postinstall.js 就能补上,效果和自动触发完全一样,只是换成手动执行。
一键安装命令做得更彻底
npm create shane-new-doc@latest 这类一键安装命令,实际上比单纯装个依赖多做了几件事:先扫描整个项目里有没有同名脚本可能冲突,交互式问你要不要清理;自动识别当前用的包管理器;跑真正的安装命令;安装完之后再检查一遍有没有生成成功——如果没有(说明 postinstall 被跳过了),会自动手动补跑一遍,保证不管包管理器是什么策略,最后的状态都是一致的。这也是为什么文档里会推荐优先用一键命令而不是手动 npm install -D 的原因。
想看更细的内容
安装过程里每一步具体的判断逻辑、候选文件名的完整列表、还有一个容易踩坑的命名冲突问题(shane-new-doc 自己也注册了一个叫 create-shane-new-doc 的本地二进制,跟真正的独立安装包同名但功能完全不同),都写在文档站里了:
查看安装内部机制完整文档 →
写这两个工具的过程,某种程度上也是把自己以前只是”用过、没深究过”的 npm 生命周期机制,重新完整过了一遍——纸上得来终觉浅,真自己写一遍才知道中间有多少边界情况需要处理。