跳转到内容

安装原理详解

写这一篇的目的只有一个:“装这个东西到底对我的项目做了什么”这件事,不应该是一笔糊涂账。 下面写的每一条,都是包里自带的 bin/postinstall.js(以及一键安装器用到的 bin/create.js)实际执行逻辑的直接描述,按它真实运行的顺序排列——每一个检查、每一个可能被改动的文件、每一条可能打印的提示,都不省略。

举例统一用 shane-new-docshane-new-post 走的是完全相同的一套流程,只是把名字换了一遍(new-post.js 换掉 new-doc.js.shane-new-post.json 换掉 .shane-new-doc.json,必需依赖是 astro 而不是 @astrojs/starlight,安装时问的那个问题是 author(作者名)而不是 imgDir(配图目录))。有实质区别的地方会单独标出来。

postinstall.js 在包自己的 package.json 里被注册成了 postinstall 生命周期脚本:

node_modules/shane-new-doc/package.json
{
"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 到底做了什么”
  1. 确定项目真实的根目录。 先读 process.env.INIT_CWD,读不到再退回 process.cwd()。之所以要用 INIT_CWD,是因为这个脚本本身其实躺在 node_modules/shane-new-doc/bin/ 里面,离它要修改的项目根目录隔了好几层,而 npm 和 pnpm 会把用户实际执行安装命令时所在的目录设置进这个环境变量。(经典 yarn v1 在这一点上不太可靠,见这份列表后面的提示。)

  2. 读取项目自己的 package.json 如果压根找不到这个文件,脚本立刻退出(process.exit(0)),后面什么都不做——没有项目可配置。

  3. 如果已经存在配置文件就先读进来——项目根目录下的 .shane-new-doc.json。这一步本身不改动任何东西,提前读进来是为了让下一步的语言判定能看到之前保存过的 "lang" 值。

  4. 判定接下来所有提示信息该用哪种语言。 按顺序,第一个命中的生效:

    1. 本次调用带的 --lang=en / --lang=zh-cn 参数(或简写 --en / --zh / --zh-cn / --chinese / --english);
    2. 环境变量 SHANE_CLI_LANG
    3. 第 3 步读到的配置文件里的 "lang" 字段;
    4. 操作系统/终端的系统语言,先用 Intl.DateTimeFormat().resolvedOptions().locale 探测,探测不到再退回 $LC_ALL / $LANG / $LANGUAGE 环境变量。

    只有最后这个“系统语言猜测”会触发交互式提问——而且只有在 process.stdin.isTTY 为真时才会问。前面几种来源都被视为已经足够明确,不会再问。如果是非交互式安装(CI、脚本化安装,或者输出被重定向),又没有任何明确来源,就会默默用系统语言的猜测结果,猜不到就用英文兜底。

  5. 检查是不是包自己的仓库。 如果 pkg.name === "shane-new-doc"(也就是 postinstall 正在这个包自己的源码仓库里跑,比如它自己在 CI 或本地跑 pnpm install 的时候),打印一条跳过提示然后立刻退出。这是为了防止这个工具往自己的仓库里生成文件。

  6. 检查目标项目是不是真的在用对应的框架。shane-new-doc 来说,就是把 dependenciesdevDependenciesoptionalDependencies 合在一起扫一遍,看有没有 @astrojs/starlightshane-new-post 检查的是 astro)。如果没有,打印一条“不是 Starlight 项目”的提示然后退出——列表里后面的步骤全都不会执行。加这一层检查是因为生成出来的脚本是写死按 Starlight 的 src/content/docs/ 目录结构和 frontmatter 格式来的,装进不相关的项目里只会是个没用的累赘。

  7. 确定脚本目录——固定是 <项目根目录>/scripts

  8. 在这个目录里扫描是否已经有同类脚本,按下面这个固定顺序逐个检查候选文件名(区分大小写):

    new-doc.js
    new doc.js
    new-docs.js
    new docs.js
    newdoc.js
    newdocs.js

    shane-new-post 的候选列表更长:new-post.jsnew post.jsnew-blog.jsnew blog.jsnewpost.jsnewblog.jsnew posts.jsnew-posts.jsnewposts.jsnew blogs.jsnew-blogs.jsnewblogs.js。)列表里第一个在磁盘上真实存在的名字,会被当成目标文件。这一点很重要:如果你在装这个包之前,项目里已经手写过一个比如叫 newdoc.js 的脚本,它会被直接复用当作目标文件名,而不会在旁边另外新建一个。

  9. 检查这个目标文件是不是已经被这个包“接管”过。 读取文件开头 200 个字符,找里面有没有 // @generated by shane-new-doc 这个固定标记字符串。如果找到了: 5. 这个脚本文件不会再被改动(保护你后续对它做的任何手动修改); 6. 但 package.json 里的 scripts 那一条还是会(重新)注入一次——见第 13 步——因为这部分改动是安全的,而且有可能被手动删掉过; 7. 然后脚本退出。

    这就是让“重装/升级不会破坏手动修改”的核心机制:第一次安装时生成文件并打上标记,之后每次安装都会绕开这个文件,只保留你自己做的改动。

  10. 如果还没被接管过,才会问安装时唯一剩下的那个问题——对 shane-new-doc 来说是配图目录(默认 public/img);对 shane-new-post 来说是默认作者名(默认为空,为空就意味着生成的文章里干脆不写 author 这个 frontmatter 字段)。只要 process.stdin.isTTY 不为真(大部分 CI 和脚本化安装都是这样),这个提问会自动跳过,直接用默认值。

  11. 写脚本文件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. 根据是否覆盖了已有文件,打印“已生成”或者“已接管现有文件”两种提示之一。

  12. 写配置文件writeConfig 函数)——项目根目录下的 .shane-new-doc.json,内容是 { "imgDir": ..., "lang": ... }如果这个配置文件已经存在,这一步整个跳过——不管是你自己手动改过的,还是之前安装时生成的,都不会被覆盖。

  13. 注入 npm scriptinjectPackageScript 函数)——往 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-docindex.js),在依赖真正安装之前先自己做一轮检测:

  1. 提前判定语言,判定顺序和上面第 4 步一样(参数 → 环境变量 → 系统语言猜测),只是这时候还没有可以读的已保存配置,因为什么都还没装。如果是通过提问或参数确定的,会通过环境变量 SHANE_CLI_LANG 转发给真正的安装过程,这样 postinstall.js 就不用再问一遍。
  2. 扫描整个项目目录树(递归扫描,排除 node_modules.git),查找任何和上表候选名单同名的文件——不局限于 scripts//script/ 目录,项目里任何位置都会查。已经带有 // @generated by... 标记的文件会被跳过(这不算冲突,是这个包自己之前生成的产物)。其余每一个匹配到的文件都会被标记出来,并且针对每一个都会交互式地问你要不要删掉。
  3. 要求当前目录下必须已经有 package.json——如果没有,安装器会立刻报错退出,提示你应该在项目根目录下运行这个命令。
  4. 判断该用哪个包管理器来做真正的安装,判断顺序是:先看环境变量 npm_config_user_agent(npm/pnpm/yarn 在拉起子进程时会自动设置这个变量,检查它是不是以 pnpm/yarn/npm 开头),如果判断不出来,再看项目里有哪个锁文件(pnpm-lock.yaml → pnpm,yarn.lock → yarn,package-lock.json → npm),都没有的话默认用 pnpm。
  5. 执行真正的安装命令(根据上一步判断结果,对应 pnpm add shane-new-docnpm install shane-new-doc --save,或者 yarn add shane-new-doc),输出实时打印到终端。如果安装过程报错或者退出码不是 0,安装器会直接停下并报告失败。
  6. 二次确认安装是否真的完成了。 安装命令跑完之后,会再去 scripts/script/ 目录里找一遍有没有已接管的脚本(判断方法和第 9 步的标记检查一样)。如果没找到——说明包管理器默默跳过了 postinstall 生命周期脚本(较新版本的 pnpm 默认就会这样),就会手动补跑一次 node node_modules/shane-new-doc/bin/postinstall.js 作为兜底,保证不管遇到哪种情况,装完之后都是能直接用的状态。
  7. 打印最终确认信息(“Initialization complete. Run: pnpm new-doc”),前提是上面每一步都顺利完成。

shane-new-doc 这个包本身(不是那个独立的 create-shane-new-doc 包)在自己的 package.json 里其实还注册了第二个可执行命令:

node_modules/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.jsonscripts 里加任何东西。

shane-new-post的情况完全一样——它自己的 bin/create.js 会写一个带 postsDirauthor 字段的 .shane-new-post.json,但同样从来不会生成 scripts/new-post.js。这两个本地的 bin/create.js,顶多算是一个不完整的“配置重置”小工具;真正要初始化项目,永远走 npm create shane-new-doc@latest / npm create shane-new-post@latest,或者安装与快速开始里的手动安装路径。