Package Scripts 工程手册:让一键命令保留真实执行证据
npm run dev 在开发机能启动三个服务,按 Ctrl+C 后却留下两个端口;npm run build -- --mode staging 在 macOS 正常,Windows 因引号规则失败;CI 显示脚本成功,真正的测试进程早已在后台退出。问题不在于 package.json 少写了一条命令,而在于团队把“短入口”误当成“执行模型已经统一”。
Package Scripts 适合把 Node 项目的 lint、test、build、dev 等权威命令暴露成稳定入口。它不是完整构建系统:不会自动推导文件依赖图、缓存键、跨包增量关系和分布式执行。脚本层越薄,越容易保留底层工具的退出码与诊断信息;脚本层越像第二套构建语言,维护成本越快失控。
先看清一条脚本实际经过哪些层
{
"scripts": {
"check": "eslint . && vitest run",
"build": "vite build"
}
}执行 npm run check 时,npm 读取 package.json,把本地依赖的可执行入口加入 PATH,切换到包根目录,再把字符串交给平台默认 Shell。POSIX 默认通常是 /bin/sh,Windows 默认是 cmd.exe;具体行为和可配置项应从 npm run 命令说明核对。
这条链解释了三个常见误判:脚本中的 vite 不要求全局安装;调用者当前目录不一定是脚本工作目录;同一字符串交给不同 Shell,变量、引号、管道和信号行为可能不同。
从最小可失败项目建立合同
先在可删除目录初始化项目并安装本地开发依赖:
npm init -y
npm install --save-dev eslint创建 scripts/verify-env.mjs:
import process from "node:process";
console.log(JSON.stringify({
cwd: process.cwd(),
initCwd: process.env.INIT_CWD,
lifecycle: process.env.npm_lifecycle_event,
node: process.version,
}, null, 2));
if (process.argv.includes("--fail")) {
process.exitCode = 23;
}在 package.json 中添加:
{
"scripts": {
"env:show": "node scripts/verify-env.mjs",
"env:fail": "node scripts/verify-env.mjs --fail"
}
}依次运行:
npm run
npm run env:show
npm run env:fail第一条列出可用脚本,第二条应输出包根目录与当前 lifecycle 名称,第三条必须让 npm 返回非零退出码。这个失败实验很重要:团队入口不仅要能成功,还要证明底层失败不会被吞掉。
pre、主脚本和 post 是隐式调用链
脚本名存在对应的 pre 与 post 时,npm 会按顺序运行:
{
"scripts": {
"prebuild": "node scripts/check-env.mjs",
"build": "vite build",
"postbuild": "node scripts/check-artifact.mjs"
}
}npm run build 会触发 prebuild -> build -> postbuild。这种约定适合短小且始终必须执行的校验,但隐式步骤过多会让开发者只看到中间失败,不知道入口从哪里开始。跨越环境准备、代码生成、测试和发布的长链更适合显式编排:
{
"scripts": {
"build:app": "vite build",
"build:verify": "node scripts/check-artifact.mjs",
"build": "npm run build:app && npm run build:verify"
}
}依赖安装期间的 preinstall、install、postinstall、prepare 属于供应链代码执行面,不应和团队主动运行的 build 等量齐观。它们会影响安装安全、发布行为和 Git 依赖处理。生命周期的触发时机应从 npm scripts 文档逐项核对,不凭脚本名称猜测。
参数透传必须经过 --
给底层命令传参数时,在脚本名后使用 --:
npm run test -- --runInBand
npm run build -- --mode staging-- 之前的选项由 npm 解释,之后的参数传给脚本命令。不要把用户输入直接拼成 Shell 字符串:
{
"scripts": {
"deploy": "node scripts/deploy.mjs"
}
}npm run deploy -- --environment staging --dry-run由 Node 脚本使用参数解析库或明确的参数表验证输入,比在 package.json 中写复杂条件、字符串替换和 eval 更容易跨平台,也更容易阻止命令注入。
本地二进制来自 PATH,不要依赖全局安装
npm 会把依赖中的可执行入口加入脚本 PATH,所以可以直接写:
{
"scripts": {
"lint": "eslint .",
"test": "vitest run"
}
}这两个命令应解析到当前依赖树的 node_modules/.bin,而不是开发者机器上的全局版本。排查来源时:
npm exec -- which eslint
npm ls eslint vitestWindows 可以使用:
npm exec -- where.exe eslint
npm ls eslint vitest脚本在本机成功、CI 报“command not found”时,先检查依赖是否声明在正确 package、CI 是否错误使用了省略开发依赖的安装模式,以及 workspace 命令是否运行在预期包中。不要用全局安装修补未声明依赖。
工作目录是 package 根,调用位置保存在 INIT_CWD
npm 脚本从 package 根目录运行,即使开发者从子目录发起。调用者原始目录放在 INIT_CWD。官方脚本文档明确描述了这两个语义。
import process from "node:process";
console.log("script root:", process.cwd());
console.log("caller cwd:", process.env.INIT_CWD);构建输入、输出目录应相对 package 根解释,不要依赖某位开发者从特定子目录运行。只有确实需要“对调用位置做动作”的工具才读取 INIT_CWD,并在越过仓库根之前拒绝继续,避免清理脚本删错目录。
monorepo 中还要分清根 package 与 workspace package。下面命令只作用于指定 workspace:
npm run test --workspace=@company/api
npm run test --workspaces --if-present--if-present 只忽略“脚本不存在”,底层脚本失败仍应返回失败。npm workspaces 的执行顺序与根 package.json 中的声明顺序有关,不能把这种顺序当成依赖图;需要拓扑调度、远端缓存或受影响范围计算时,应交给专门的大仓构建工具。
环境变量只传名称,秘密由运行环境注入
跨平台脚本不要直接使用 Shell 专属的赋值语法:
{
"scripts": {
"dev": "NODE_ENV=development node server.mjs"
}
}这在 POSIX Shell 可用,在默认 Windows cmd.exe 中会失败。简单场景可以使用经过批准并锁定版本的跨平台环境工具;更复杂的配置应由 Node 启动脚本读取并校验:
const mode = process.env.APP_MODE ?? "development";
const allowed = new Set(["development", "test", "production"]);
if (!allowed.has(mode)) {
throw new Error(`unsupported APP_MODE: ${mode}`);
}.env.example 只写变量名和无敏感示例:
APP_MODE=development
API_BASE_URL=https://example.invalid
SERVICE_TOKEN=真实 token 由本地秘密工具或 CI secret 注入。不要把 dotenv 自动加载理解为安全机制:debug 日志、set -x、错误堆栈、进程列表和构建产物都可能泄露值。脚本只检查变量是否存在,不回显完整凭证。
Windows 与 POSIX 的差异从 Shell 层解决
默认 Shell 差异会影响:
环境变量:$NAME、${NAME} 与 %NAME%。引号与转义:单引号在 cmd.exe 中不是 POSIX 单引号。命令链:&&、||、管道的退出语义。
路径:反斜杠、盘符、空格和 glob 展开。信号:POSIX signal 与 Windows 进程终止模型不同。
不要为了统一而把所有开发机强制切到一个未管理的 script-shell。npm 支持 script-shell 配置,但一旦改变,仓库所有脚本都依赖那个 Shell 的安装位置和语法。更稳的顺序是:优先调用跨平台 CLI;复杂逻辑写成 Node 脚本;只有团队明确管理 Shell 基线时才覆盖默认值。
下面这种长字符串很快会失控:
{
"scripts": {
"release": "if [ -n \"$TOKEN\" ]; then rm -rf dist && tool --flag; fi"
}
}改成 node scripts/release.mjs,在代码中完成路径验证、参数解析、退出码和清理,更容易测试 Windows 与 POSIX 行为。
顺序和并发都必须保留失败语义
有依赖关系的步骤应串行:
{
"scripts": {
"verify": "npm run lint && npm run test && npm run build"
}
}&& 让前一步失败时停止后续步骤,但仍受 Shell 语义影响。跨平台要求高或步骤多时,用 Node 编排脚本逐个 spawn,显式继承标准输入输出并检查退出码。
可以并行的独立任务不要用后台符号简单拼接:
{
"scripts": {
"dev": "api-server & web-server"
}
}这个写法在不同 Shell 下行为不同,也难以保证任一子进程失败时终止其余进程。采用并发 runner 时要验证四件事:一方失败时整体非零、Ctrl+C 能传到全部子进程、输出可区分来源、退出后端口和临时文件已清理。runner 本身也属于开发依赖和供应链输入,不能用未锁定的远程执行替代本地依赖。
信号、退出码和子进程清理决定“一键启动”是否可信
Node 编排脚本应使用参数数组而不是拼接命令,并转发退出信号:
import { spawn } from "node:child_process";
const child = spawn(process.execPath, ["server.mjs"], {
stdio: "inherit",
shell: false,
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => child.kill(signal));
}
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exitCode = code ?? 1;
});这只是最小模型。Windows 进程树、容器、Java 子进程和经 Shell 启动的孙进程可能无法靠一次 child.kill() 全部结束。开发脚本需要在目标平台实测,必要时使用具备进程树清理能力的受控工具,并提供独立 stop 或资源回收动作。
验证不能只看终端回到提示符:
npm run dev
# 另一个终端确认端口和进程
curl -fsS http://127.0.0.1:3000/health
# 停止后再次检查,访问应失败且端口应释放CI 中应设置超时。超时后既要标记失败,也要清理子进程、容器和临时凭证;只杀父 npm 进程可能留下后台任务继续占用 runner。
脚本供应链不只发生在 npm install
Package Scripts 可以调用仓库中的任意程序,因此评审重点包括:
新增或变更的 preinstall、install、postinstall、prepare。curl | sh、PowerShell 下载执行、npx 临时拉取和未锁定 CLI。从外部输入拼接的分支名、路径、URL 或发布参数。
自动读取 .env、云凭证目录和 SSH agent 的脚本。向日志、产物或缓存写入环境快照的诊断命令。
常用工具应声明为 devDependencies 并由 lockfile 恢复。需要一次性执行包时,也要明确版本与来源,不让“方便运行”绕过依赖评审。
脚本禁用策略与主动运行行为要分开理解。npm 的 ignore-scripts 会影响安装生命周期和 pre/post 行为,但显式 npm run <name> 仍会运行目标脚本;具体边界应查当前 npm CLI 文档,而不是把一个开关当成全局代码执行保险。
CI 合同从干净恢复开始
一条可信流水线至少把依赖恢复和任务执行分开:
steps:
- run: npm ci
- run: npm run lint
- run: npm run test -- --runInBand
- run: npm run build这里 Package Scripts 只提供入口,依赖确定性仍由包管理器与 lockfile 负责。CI 要记录 Node/npm 版本和脚本退出码,但不打印全部环境变量。
本地与 CI 应调用同名权威脚本,避免本地 npm run verify 与 CI 手写另一套底层命令。平台差异可以放在 Node 编排脚本内部做最小适配,或用多平台任务验证,不要让脚本名相同但执行语义完全不同。
workspace 项目要明确根脚本的作用域:
{
"scripts": {
"test:all": "npm run test --workspaces --if-present",
"test:api": "npm run test --workspace=@company/api"
}
}大仓任务存在依赖拓扑、受影响范围和远端缓存需求时,Package Scripts 保留为稳定的人类入口,内部调用团队选定的构建图工具;不要把几十个 workspace 的拓扑硬编码成一条 && 链。
常见失败按执行层排查
本地成功,CI 报找不到命令
检查 CLI 是否在对应 package 的依赖中、CI 是否安装了 devDependencies、workspace 作用域是否正确,再检查 PATH。不要先全局安装。
Windows 报语法或路径错误
把最终命令复制到目标 Shell 单独运行,检查引号、变量、glob、盘符和空格。复杂命令迁移到 Node 脚本,不在 JSON 字符串上继续叠转义。
脚本显示成功,测试实际失败
检查管道与并发 runner 是否保留非零退出码,检查是否使用了后台符号,故意让底层命令返回固定非零值做反向实验。
Ctrl+C 后端口仍被占用
记录父子进程树,确认 runner 是否转发信号、子服务是否再派生孙进程、容器是否独立运行。提供幂等清理命令,并让 CI 超时路径调用同一清理逻辑。
从子目录运行后文件生成到错误位置
打印 process.cwd() 与 INIT_CWD,明确脚本应以 package 根还是调用目录为基准。所有删除动作先解析绝对路径并校验仍位于仓库的受控输出目录。
迁移与回滚不要一次改动所有入口
从零散 README 命令迁移到 Package Scripts 时,先选择 lint、test、build 这类已有权威底层命令,建立薄入口并对比输出、产物和退出码。随后再迁移 dev 编排与清理动作。
从 Shell 字符串迁移到 Node 脚本时,保留旧入口一段时间:
{
"scripts": {
"build": "node scripts/build.mjs",
"build:legacy": "vite build"
}
}代表性平台验证通过后删除旧入口。回滚应能恢复旧脚本、旧 runner 版本和旧 CI 调用,不要同时升级 Node、npm、并发工具和业务构建器。
落地工程深水区:短入口最容易隐藏长尾责任
团队往往只维护“怎么启动”,却没人负责“怎么停止、失败时留下什么证据、升级后谁验证”。每个长期脚本应有 owner、输入、输出、超时、退出码、清理动作和支持平台。名称要表达动作,避免 do-all、magic 这类无法审查的入口。
脚本层不应复制底层构建系统的依赖关系。CMake、Gradle、Maven、Nx 等工具已经维护依赖图时,Package Scripts 只调用它们的权威入口。双重建模会造成一处改了依赖,另一处仍按旧顺序运行。
秘密治理要覆盖运行时,而不只是仓库扫描。脚本回显、debug 模式、错误对象、崩溃转储、缓存和构建产物都可能留下秘密。CI 对外部贡献分支应使用无秘密或只读上下文,发布脚本只在受保护环境运行。
成本也需要观测。一个“方便”的根脚本如果每次串行跑所有 workspace,会把开发反馈从几十秒拖到十几分钟。先测量各阶段时长和失败率,再决定是拆入口、并行独立任务,还是引入真正的任务图工具。
脚本调用项目内锁定的 CLI,不依赖开发机全局安装。每个入口的工作目录、参数、环境变量名、输出和退出码可解释。pre/post 生命周期保持短小,安装阶段脚本纳入供应链审查。
复杂条件和跨平台逻辑使用可测试的 Node 脚本,不堆在 JSON Shell 字符串中。Windows、POSIX 与 CI 至少有代表性验证,script-shell 变化受团队管理。并发任务能传播失败和停止信号,退出后无残留进程、端口、容器与临时文件。
.env 不承载可提交秘密,日志和诊断输出不回显凭证。CI 从干净依赖恢复开始,本地与 CI 调用同名权威入口。Package Scripts 不重复构建系统的依赖图;达到拓扑、缓存和增量边界时及时升级工具。
脚本迁移有等价性验证、分步切换和可恢复的旧入口。
