大家好,我是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
|
|
|
--ignore-scripts 会失效
|
|
optionalDependencies
|
|
|
|
|
后面我们会看到,飞书 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"
iflen(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 也能分发预编译二进制,你觉得它们各自适合什么场景?
本文涉及的源码,可以在这里下载。
https://github.com/bigwhite/experiments/tree/master/npm-distribution/hello-go-cli
如果本文对你有所帮助,请帮忙点赞、推荐和转发
!
点击下面标题,阅读更多干货!
- 10倍速 TypeScript 7.0 正式发布,前Go产品经理:Go才是AI智能体时代的“天选语言”
- 性能之战的“罗生门”:Go 重写 Node.js 项目,究竟赢在了哪里?
- 火爆外网的 Go 开源神器 CLI Printing Press:一键生成 Agent 专属 CLI 工具
- 【Gemini CLI:重新定义命令行AI开发】01 入门篇 - 为什么你需要一个命令行 AI?
- 二进制的“魔术”:每个 Go 程序员都应掌握的位操作艺术
- Go开发命令行程序指南
- 事不过三,我们为什么总是学不好 Rust?
🔥 还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 《从0 开始构建 Agent Harness》 将带你:
-
抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理 -
用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw -
构建坚不可摧的 Safety Middleware 与飞书人工审批防线 -
在底层实现 Token 成本审计、链路追踪与自动化跑分评估 -
从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”
扫描下方二维码👇,开启从 0 开始构建Agent Harness 的实战之旅。

