本文永久链接 – https://tonybai.com/2026/10/10/why-native-cli-ships-on-npm
大家好,我是Tony Bai。
【导读】
打开飞书 CLI、esbuild、Prisma、Turborepo 的安装文档,你会发现一个有趣的现象:它们的核心都不是 JavaScript,却都让你敲 npm 或 npx。这不是巧合,而是一种已经成熟的工程套路。这篇文章会先讲清楚“为什么”,再拆开飞书 CLI 的 npm 包看“怎么做”,最后带你从注册 npm 账号开始,把一个 Go 版 HelloWorld 发布到 npm 上,亲手跑通完整链路。
【文章要点】
- 现象:原生 CLI 披着 npm 的外衣,本质是把 npm 当作“跨平台应用商店”。
- 原因:统一入口、
npx零安装、用户环境重合、免费 CDN 与版本管理、PATH 自动处理。 - 三种包装模式:fat package、
postinstall下载、optionalDependencies子包,各有取舍。 - 案例拆解:飞书 CLI 的
package.json、install.js、run.js和 GoReleaser 配置各自承担什么职责。 - 实战:注册 npm 账号、开启 2FA、交叉编译 Go、编写
run.js启动器、npm publish、验证与升级。 - 避坑:国内网络、
--ignore-scripts、供应链安全、Windows 文件占用、版本不可复用。

前阵子我在装飞书 CLI,安装文档只有一行命令:
npx @larksuite/cli@latest install
我愣了一下。飞书 CLI 的仓库明明是 Go 项目,编译出来是一个独立的二进制文件,跟 Node.js 毫无关系,为什么要用 npx 安装?
再留意一下,这种做法并不少见:esbuild 是 Go 写的,Turborepo 和 SWC 是 Rust 写的,Prisma 的引擎也是 Rust,它们都能通过 npm 安装。
今天这篇文章,我们就来弄清楚三件事:
- 大家为什么都这么做?
- 具体是怎么做到的?
- 如果我也有一个 Go 或 Rust 写的小工具,怎么把它发布到 npm?
为什么大家都在用 npm 分发原生 CLI
先看没有 npm 的时候,分发一个跨平台 CLI 要做多少事:
- macOS:维护 Homebrew Formula。
- Linux:打 deb、rpm 包,或者 Snap。
- Windows:提交 Scoop、Chocolatey、Winget,或者让用户自己下载
.exe再配置 PATH。
每个渠道都有各自的审核流程、更新节奏和安装文档,维护成本相当高。而用 npm,这一切被折叠成一张图:

具体来说,有五个原因。
第一,统一入口。
无论什么系统,安装命令都是 npm install -g 或 npx。文档只需要写一份,用户也不用关心自己该选哪个包管理器。
第二,npx 的“即用即走”。
npx 会把包下载到本地缓存目录(默认在 ~/.npm/_npx)并执行,不需要全局安装。配合 @latest,每次运行拿到的都是最新版本,能有效减少“用户因为版本太旧而反馈已修复 Bug”的问题。很多 CLI 还把它当作引导器:先通过 npx 跑起来,再由程序自己完成真正的安装和配置。
第三,用户环境高度重合。 CLI 工具的受众主要是开发者,而 Node.js 几乎是今天开发者电脑上的标配。借用用户现成的环境,阻力最小。相比之下,如果你用 Python 分发,还要面对虚拟环境、pip 权限等一堆问题。
第四,免费且成熟的基础设施。 npm Registry 自带全球 CDN、严格的语义化版本、dist-tag(latest、beta 等)和回滚机制,国内还有淘宝(npmmirror)等镜像。自建下载服务意味着带宽、加速节点和高可用,npm 把这些成本几乎降到了零。
第五,PATH 问题被顺手解决。 全局安装时,npm 会替你在全局 bin 目录创建软链接(Windows 上是 .cmd 和 .ps1 包装脚本),用户不需要手动改环境变量,也很少遇到 /usr/local/bin 的权限报错。
一句话总结:npm 在这里扮演的不是“JavaScript 包管理器”,而是“覆盖面最广、摩擦最小的跨平台应用商店”。
当然,代价也有:用户必须先装 Node.js。所以它更适合开发者工具,不太适合面向普通大众的软件。
三种包装模式
二进制是怎么“藏”进 npm 包里的呢?业界主要有三种做法:
| 模式 | 做法 | 优点 | 缺点 | 代表 |
|---|---|---|---|---|
| 单包内置 | 把所有平台的二进制都塞进一个包 | 最简单,离线可用 | 包体积大,每个用户都下载全部平台 | 小工具、教学示例 |
postinstall 下载 |
包里只有 JS,安装时按平台从 GitHub Releases 等地址下载 | 包很小,二进制可放在任何位置 | 依赖外部网络,--ignore-scripts 会失效 |
飞书 CLI |
optionalDependencies 子包 |
每个平台一个子包,主包声明为可选依赖,npm 只安装匹配当前平台的那个 | 无需二次下载,速度快,离线镜像友好 | 要发布多个包,发布流程更复杂 | esbuild |
后面我们会看到,飞书 CLI 选的是第二种,而我们的实战为了便于入门,先用第一种,最后再讲怎么升级到第三种。
拆解飞书 CLI:它是怎么套壳的
飞书 CLI(larksuite/cli)是一个 Go 项目,npm 包名是 @larksuite/cli。我们看看它的包装层由哪几个文件组成。
1. package.json:声明入口和约束
核心字段只有几行(节选):
{
"name": "@larksuite/cli",
"bin": { "lark-cli": "scripts/run.js" },
"scripts": { "postinstall": "node scripts/install.js" },
"os": ["darwin", "linux", "win32"],
"cpu": ["x64", "arm64", "riscv64"],
"engines": { "node": ">=16" },
"files": ["scripts/install.js", "scripts/install-wizard.js", "scripts/run.js", "checksums.txt", "CHANGELOG.md"]
}
几个值得注意的点:
bin把命令lark-cli映射到一个 JS 启动器,而不是二进制本身。postinstall钩子负责下载二进制。os和cpu限定了支持的平台,不支持的机器在安装阶段就会被拒绝。files是白名单,发布的包里没有任何二进制,只有几个脚本和checksums.txt,所以包体积非常小。
2. GoReleaser:把二进制编译好放到 GitHub Releases
仓库根目录的 .goreleaser.yml 定义了构建矩阵:关闭 CGO(CGO_ENABLED=0,保证纯静态、无系统依赖),目标系统为 darwin、linux、windows,架构为 amd64、arm64、riscv64,并生成统一命名的压缩包,例如 lark-cli-<版本>-<系统>-<架构>.tar.gz(Windows 为 .zip),同时生成 checksums.txt。
发布流程由推送标签(tag)触发的 GitHub Actions 驱动,并带有预检:比如要求标签受保护、标签必须指向当前代码,且拒绝重新构建已公开的版本。
3. install.js:postinstall 阶段的“搬运工”
install.js 做的事可以画成一张流程图:

这里有几个很实用的细节:
- 平台映射:把 Node 的
win32、x64翻译成 Go 习惯的windows、amd64。 - 多源下载:优先 GitHub,失败后回退到 npmmirror。它还会读取用户的
npm_config_registry,兼容企业内部镜像。这对国内网络环境非常友好。 - 完整性校验:
checksums.txt随 npm 包一起发布,下载后比对 SHA-256。即使下载源被篡改,校验也会失败。 - 主机白名单:只允许从预期的域名下载,作为纵深防御。
- 下载工具:直接调用系统的
curl,并针对旧版 Windows 的 curl 做了兼容判断。
4. run.js:运行时的“转发器”
用户敲下 lark-cli,实际执行的是 run.js。它很薄,但有两个巧思:
- 拦截
install子命令:npx @larksuite/cli@latest install里的install并不是 npm 的命令,而是传给run.js的参数。启动器识别后直接运行安装向导,绕开尚不存在的原生二进制。这就是“引导器”的用法。 - 懒加载兜底:如果发现二进制不存在(比如
npx场景下跳过了postinstall,或用户用了--ignore-scripts),就现场调用install.js补下载,再用execFileSync以stdio: "inherit"把参数原样转交给二进制。
此外它还处理了一个 Windows 特有的问题:自更新时正在运行的 .exe 无法被覆盖,所以会先改名为 .old,run.js 启动时负责检查并恢复。
整个套路可以总结为四步:
GoReleaser 出产物 → GitHub Release 存放 → npm 包只放脚本与校验和 → 安装时下载、运行时转发。
实战:把 Go HelloWorld 发布到 npm
下面我们动手做一遍。为了便于理解,用最简单的“单包内置”模式:一个 npm 包里放好几个平台的二进制,启动器按平台选择。
整体流程如下:

准备账号
- 打开 npmjs.com,注册账号,并验证邮箱。
- 在账号设置里开启双因素认证(2FA)。目前发布包需要满足 npm 的安全要求,建议直接使用验证器 App 开启。
- 本地确认已安装 Node.js,然后登录:
npm login --registry=https://registry.npmjs.org/
npm whoami --registry=https://registry.npmjs.org/ # 能输出你的用户名即成功
- 选一个包名,用
npm view <包名>检查是否被占用。查不到(报 404)就说明可用。想避免重名,可以使用作用域包名,如@你的用户名/hello-go-cli。
$npm view @tonybai_cn/hello-go-cli --registry=https://registry.npmjs.org/
npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/@tonybai_cn%2fhello-go-cli - Not found
npm error 404
npm error 404 '@tonybai_cn/hello-go-cli@*' is not in this registry.
npm error 404
npm error 404 Note that you can also install from a
npm error 404 tarball, folder, http url, or git url.
npm error A complete log of this run can be found in: /Users/tonybai/.npm/_logs/2026-10-09T06_51_07_536Z-debug-0.log
写 Go 程序并交叉编译
创建目录 hello-go-cli,新建 main.go:
package main
import (
"fmt"
"os"
"runtime"
)
func main() {
name := "world"
if len(os.Args) > 1 {
name = os.Args[1]
}
fmt.Printf("Hello, %s! (%s/%s)\n", name, runtime.GOOS, runtime.GOARCH)
}
执行 go mod init hello-go-cli,然后编写 build.sh,利用 Go 原生的交叉编译能力一次出齐:
#!/usr/bin/env bash
set -e
mkdir -p bin
build() {
GOOS=$1 GOARCH=$2 CGO_ENABLED=0 \
go build -ldflags="-s -w" -o "bin/hello-$1-$2$3" .
}
build darwin arm64
build darwin amd64
build linux amd64
build linux arm64
build windows amd64 .exe
-s -w 用来去掉符号表和调试信息,缩小体积。如果你用的是 Rust,思路一样,用 cargo build --target <三元组> 或 cross 工具,产物命名保持相同规则即可。
编写 npm 包
在同一目录下新建 package.json:
{
"name": "hello-go-cli-demo",
"version": "0.1.0",
"description": "A hello world CLI written in Go, shipped via npm",
"bin": { "hello-go": "run.js" },
"files": ["run.js", "bin/"],
"os": ["darwin", "linux", "win32"],
"cpu": ["x64", "arm64"],
"engines": { "node": ">=16" },
"license": "MIT"
}
再写启动器 run.js:
#!/usr/bin/env node
const { spawnSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const OS = { darwin: "darwin", linux: "linux", win32: "windows" }[process.platform];
const ARCH = { x64: "amd64", arm64: "arm64" }[process.arch];
if (!OS || !ARCH) {
console.error(`不支持的平台:${process.platform}-${process.arch}`);
process.exit(1);
}
const bin = path.join(
__dirname, "bin", `hello-${OS}-${ARCH}${OS === "windows" ? ".exe" : ""}`
);
try { fs.chmodSync(bin, 0o755); } catch (_) {} // 保证有执行权限
const r = spawnSync(bin, process.argv.slice(2), { stdio: "inherit" });
if (r.error) {
console.error(r.error.message);
process.exit(1);
}
process.exit(r.status === null ? 1 : r.status);
这里要注意两点:stdio: "inherit" 让子进程直接共享终端,交互和颜色输出都正常;退出码要原样透传,否则 CLI 在脚本里无法判断成败。
本地验证与发布
先别急着发布,本地演练一遍:
bash build.sh
npm pack --dry-run # 查看将被打包的文件清单与体积
npm install -g . # 本地全局安装
hello-go npm # 输出:Hello, npm! (darwin/arm64)
发布包需要身份验证:npm publish 支持 OTP,也可以通过创建 bypass 2FA 的 Access Token 来完成。
你可以在 npm 后台创建 Granular Access Token,记得选择“bypass 2FA”。
拿到 token 后,可以在本地设置:
npm config set //registry.npmjs.org/:_authToken=你的token
确认无误后发布:
$npm publish --access public --registry=https://registry.npmjs.org/
npm notice
npm notice 📦 hello-go-cli-demo@0.1.0
npm notice Tarball Contents
npm notice 1.7MB bin/hello-darwin-amd64
npm notice 1.7MB bin/hello-darwin-arm64
npm notice 1.6MB bin/hello-linux-amd64
npm notice 1.6MB bin/hello-linux-arm64
npm notice 1.7MB bin/hello-windows-amd64.exe
npm notice 310B package.json
npm notice 756B run.js
npm notice Tarball Details
npm notice name: hello-go-cli-demo
npm notice version: 0.1.0
npm notice filename: hello-go-cli-demo-0.1.0.tgz
npm notice package size: 3.6 MB
npm notice unpacked size: 8.3 MB
npm notice shasum: 4dc2f0db43fb83f6c21b7c36e0a24fa045cacf79
npm notice integrity: sha512-2kp8CIgpJzZDg[...]sdiEMGSYRj6RA==
npm notice total files: 7
npm notice
npm notice Publishing to https://registry.npmjs.org/ with tag latest and public access
npm notice Your package is being processed and may take a few minutes to become available.
+ hello-go-cli-demo@0.1.0
发布成功后,换一个干净的目录验证:
npx hello-go-cli-demo@latest 世界
能看到输出,就说明你的 Go 程序已经可以通过 npm 触达全球用户了。
发布后的升级与维护
- 版本号不可复用:同一个版本号发布后不能覆盖。改了代码就升版本:
npm version patch && npm publish。 - 撤回有限制:
npm unpublish只在发布后短时间内允许,且会影响依赖方,更稳妥的做法是发布新版本,并用npm deprecate标记问题版本。 - 用 dist-tag 做灰度:
npm publish --tag beta,用户通过npx 包名@beta试用,latest不受影响。 - 自动化:正式项目建议用 GitHub Actions 在打标签时自动构建与发布,并优先使用 npm 提供的受信任发布(Trusted Publishing)或细粒度令牌,避免长期有效的高权限令牌。
想进阶?
当你的二进制变大(几十 MB),单包内置就不合适了。有两个升级方向:
- 改成飞书 CLI 的方式:包里只放
install.js,二进制交给 GitHub Releases,再用checksums.txt校验。 - 改成 esbuild 的方式:为每个平台发布子包(如
hello-go-cli-demo-linux-x64),主包用optionalDependencies引用它们,run.js里通过require.resolve找到匹配的子包二进制。npm 会自动只安装匹配当前系统的那个,不需要二次下载,也不怕--ignore-scripts。
避坑清单
--ignore-scripts与企业策略:很多公司默认禁用postinstall。所以run.js里要有懒加载兜底,或者直接改用optionalDependencies。- 国内网络:GitHub Releases 在国内下载不稳定,要像飞书 CLI 一样准备镜像回退,并尊重用户配置的 registry。
- 供应链安全:用
postinstall下载外部文件,一定要做校验和比对,并限制下载域名,别让安装脚本成为攻击入口。 - 静态编译:Go 记得
CGO_ENABLED=0,否则 Alpine(musl)等环境可能跑不起来。Rust 可考虑 musl 目标。 - Windows 文件占用:运行中的
.exe不能被覆盖,做自更新要参考“改名.old再恢复”的处理。 - macOS 签名:没有签名和公证的二进制可能被系统拦截。飞书 CLI 的 GoReleaser 配置里就包含了 macOS 签名与公证的步骤,面向公众的工具值得投入。
- 别忘了许可证与 README:
files白名单容易漏掉这两个文件,用npm pack --dry-run发布前检查一遍。
小结
回到开头的问题:为什么 Go、Rust 写的 CLI 都通过 npm 分发?
因为对开发者工具来说,npm 提供了一个几乎零成本的“跨平台分发 + 版本管理 + 免安装运行”的组合。你用 JS 写几十行启动器,换来的是统一的安装体验和极低的推广成本。
实现上则是三条路线:简单就内置,体积大就 postinstall 下载,追求稳健就用 optionalDependencies 子包。飞书 CLI 走的是第二条,并在校验、镜像回退、懒加载兜底上做足了工程细节。
最后留两个思考题,欢迎在评论区交流:
- 如果你的用户所在的公司全面禁用了
postinstall,你会怎样调整分发方案? - 除了 npm,PyPI(
pip/uvx)和 Cargo 也能分发预编译二进制,你觉得它们各自适合什么场景?
本文涉及的源码,可以在这里下载。
还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 《从0 开始构建 Agent Harness》 将带你:
- 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
- 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
- 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
- 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
- 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”
扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。

你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?
- 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
- 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
- 想打造生产级的Go服务,却在工程化实践中屡屡受挫?
继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!
我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。
目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。
