當 CLI 開發告一段落後,下一步是將工具發布給使用者。目標使用者的電腦通常沒有 Go 開發環境,無法透過原始碼執行;而 npm 是目前普及度極高的套件管理工具。將兩者結合,能讓使用者透過熟悉的一行指令完成安裝或直接執行,同時保有 Go 原生執行檔的高效能。
整個機制的核心思路很單純:npm 套件內部打包各平台預先編譯好的原生執行檔,並由一支輕量的啟動腳本作為門戶,在執行時依據使用者的系統環境自動呼叫對應的檔案。
對應的範例專案位於 cli-sample/go-cli-release/。
從原始碼到使用者終端機的發布流程包含以下 5 個階段:
開發時可以用 go run 直接編譯到暫存目錄並執行:
go run .
要產生可以複製與發布的執行檔,使用 go build:
go test ./...
go build -o mytool .
./mytool
指定 . 代表編譯當前 package。這比 go build main.go 更適合真實專案,因為 main package 拆成多個 .go 檔後,所有檔案仍會一起參與編譯。
發布用的編譯可以加上 -trimpath,移除執行檔中與本機專案路徑有關的資訊:
go build -trimpath -o mytool .
手動進行跨平台編譯需要反覆切換環境變數(如 GOOS 與 GOARCH),而且不易自動注入版本號。GoReleaser 能依照單一設定檔,一次自動編譯出所有主流平台的執行檔,並在編譯時將 Git tag(如 v1.0.0)動態寫入程式中。
整個流程分為三個步驟:
動態注入版本的原理,是透過編譯參數修改 Go 程式的全域變數。因此我們先在程式碼中宣告 version 變數(本機開發預設為 "dev"),並交給 Cobra 設定:
package main
import "github.com/spf13/cobra"
var version = "dev"
func newRootCmd() *cobra.Command {
return &cobra.Command{
Use: "mytool",
Version: version,
}
}
.goreleaser.yaml安裝 GoReleaser(macOS 可執行 brew install goreleaser)後,在專案根目錄建立 .goreleaser.yaml:
version: 2
builds:
- id: mytool
main: .
binary: mytool
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Version }}
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
核心設定說明:
ldflags):-X main.version={{ .Version }} 會在編譯時讀取當前的 Git tag,動態覆蓋 Go 程式裡的 version 變數。使用者執行 mytool --version 就能看到正式版本號。CGO_ENABLED=0):停用 C 語言依賴,產出的執行檔不依賴目標系統的 C library,在不同的 Linux 發行版上都能直接執行。goos 與 goarch):搭配後會自動排列組合,一次產出 6 種平台的執行檔(macOS、Linux、Windows 的 x86-64 與 ARM64)。在正式打 Git tag 之前,可以使用「快照模式」(--snapshot)測試設定與編譯是否能順利通過:
# 1. 檢查設定檔語法
goreleaser check
# 2. 執行快照測試編譯(--clean 會在編譯前清空舊產物)
goreleaser build --snapshot --clean
編譯完成後,產物會存放在 dist/ 目錄:
dist/mytool_darwin_arm64/mytool)。dist/artifacts.json,詳細記錄每個執行檔的路徑與對應系統架構——這也是下一節自動化腳本用來包裝 npm 套件的關鍵依據。將 Go CLI 發布至 npm 時,我們需要把 GoReleaser 編譯好的跨平台執行檔放入 npm 套件中,並搭配一支 Node.js 啟動腳本。當使用者執行命令時,該腳本會根據使用者的作業系統與 CPU 架構自動呼叫對應的執行檔。
以下做法將六個平台的執行檔放進同一個 npm 套件中。
發布套件前,先到 npm 註冊頁面 建立帳號,並在帳號設定中啟用雙因素驗證。
你的 npm 使用者名稱也就是發布個人套件時使用的 scope。例如使用者名稱為 yourname 時,就能在自己的 scope 發布 @yourname/mytool。
在 terminal 登入並確認目前帳號:
npm login
npm whoami
# yourname
完成帳號登入後,在 repository 新增 npm/package.json:
{
"name": "@yourname/mytool",
"version": "0.0.0",
"description": "我的 CLI 工具",
"license": "MIT",
"bin": {
"mytool": "bin/mytool.js"
},
"files": [
"bin"
],
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
}
}
設定說明:
version 欄位裡的 "0.0.0" 是本機開發與樣板中的預設佔位值。正式發布時,準備腳本 prepare.mjs 會自動把這裡覆蓋為 Git tag 的正式版本號。name 欄位裡的 @yourname 需替換為剛才確認的 npm 帳號名稱(npm scope)。例如 npm 使用者名稱為 evanchen,請改為 @evanchen/mytool。公開的 scoped package 可搭配 publishConfig.access 設為 public。bin 欄位告訴 npm 當使用者在終端機輸入 mytool 命令時要連到的啟動檔。Unix-like 系統會建立 symlink,Windows 則會建立 .cmd 啟動檔。在專案中手動建立 npm/bin/mytool.js 啟動器腳本:
💡
npm/bin/mytool.js是一支需要手動建立並加入版控的通用 Node.js 啟動腳本;而存放於npm/bin/native/目錄中的各平台 Go 執行檔,才是後續由自動化腳本複製產生的。
#!/usr/bin/env node
const { join } = require("node:path");
const { spawnSync } = require("node:child_process");
const platforms = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const architectures = {
x64: "amd64",
arm64: "arm64",
};
const platform = platforms[process.platform];
const architecture = architectures[process.arch];
if (!platform || !architecture) {
console.error(
`Unsupported platform: ${process.platform}/${process.arch}`,
);
process.exit(1);
}
const extension = process.platform === "win32" ? ".exe" : "";
const binary = join(
__dirname,
"native",
`mytool_${platform}_${architecture}${extension}`,
);
const result = spawnSync(binary, process.argv.slice(2), {
stdio: "inherit",
});
if (result.error) {
console.error(result.error.message);
process.exit(1);
}
if (result.signal) {
process.kill(process.pid, result.signal);
}
process.exit(result.status ?? 1);
在 macOS 或 Linux 設定啟動器的執行權限:
chmod +x npm/bin/mytool.js
啟動器只負責選擇 binary、轉交參數,以及把標準輸入、標準輸出、標準錯誤與結束狀態傳回目前的 terminal。CLI 的功能仍由 Go 執行檔提供。
準備好啟動器後,我們需要一個自動化腳本,負責把 GoReleaser 編譯出來放在 dist/ 的跨平台執行檔,自動複製整理進 npm/bin/native/ 目錄中。
GoReleaser 在編譯時會把所有產物的檔案路徑與目標架構寫入 dist/artifacts.json。我們在 npm/scripts/prepare.mjs 撰寫打包準備腳本,讀取這份資料完成整理:
import {
chmodSync,
copyFileSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const version = process.argv[2]?.replace(/^v/, "");
if (!version) {
throw new Error("Usage: node npm/scripts/prepare.mjs <version>");
}
const scriptDir = dirname(fileURLToPath(import.meta.url));
const packageDir = resolve(scriptDir, "..");
const repositoryDir = resolve(packageDir, "..");
const nativeDir = join(packageDir, "bin", "native");
const artifactsFile = join(repositoryDir, "dist", "artifacts.json");
const supported = new Set([
"darwin/amd64",
"darwin/arm64",
"linux/amd64",
"linux/arm64",
"windows/amd64",
"windows/arm64",
]);
const artifacts = JSON.parse(readFileSync(artifactsFile, "utf8"));
const binaries = artifacts.filter(
(artifact) =>
artifact.type === "Binary" &&
supported.has(`${artifact.goos}/${artifact.goarch}`),
);
if (binaries.length !== supported.size) {
throw new Error(
`Expected ${supported.size} binaries, received ${binaries.length}`,
);
}
rmSync(nativeDir, { recursive: true, force: true });
mkdirSync(nativeDir, { recursive: true });
for (const artifact of binaries) {
const extension = artifact.goos === "windows" ? ".exe" : "";
const filename =
`mytool_${artifact.goos}_${artifact.goarch}${extension}`;
const source = resolve(repositoryDir, artifact.path);
const target = join(nativeDir, filename);
copyFileSync(source, target);
if (artifact.goos !== "windows") {
chmodSync(target, 0o755);
}
}
const packageFile = join(packageDir, "package.json");
const packageJson = JSON.parse(readFileSync(packageFile, "utf8"));
packageJson.version = version;
writeFileSync(
packageFile,
`${JSON.stringify(packageJson, null, 2)}\n`,
);
最後,在正式發布之前,可以在本機打包並預覽套件內容,確認一切運作正常:
# 1. 以快照模式測試編譯出各平台的 Go 執行檔到 dist/ 資料夾
goreleaser build --snapshot --clean
# 2. 執行準備腳本,將 dist/ 中的二進位檔複製整理進 npm/bin/native/
node npm/scripts/prepare.mjs 0.0.0-test
# 3. 建立存放 npm 測試打包產物的目標資料夾
mkdir -p dist/npm
# 4. 將 npm/ 資料夾打包成測試用 .tgz 壓縮套件
npm pack ./npm --pack-destination dist/npm
# 5. 在本機全域安裝剛剛打包好的 .tgz 套件
npm install -g ./dist/npm/yourname-mytool-0.0.0-test.tgz
# 6. 測試執行的 CLI 命令與版本輸出是否正常
mytool --version
# 7. 測試完成後卸載本機測試套件
npm uninstall -g @yourname/mytool
npm pack 會印出最終壓縮包(.tgz)裡包含的檔案清單。確認裡面只有 JavaScript 啟動器與六個對應平台的 Go 執行檔,避免不小心把本機專案的原始碼、測試檔案或憑證也包進 npm 套件中。
確認 npm 已登入且 npm/package.json 欄位設定正確後,即可進行正式發布。
正式發布前的核心步驟是建立 Git tag。這是因為 GoReleaser 在編譯時會自動讀取目前的 Git tag,將版本號(例如 v1.0.0)注入到 Go 執行檔中,同時我們的準備腳本 prepare.mjs 也會將此版本號同步寫入 package.json 的 "version" 欄位,確保 npm 套件與 Go 執行檔的版本一致。
確認目前位於要發布的 commit 並建立版本 tag:
git status
git tag -a v1.0.0 -m "release v1.0.0"
git push origin v1.0.0
接著讓 GoReleaser 讀取 Git tag 並正式編譯各平台執行檔:
goreleaser build --clean
GoReleaser 完成編譯後,執行準備腳本並預覽打包內容:
# 1. 讀取 GoReleaser 產出的檔案與 tag 版本,整理進 npm/ 目錄
node npm/scripts/prepare.mjs v1.0.0
# 2. 模擬打包並預覽將被發布的檔案清單
npm pack ./npm --dry-run
確認 npm pack 列出的檔案與版本正確後,即可公開發布至 npm registry:
npm publish ./npm
執行 npm publish 時,npm 會要求完成雙因素驗證。同一個 package 名稱與版本發布後不能再次覆蓋或修改,因此若 package 內容有調整,必須更新版本號並建立新的 Git tag 再發布。
Windows、macOS 與 Linux 使用者可以透過 npm 安裝:
npm install -g @yourname/mytool
mytool --version
不安裝到全域時,可以用 npx 直接執行:
npx @yourname/mytool --version
npm 更新時指定 latest,避免現有的 SemVer range 保留舊版:
npm install -g @yourname/mytool@latest
透過「GoReleaser 跨平台自動編譯」結合「npm 輕量啟動器」,我們在不要求使用者安裝 Go 環境的前提下,既能享有 Go 原生執行檔的高效能,又能藉由 npm 獲得極低的安裝與散布門檻(同時支援全域安裝與 npx 免安裝直接執行)。當工具能夠順暢發布後,下一篇將進一步探討 CLI 在處理外部輸入、敏感設定與執行權限時的安全防護機制。