本文永久链接 – 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 上,亲手跑通完整链路。

【文章要点】

  1. 现象:原生 CLI 披着 npm 的外衣,本质是把 npm 当作“跨平台应用商店”。
  2. 原因:统一入口、npx 零安装、用户环境重合、免费 CDN 与版本管理、PATH 自动处理。
  3. 三种包装模式:fat package、postinstall 下载、optionalDependencies 子包,各有取舍。
  4. 案例拆解:飞书 CLI 的 package.json、install.js、run.js 和 GoReleaser 配置各自承担什么职责。
  5. 实战:注册 npm 账号、开启 2FA、交叉编译 Go、编写 run.js 启动器、npm publish、验证与升级。
  6. 避坑:国内网络、--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 包里放好几个平台的二进制,启动器按平台选择。

整体流程如下:

准备账号

  1. 打开 npmjs.com,注册账号,并验证邮箱。
  2. 在账号设置里开启双因素认证(2FA)。目前发布包需要满足 npm 的安全要求,建议直接使用验证器 App 开启。
  3. 本地确认已安装 Node.js,然后登录:
npm login --registry=https://registry.npmjs.org/
npm whoami --registry=https://registry.npmjs.org/  # 能输出你的用户名即成功
  1. 选一个包名,用 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),单包内置就不合适了。有两个升级方向:

  1. 改成飞书 CLI 的方式:包里只放 install.js,二进制交给 GitHub Releases,再用 checksums.txt 校验。
  2. 改成 esbuild 的方式:为每个平台发布子包(如 hello-go-cli-demo-linux-x64),主包用 optionalDependencies 引用它们,run.js 里通过 require.resolve 找到匹配的子包二进制。npm 会自动只安装匹配当前系统的那个,不需要二次下载,也不怕 --ignore-scripts。

避坑清单

  1. --ignore-scripts 与企业策略:很多公司默认禁用 postinstall。所以 run.js 里要有懒加载兜底,或者直接改用 optionalDependencies。
  2. 国内网络:GitHub Releases 在国内下载不稳定,要像飞书 CLI 一样准备镜像回退,并尊重用户配置的 registry。
  3. 供应链安全:用 postinstall 下载外部文件,一定要做校验和比对,并限制下载域名,别让安装脚本成为攻击入口。
  4. 静态编译:Go 记得 CGO_ENABLED=0,否则 Alpine(musl)等环境可能跑不起来。Rust 可考虑 musl 目标。
  5. Windows 文件占用:运行中的 .exe 不能被覆盖,做自更新要参考“改名 .old 再恢复”的处理。
  6. macOS 签名:没有签名和公证的二进制可能被系统拦截。飞书 CLI 的 GoReleaser 配置里就包含了 macOS 签名与公证的步骤,面向公众的工具值得投入。
  7. 别忘了许可证与 README:files 白名单容易漏掉这两个文件,用 npm pack --dry-run 发布前检查一遍。

小结

回到开头的问题:为什么 Go、Rust 写的 CLI 都通过 npm 分发?

因为对开发者工具来说,npm 提供了一个几乎零成本的“跨平台分发 + 版本管理 + 免安装运行”的组合。你用 JS 写几十行启动器,换来的是统一的安装体验和极低的推广成本。

实现上则是三条路线:简单就内置,体积大就 postinstall 下载,追求稳健就用 optionalDependencies 子包。飞书 CLI 走的是第二条,并在校验、镜像回退、懒加载兜底上做足了工程细节。

最后留两个思考题,欢迎在评论区交流:

  1. 如果你的用户所在的公司全面禁用了 postinstall,你会怎样调整分发方案?
  2. 除了 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技能再上一个新台阶!


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