安装原理详解
写这一篇的目的只有一个:“装这个东西到底对我的项目做了什么”这件事,不应该是一笔糊涂账。 下面写的每一条,都是包里自带的 bin/postinstall.js(以及一键安装器用到的 bin/create.js)实际执行逻辑的直接描述,按它真实运行的顺序排列——每一个检查、每一个可能被改动的文件、每一条可能打印的提示,都不省略。
举例统一用 shane-new-doc;shane-new-post 走的是完全相同的一套流程,只是把名字换了一遍(new-post.js 换掉 new-doc.js,.shane-new-post.json 换掉 .shane-new-doc.json,必需依赖是 astro 而不是 @astrojs/starlight,安装时问的那个问题是 author(作者名)而不是 imgDir(配图目录))。有实质区别的地方会单独标出来。
这段代码到底在什么时候会跑
Section titled “这段代码到底在什么时候会跑”postinstall.js 在包自己的 package.json 里被注册成了 postinstall 生命周期脚本:
{ "scripts": { "postinstall": "node bin/postinstall.js" }}npm、pnpm、yarn 都会在一个依赖装完之后自动执行它自己的 postinstall 脚本——这是包管理器内置的功能,不是这个项目自己加的东西。这也意味着,下面这几种情况下它不会跑:
- 安装命令带了
--ignore-scripts(CI 环境、安全策略里常见); - 或者包管理器默认就拦截新依赖的生命周期脚本——较新版本的 pnpm(v9/v10)默认就是这样,除非你用
pnpm approve-builds或者在package.json里配置了pnpm.onlyBuiltDependencies明确放行(见安装与快速开始)。
只要碰到上面任意一种情况,这一整页讲的内容都不会自动发生——手动触发的方法见验证是否装成功。
一步步拆解:postinstall.js 到底做了什么
Section titled “一步步拆解:postinstall.js 到底做了什么”-
确定项目真实的根目录。 先读
process.env.INIT_CWD,读不到再退回process.cwd()。之所以要用INIT_CWD,是因为这个脚本本身其实躺在node_modules/shane-new-doc/bin/里面,离它要修改的项目根目录隔了好几层,而 npm 和 pnpm 会把用户实际执行安装命令时所在的目录设置进这个环境变量。(经典 yarn v1 在这一点上不太可靠,见这份列表后面的提示。) -
读取项目自己的
package.json。 如果压根找不到这个文件,脚本立刻退出(process.exit(0)),后面什么都不做——没有项目可配置。 -
如果已经存在配置文件就先读进来——项目根目录下的
.shane-new-doc.json。这一步本身不改动任何东西,提前读进来是为了让下一步的语言判定能看到之前保存过的"lang"值。 -
判定接下来所有提示信息该用哪种语言。 按顺序,第一个命中的生效:
- 本次调用带的
--lang=en/--lang=zh-cn参数(或简写--en/--zh/--zh-cn/--chinese/--english); - 环境变量
SHANE_CLI_LANG; - 第 3 步读到的配置文件里的
"lang"字段; - 操作系统/终端的系统语言,先用
Intl.DateTimeFormat().resolvedOptions().locale探测,探测不到再退回$LC_ALL/$LANG/$LANGUAGE环境变量。
只有最后这个“系统语言猜测”会触发交互式提问——而且只有在
process.stdin.isTTY为真时才会问。前面几种来源都被视为已经足够明确,不会再问。如果是非交互式安装(CI、脚本化安装,或者输出被重定向),又没有任何明确来源,就会默默用系统语言的猜测结果,猜不到就用英文兜底。 - 本次调用带的
-
检查是不是包自己的仓库。 如果
pkg.name === "shane-new-doc"(也就是 postinstall 正在这个包自己的源码仓库里跑,比如它自己在 CI 或本地跑pnpm install的时候),打印一条跳过提示然后立刻退出。这是为了防止这个工具往自己的仓库里生成文件。 -
检查目标项目是不是真的在用对应的框架。 对
shane-new-doc来说,就是把dependencies、devDependencies、optionalDependencies合在一起扫一遍,看有没有@astrojs/starlight(shane-new-post检查的是astro)。如果没有,打印一条“不是 Starlight 项目”的提示然后退出——列表里后面的步骤全都不会执行。加这一层检查是因为生成出来的脚本是写死按 Starlight 的src/content/docs/目录结构和 frontmatter 格式来的,装进不相关的项目里只会是个没用的累赘。 -
确定脚本目录——固定是
<项目根目录>/scripts。 -
在这个目录里扫描是否已经有同类脚本,按下面这个固定顺序逐个检查候选文件名(区分大小写):
new-doc.jsnew doc.jsnew-docs.jsnew docs.jsnewdoc.jsnewdocs.js(
shane-new-post的候选列表更长:new-post.js、new post.js、new-blog.js、new blog.js、newpost.js、newblog.js、new posts.js、new-posts.js、newposts.js、new blogs.js、new-blogs.js、newblogs.js。)列表里第一个在磁盘上真实存在的名字,会被当成目标文件。这一点很重要:如果你在装这个包之前,项目里已经手写过一个比如叫newdoc.js的脚本,它会被直接复用当作目标文件名,而不会在旁边另外新建一个。 -
检查这个目标文件是不是已经被这个包“接管”过。 读取文件开头 200 个字符,找里面有没有
// @generated by shane-new-doc这个固定标记字符串。如果找到了: 5. 这个脚本文件不会再被改动(保护你后续对它做的任何手动修改); 6. 但package.json里的scripts那一条还是会(重新)注入一次——见第 13 步——因为这部分改动是安全的,而且有可能被手动删掉过; 7. 然后脚本退出。这就是让“重装/升级不会破坏手动修改”的核心机制:第一次安装时生成文件并打上标记,之后每次安装都会绕开这个文件,只保留你自己做的改动。
-
如果还没被接管过,才会问安装时唯一剩下的那个问题——对
shane-new-doc来说是配图目录(默认public/img);对shane-new-post来说是默认作者名(默认为空,为空就意味着生成的文章里干脆不写author这个 frontmatter 字段)。只要process.stdin.isTTY不为真(大部分 CI 和脚本化安装都是这样),这个提问会自动跳过,直接用默认值。 -
写脚本文件(
writeScript函数): 8. 如果scripts/目录还不存在就先创建,创建时打印一条提示; 9. 如果目标路径下已经有同名文件(但还没被标记为“已接管”——这种情况只会发生在第一次安装、且项目里本来就有个不相关的同名文件时),会先把它备份成<文件名>.js.bak,再动手改; 10. 从包自己的bin/new-doc.js里读出内置模板; 11. 把// @generated by shane-new-doc这行标记插在 shebang 行(#!/usr/bin/env node)的正后面——而不是前面,因为如果把注释放在 shebang 之前,这个文件就不能再被直接当作可执行脚本运行了; 12. 把结果写到scripts/目录下的目标路径; 13. 根据是否覆盖了已有文件,打印“已生成”或者“已接管现有文件”两种提示之一。 -
写配置文件(
writeConfig函数)——项目根目录下的.shane-new-doc.json,内容是{ "imgDir": ..., "lang": ... }。如果这个配置文件已经存在,这一步整个跳过——不管是你自己手动改过的,还是之前安装时生成的,都不会被覆盖。 -
注入 npm script(
injectPackageScript函数)——往package.json的"scripts"对象里加一条"new-doc": "node scripts/new-doc.js"(路径是第 11 步脚本实际落地位置的相对路径),然后用 2 空格缩进、末尾带换行符的格式重写整个package.json。如果这个键值对已经原样存在,这一步就什么都不做——不会重写文件,也不会打印提示。
以上每一步涉及打印提示的地方,用的都是第 4 步判定出来的语言——提示文字会变,但底层逻辑本身永远一样。
哪些已有文件名会被当成同一个脚本
Section titled “哪些已有文件名会被当成同一个脚本”第 8 步里两个工具完整的候选列表对照:
shane-new-doc |
shane-new-post |
|---|---|
new-doc.js |
new-post.js |
new doc.js |
new post.js |
new-docs.js |
new-blog.js |
new docs.js |
new blog.js |
newdoc.js |
newpost.js |
newdocs.js |
newblog.js |
| — | new posts.js |
| — | new-posts.js |
| — | newposts.js |
| — | new blogs.js |
| — | new-blogs.js |
| — | newblogs.js |
这份列表存在的唯一目的,就是避免在你已经有个名字、用途都很接近的脚本旁边,再重复生成一个新文件。
一键安装器在这基础上又多做了什么
Section titled “一键安装器在这基础上又多做了什么”执行 npm create shane-new-doc@latest / pnpm create shane-new-doc@latest 时,并不是直接跑一遍 postinstall.js 就完了——它会先跑一个独立的包装脚本(create-shane-new-doc 的 index.js),在依赖真正安装之前先自己做一轮检测:
- 提前判定语言,判定顺序和上面第 4 步一样(参数 → 环境变量 → 系统语言猜测),只是这时候还没有可以读的已保存配置,因为什么都还没装。如果是通过提问或参数确定的,会通过环境变量
SHANE_CLI_LANG转发给真正的安装过程,这样postinstall.js就不用再问一遍。 - 扫描整个项目目录树(递归扫描,排除
node_modules和.git),查找任何和上表候选名单同名的文件——不局限于scripts//script/目录,项目里任何位置都会查。已经带有// @generated by...标记的文件会被跳过(这不算冲突,是这个包自己之前生成的产物)。其余每一个匹配到的文件都会被标记出来,并且针对每一个都会交互式地问你要不要删掉。 - 要求当前目录下必须已经有
package.json——如果没有,安装器会立刻报错退出,提示你应该在项目根目录下运行这个命令。 - 判断该用哪个包管理器来做真正的安装,判断顺序是:先看环境变量
npm_config_user_agent(npm/pnpm/yarn 在拉起子进程时会自动设置这个变量,检查它是不是以pnpm/yarn/npm开头),如果判断不出来,再看项目里有哪个锁文件(pnpm-lock.yaml→ pnpm,yarn.lock→ yarn,package-lock.json→ npm),都没有的话默认用 pnpm。 - 执行真正的安装命令(根据上一步判断结果,对应
pnpm add shane-new-doc、npm install shane-new-doc --save,或者yarn add shane-new-doc),输出实时打印到终端。如果安装过程报错或者退出码不是 0,安装器会直接停下并报告失败。 - 二次确认安装是否真的完成了。 安装命令跑完之后,会再去
scripts/或script/目录里找一遍有没有已接管的脚本(判断方法和第 9 步的标记检查一样)。如果没找到——说明包管理器默默跳过了postinstall生命周期脚本(较新版本的 pnpm 默认就会这样),就会手动补跑一次node node_modules/shane-new-doc/bin/postinstall.js作为兜底,保证不管遇到哪种情况,装完之后都是能直接用的状态。 - 打印最终确认信息(“Initialization complete. Run: pnpm new-doc”),前提是上面每一步都顺利完成。
一个值得注意的命名冲突
Section titled “一个值得注意的命名冲突”shane-new-doc 这个包本身(不是那个独立的 create-shane-new-doc 包)在自己的 package.json 里其实还注册了第二个可执行命令:
{ "bin": { "new-doc": "bin/new-doc.js", "create-shane-new-doc": "bin/create.js" }}也就是说,只要 shane-new-doc 作为依赖装进了项目,node_modules/.bin/create-shane-new-doc 也会跟着存在——但它指向的是一个完全不同、简化得多的脚本(bin/create.js),跟前面讲的那个独立的 create-shane-new-doc 包不是一回事。这个本地版本只会问你配图目录,你确认之后就只写一个 .shane-new-doc.json——它不会扫描冲突文件,不会检查项目有没有 @astrojs/starlight,不会识别包管理器,最关键的是不会生成 scripts/new-doc.js,也不会往 package.json 的 scripts 里加任何东西。
shane-new-post的情况完全一样——它自己的 bin/create.js 会写一个带 postsDir 和 author 字段的 .shane-new-post.json,但同样从来不会生成 scripts/new-post.js。这两个本地的 bin/create.js,顶多算是一个不完整的“配置重置”小工具;真正要初始化项目,永远走 npm create shane-new-doc@latest / npm create shane-new-post@latest,或者安装与快速开始里的手动安装路径。