上周我把自己写的第一个命令行工具发布到 npm 上了。从跑完npm publish --dry-run那一瞬间的“原来如此”,到第二天同事在他电脑上敲了一句npx就直接跑起来,整个过程踩了不少坑,也把发布链路里几个容易忽略的细节彻底搞明白了。这篇内容就是把这次完整过程记录下来:从dry-run预演、bin字段配置、registry 与镜像源切换、版本号管理,到别人用npx一条命令运行你的包,以及 Windows 下各种高频报错的排查方法。
如果你正准备发布自己的第一个 npm 包,或者在本地反复遇到npm.ps1无法加载、npx不被识别、ERESOLVE冲突这类问题,这篇文章基本可以覆盖你接下来几小时会碰到的绝大多数坑。
1. dry-run:发布前的“无伤彩排”
1.1 dry-run 到底干了什么
很多人第一次发 npm 包都是直接npm publish,结果要么把一堆不该上传的文件发上去了,要么报错之后才想起本地应该先验证一下。npm publish --dry-run这个命令做的事情很简单:把发布会经历的所有步骤完整跑一遍——打包、生成文件清单、计算包体积、检查 package.json 元信息,但最后一步“真正上传到 registry”被拦截住了。
我把它理解为“带妆彩排”。灯光、走位、台词全按正式演出来,只是台下没有观众。这个预处理过程能给你一份非常关键的报告:包里到底会带哪些文件、包有多大、文件名是什么。比如执行:
npm publish --dry-run输出里会有类似这样的内容:
npm notice npm notice 📦 greeting-cli@0.1.0 npm notice === Tarball Contents === npm notice 1.2kB package.json npm notice 3.1kB lib/cli.js npm notice 1.4kB README.md npm notice === Tarball Details === npm notice name: greeting-cli npm notice version: 0.1.0 npm notice package size: 2.8 kB npm notice unpacked size: 5.7 kB npm notice total files: 3注意看Tarball Contents这一栏,它列出来的就是将来别人npm install时能拿到的全部文件。这是一个很好的体检机会:如果这里列出了一个不该出现的.env、node_modules或者一堆测试临时文件,那说明你的发布配置有问题,需要立刻停下来处理。
1.2 用 files 字段管好你的发布内容
第一次跑 dry-run 时我的包非常“干净”,因为我在 package.json 里加了一个files白名单。files字段决定了哪些文件会被打包进发布产物,它只接受数组形式,比如:
{ "name": "greeting-cli", "version": "0.1.0", "files": [ "lib", "bin", "README.md" ] }有了这个白名单之后,npm publish基本只会把lib和bin两个目录加上 README 带上去,package.json 是自动包含的,不需要写进去。相反,如果你不用files,npm 默认会打包几乎所有文件,只排除.git、.svn、node_modules等少数几个路径,这时你就得靠.npmignore来补刀。
我个人的做法是优先用files白名单,因为它更不容易漏。.npmignore是黑名单思维,只适合项目里文件特别多、又不想一个个列白名单的情况。还有一个细节:files白名单不能排除 package.json 和 README,这两个是强制包含的。如果你 README 没写,npm 会警告,而且发布到 npm 官网后包主页会是一块空白,非常劝退使用者。
1.3 我在 dry-run 里揪出来的三个问题
第一次跑 dry-run 我就发现三个问题,很典型。
第一个是bin字段没配好。我一开始只写了main字段指向入口文件,dry-run 报告里完全没有 bin 信息。这意味着别人即使安装了包,npx也找不到对应的可执行命令。后来我在 package.json 里补上bin,才算真正变成一个“命令行工具”。
第二个是 README 内容为空导致 warning。npm 会提示no readme data,发布倒是能发,但是 npm 官网的包详情页会非常难看。另外如果包名跟 README 里的用法对不上,也会让人困惑。
第三个是打包体积里出现了一个临时日志文件。因为测试时生成过debug.log又没有清理,dry-run 报告把它列在 Tarball Contents 里。这个文件除了增加包体积没有任何作用,而且可能包含本地路径等环境信息。用files白名单之后这类问题基本绝迹。
这里我强烈建议在发布前把npm pack --dry-run也跑一次,它会直接在本地生成一个.tgz文件,你可以用压缩工具打开看看里面到底有什么。npm pack --dry-run和npm publish --dry-run的差异在于,前者是“本地打包演练”,后者是“完整发布流程演练”,两者都能看到文件清单,但publish --dry-run还会额外校验登录态、版本号、registry 等发布环节的配置。本地多看一眼 tarball,就好比去餐厅吃饭前先看一眼后厨,心里踏实。
2. 把一个普通 JS 文件变成“命令行工具”
2.1 package.json 里的 bin 字段
很多人写了一个不错的 CLI 脚本,但发到 npm 之后怎么都跑不起来,原因几乎都出在bin字段上。bin字段的作用是把一个可执行文件“注册”成命令。它的写法有两种,字符串简化版和对象完整版:
{ "name": "greeting-cli", "bin": "./bin/cli.js" }上面的写法等价于说:安装这个包之后,创建一个名为greeting-cli的命令,它指向./bin/cli.js。如果你想自定义命令名,比如包名叫greeting-cli,但你想让用户敲的是hi,就写成对象形式:
{ "name": "greeting-cli", "bin": { "hi": "./bin/cli.js" } }npx执行时的行为其实很直接:它会在临时安装的包的node_modules/.bin目录里找对应名字的命令,然后执行。所以如果你不写bin字段,npx greeting-cli就一定会报“命令找不到”。这个字段是 CLI 类 npm 包的生命线。
2.2 第一行 shebang 为什么必须是 #!/usr/bin/env node
在写bin/cli.js的时候,第一行必须是:
#!/usr/bin/env node这一行叫 shebang,它的作用是指定这个文件要用什么解释器来执行。没有它,你在 Unix/Linux/macOS 上直接运行脚本时会得到 permission denied 或无法识别文件格式;在 Windows 上 npm 生成 shim 的逻辑也会出问题。
#!/usr/bin/env node的意思是:去环境变量 PATH 里找到node可执行文件,然后用它来运行当前脚本。为什么不直接写/usr/bin/node?因为不同机器的 Node.js 安装路径千差万别,用env去找才足够通用。这是 Node.js 生态里事实上的标准写法,也算是跨平台的一种“软编码”实现。
写完后执行这个文件,还需要给它加上可执行权限:
chmod +x bin/cli.js这一步在 Windows 上不需要,但在 macOS/Linux 上很关键。忘了加权限,本地node bin/cli.js还是能跑,但npx在 Linux 服务器上执行时会直接报 EACCES。
2.3 本地先跑通:node cli.js 与 npm link
发布之前最好在本地完整跑一遍这个命令。比如我写了一个简化版的问候工具:
#!/usr/bin/env node const args = process.argv.slice(2); const name = args[0] || 'friend'; console.log(`Hello, ${name}! Welcome to npm package publishing.`);本地验证最直接的方式是node bin/cli.js npm,能输出预期的Hello, npm! ...。但这只能证明逻辑对,不能证明bin注册没问题。更好的方案是用npm link,它会在全局 node_modules 下创建一个符号链接,相当于提前把包“全局安装”了一次。
npm link运行之后,你可以在任意目录执行:
greeting-cli npm如果这能跑通,就说明bin字段、shebang、文件路径这几个关键点都对了。npm link本质上模拟了别人安装你包之后的效果,只是在你的机器上是链接到本地源码目录。改代码不用重新安装,实时生效,非常适合开发阶段验证。调试完之后记得npm unlink清理全局符号链接,否则下次发布新版本测试容易搞混。
3. 真的发布:登录、registry、版本号与首发
3.1 registry 和镜像源:发布前先确认你对着哪个源
这一步是我这次发布过程中第一道坎。我本地长期用镜像源来加速安装依赖,但发布的时候忘了切回来,结果 npm 直接拒绝了登录,提示 registry 不是官方源。npm 的registry就是“包仓库地址”,安装依赖和发布包都会访问它。
很多同学因为网络速度和稳定性问题习惯临时或长期使用镜像源,这是一个很现实的需求。但要记住一个重要原则:安装依赖可以用镜像源,发布包必须切回官方源。原因很朴素:镜像源本质上是一个副本服务,主要面向下载场景,发布场景要写数据,必须写回官方仓库,否则其他开发者从官方源永远看不到你的包。
先看一下当前配置:
npm config get registry输出如果是镜像源地址,发布前需要切回官方源:
npm config set registry https://registry.npmjs.org/如果你不想全局改配置,更推荐在当前项目放一个.npmrc文件,里面写registry=https://registry.npmjs.org/。这样发布专用,不影响全局。我后来就是用这种方式,避免在全局配置和项目配置之间来回折腾。
3.2 npm login 与登录态
发布前必须先登录:
npm adduser # 或者 npm login两者会引导你输入用户名、密码和邮箱,npm 会在本地保存一个 token。登录完之后可以用npm whoami确认当前登录身份:
npm whoami如果输出你的用户名,说明登录成功。如果显示ENEEDAUTH或者403,基本就是没登录或 token 过期。
要特别提醒一点,npm login生成的 token 相当于一把钥匙。社区和一些企业内部源经常发生 token 泄露事件,导致有人被恶意 publish 恶意版本。所以不要把~/.npmrc里的//registry.npmjs.org/:_authToken=...这行内容提交到 git 仓库。如果不小心泄露,去 npm 官网 settinngs 里删掉 token 重新生成一个。
3.3 版本号:别手动改,交给 npm version
npm 包的版本号遵循语义化版本规范(semver),格式是主版本号.次版本号.修订号。首次发布通常是1.0.0或者0.1.0。修复 bug 加修订号,新增功能向后兼容加次版本号,有不兼容的大改动才加主版本号。
很多新手会直接打开 package.json 改 version,比如改成1.0.1,然后发布。这样不是不行,但很容易出问题:忘了改、改错文件、改完不提交 git,导致包版本和代码版本对不上。
更稳妥的做法是用命令触发版本变更:
npm version patch # 1.0.0 -> 1.0.1 npm version minor # 1.0.0 -> 1.1.0 npm version major # 1.0.0 -> 2.0.0这条命令会自动修改 package.json 里的版本号,并且如果你在 git 仓库里,它还会顺便打一个 tag。之后再npm publish,版本号就不会出错。版本号在 npm 生态里是一条不可回退的链,同一个版本号只能发布一次,重复发布会报403。所以宁可版本号大一点,也不要覆盖一个已有版本。
3.4 发布动作:npm publish
登录完、版本号确认好之后,执行:
npm publish如果你的包名是带 scope 的,比如@myscope/greeting-cli,默认是私有包,发布时会报错要求加:
npm publish --access public发布成功后你会看到类似这样的输出:
+ greeting-cli@0.1.0然后去 npm 官网搜索你的包名,就能看到包主页。到这里,你的包已经进了官方仓库,全世界任何一台装好 Node.js 的机器理论上都能安装。
不过我第一次发布之后就立刻发现一个尴尬问题:包名已经被别人注册了会怎样?答案是你根本发不上去,npm 会直接提示403 Registry returned 403。所以在写代码之前就可以用npm view 包名看这个名字是否已存在,避免做完一堆工作才发现名字撞车。
4. 别人怎么通过 npx 用上你的包
4.1 npx 的运行机制
npx 是 npm 自带的一个命令行工具,它的核心能力是“不安装也能执行 npm 包里的命令”。这句话值得拆开讲。npx greeting-cli执行时,npx 会先检查本地node_modules/.bin里有没有这个命令;如果没有,就去 registry 查找这个包;找到之后临时下载到一个缓存目录,把包里的 bin 命令执行完,然后这个临时包就会交给缓存管理。看起来像是“用完即走”,实际上它还是会下载的,只是不需要你手动把它写进 package.json。
如果你不想 npx 每次询问是否安装,可以加--yes:
npx --yes greeting-cli如果你用的是带 scope 的包,执行方式要写完整:
npx --yes @myscope/greeting-clinpx 和 npm 命令最核心的区别就在安装后的生命周期上。npm install -g greeting-cli是全局安装,命令会长期常驻;npx greeting-cli是临时执行,更适合同一个命令偶尔用一次,或者想要保证每次都用最新版本的场景。这也是为什么很多工具型 CLI 推荐用 npx 而不是全局安装的原因。
4.2 实际演示:在空项目里 npx 一把梭
包发布成功之后,我特意开了一个全新的目录,假装自己是一个普通用户:
mkdir demo-project cd demo-project npx greeting-cli npm第一次执行时 npx 会下载包,然后输出:
Need to install the following packages: greeting-cli@0.1.0 Ok to proceed? (y)按 y 回车,等几秒,就看到:
Hello, npm! Welcome to npm package publishing.到这一步,一个 npm 包从发布到被全球任何一台机器通过 npx 消费的链路就彻底闭环了。整个过程没有再经过本地源码,我从一个空白目录,用一条命令拿到了包里的可执行程序。这种“别人能用上你的包”的感觉,和之前npm link本地调试是完全不同的。
4.3 让 npx 体验更顺滑:包名、描述与 README
包发布之后,第一个版本的 README 写得比较随意。后来我发现 README 不只是“看得懂”,还能直接影响别人愿不愿意用你的包。npm 官网的包详情页会直接渲染 README,一个结构干净的 README 应该包含:这个包是干什么的、安装方式、最少可用示例、完整的 API 或命令参数说明、License。如果你用npx命令安装,README 里最好直接把npx 包名的示例写在最前面,因为这是最省事的使用方式。
包描述(package.json 里的description字段)也很重要。npm 搜索结果的卡片上显示的就是它。写得含糊其辞,不如直接写清楚“一个在终端里向指定名字打招呼的小工具,通过 npx 即可运行”。
另外,keywords字段虽然不影响功能,但会影响 npm 搜索命中率,顺手填几个合适的关键词不是坏事。发布工具的最终目标不只是“技术上能跑”,而是“别人愿意跑”。
5. Windows 下的高发坑:从 PowerShell 到 PATH
这一节基本是热词里那堆报错的实战排查记录。我发布完包之后,有位同事在 Windows 上使用,连续踩了好几个经典环境坑,我远程帮他排查的同时把这些问题整理成了速查表。
5.1 PowerShell 禁止运行脚本:npm.ps1 / npx.ps1 无法加载
在 Windows 上跑npm或npx时出现这样的报错非常高频:
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。有关详细信息,请参阅 about_Execution_Policies。问题出在 Windows PowerShell 的脚本执行策略(Execution Policy)。Node.js 安装包在 Windows 上提供的npm和npx入口其实有两种:一种是.cmd批处理文件,一种是.ps1PowerShell 脚本。终端如果默认使用 PowerShell,它就会尝试去执行.ps1脚本,弹出来这个安全策略报错。
解决方案是用管理员权限打开 PowerShell,执行:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned的含义是:本地创建的脚本可以运行,从互联网下载的脚本必须有可信签名。这个设置在安全性和便利性之间比较平衡。为什么会这样?因为npm安装时生成的npm.ps1是本地文件,符合 RemoteSigned 的运行条件,所以能跑。如果改成Unrestricted,虽然也不会有什么大问题,但没有必要把安全门槛放得那么开。
如果同事的机器上有管理员权限限制,改用CurrentUser作用域就不用动系统设置:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force改完之后重新打开终端再执行npm -v,通常就能通过。
5.2 npx 不是内部或外部命令:PATH 环境变量缺失
另一个高频报错是:
npx : 无法将“npx”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。或者命令行提示npx 不是内部或外部命令。
这种情况说明系统找不到npx可执行文件,通常是因为 Node.js 安装的时候没有把可执行目录写进 PATH,或者 PATH 里之前配置的路径和实际安装路径不一致。尤其是拿绿色版、手动解压的 Node.js 包,很容易漏掉 PATH 配置。
解决办法是确认 Node.js 安装目录,然后把这个目录加到 PATH。在 PowerShell 里可以执行:
where.exe node查看 node 的完整路径,一般是类似C:\Program Files\nodejs\node.exe,那么这个路径下的npx.cmd、npm.cmd就是我们要找的执行入口。然后把C:\Program Files\nodejs加入系统的 PATH 环境变量。
Windows 上还有一种特殊情况:PowerShell 对可执行文件的查找顺序是 PATH 里的目录顺序,如果之前安装过旧版本 Node.js,卸载时残留了无效的 PATH 路径,也可能导致找不到命令。清理 PATH 里不存在的路径,重新打开终端一般就正常了。
5.3 其他高频报错速查表
除了上面的 PowerShell 和 PATH,我整理了这次实践中遇到过的其他几个常见报错,以及对应的处理思路。
| 报错信息(关键词) | 原因分析 | 处理建议 |
|---|---|---|
npm err! code ebusy | Windows 上某个文件被编辑器或另一个进程占用,npm 无法覆盖写入 | 关闭 IDE、终端、占用该文件的程序后重试,必要时重启电脑再装 |
npm error code eunsupportedprotocol unsupported url type "workspace:" | 项目依赖里写了workspace:协议,这个协议是 pnpm 的 workspace 功能,npm 原生不支持 | 改用 pnpm 安装,或把依赖改成file:或具体版本号 |
ERESOLVE overriding peer dependency | 本地项目的 peerDependencies 版本与当前安装的依赖版本冲突 | 按提示调整 peer 依赖版本;临时场景可加--legacy-peer-deps,但要意识到它在绕过冲突检查 |
npm warn deprecated node-domexception@1.0.0: use your platform's native dome... | 某个依赖包声明废弃了旧依赖,只是警告,不影响安装 | 关注即可,后续升级使用该旧依赖的上层包 |
cannot find native binding. npm has a bug related to optional dependencies | 原生模块(如 node-sass、bcrypt)编译失败或 optional 依赖未安装成功 | 先确保 Node 版本与模块兼容,Windows 需要装好 VS Build Tools 和 Python;再执行npm rebuild重试 |
cb() called never!且npm cache相关 | 缓存或者并发问题 | 清理缓存npm cache verify或npm cache clean --force后重装 |
npm ERR! code EINTEGRITY | 下载包时校验值不一致,通常是缓存损坏或镜像同步不完全 | 清缓存后切换一次源,重试安装 |
运维过 Windows 环境的人都能体会到,环境变量和脚本执行策略这类问题往往比业务代码更磨人。但好在这些都是确定性很高的环境故障,排查思路一旦整理成清单,下次遇到基本能 5 分钟内定位。
6. 发布之后的收尾与迭代
6.1 版本升级与再次发布
发布完成并不代表结束。只要你继续维护这个包,下一次修改后就需要走“改代码、更新版本、发布”的循环。我的习惯是:
- 在本地开发分支完成代码修改并测试通过
- 用
npm version patch自动提升版本号 - 执行
npm publish - 在 git 里提交代码并推送 tag
这个小循环可以让每次发版都有据可查。尤其是在团队协作时,如果每个人都是手动改版本号再发布,很容易漏掉 git tag,后续回滚时完全找不到对应代码版本。
6.2 不再维护怎么办:deprecate 与 unpublish
如果某个包以后不再维护了,千万不要直接unpublish。npm unpublish只能在发布后 72 小时内执行,而且会把包从 registry 里完全删除,所有依赖你包的项目都会因此出现安装失败。这会给使用者带来连锁影响,非常不建议。
更负责任的方式是执行:
npm deprecate greeting-cli "This package is no longer maintained. Please use xxx instead."这样别人安装时会在终端看到一条明确的废弃提示,但仍然能正常安装,不会破坏依赖关系。这是 npm 生态里约定俗成的“退场礼仪”。
6.3 dist-tag 与后续扩展
npm 的dist-tag是很多人容易忽略的功能。每个包默认有一个latest标签,npm publish会把当前版本默认打到latest上。如果你想发一个试验性版本,不希望用户默认安装到它,可以用:
npm publish --tag beta之后用户需要显式npm install 包名@beta才能装到,日常npm install 包名仍然拿到 latest。这个机制在功能预览、灰度发布、尝鲜版本场景下非常有用。等 Beta 版本测试稳定了,再用npm dist-tag add 包名@版本号 latest把最新版提为正式版。管理好 tag 之后,你的包在用户那边的安装体验会稳定很多。
最后分享一点我自己的感受。发布一个 npm 包最大的门槛并不在写代码,而在流程与环境。我第一次发布时,光处理 Windows 下的 PowerShell 策略、PATH 配置、registry 源这几个环境问题就花了接近一个小时,真正写 CLI 代码的时间反而不长。发布后第一件事不要急着到处宣传,先找一个干净环境用npx完整走一遍,确认“别人视角”真的能用,然后再分享出去。你发出去的不仅仅是一个包,更是一段让别人少踩坑的体验。