multi channel install test

This commit is contained in:
rendianmeng
2026-07-23 17:34:30 +08:00
parent 678f60be75
commit 66a797203c
29 changed files with 1678 additions and 179 deletions
+19 -4
View File
@@ -29,11 +29,11 @@ concurrency:
jobs:
publish-stable:
if: inputs.mode == 'stable'
name: publish stable (${{ inputs.package }}) to npm + tag
name: publish stable (${{ inputs.package }}) to npm + binary + tag
runs-on: ubuntu-latest
environment: production # Required Reviewers gate
permissions:
contents: write # push lightweight tag to origin
contents: write # push tag + create GitHub Release with binary assets
id-token: write # OIDC for npm Trusted Publishing + provenance
steps:
- uses: actions/checkout@v6
@@ -57,15 +57,23 @@ jobs:
- run: pnpm install --frozen-lockfile
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2.19"
- name: publish-stable
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Optional: HTTP trigger for external FC (GitHub Release → OSS). Leave unset to skip.
BAILIAN_OSS_SYNC_WEBHOOK: ${{ secrets.BAILIAN_OSS_SYNC_WEBHOOK }}
run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }}
publish-channel:
if: inputs.mode == 'channel'
name: publish channel (${{ inputs.package }}) to npm
name: publish channel (${{ inputs.package }}) to npm + binary
runs-on: ubuntu-latest
permissions:
contents: read # no tag, no Release; just publish
contents: write # create prerelease GitHub Release with binary assets
id-token: write # OIDC for npm Trusted Publishing + provenance
steps:
- uses: actions/checkout@v6
@@ -89,5 +97,12 @@ jobs:
- run: pnpm install --frozen-lockfile
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2.19"
- name: publish-channel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BAILIAN_OSS_SYNC_WEBHOOK: ${{ secrets.BAILIAN_OSS_SYNC_WEBHOOK }}
run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}"
+1
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
# Dependencies & build output
node_modules
dist
dist-bin
dist-ssr
tools/generated
.node-version
+2 -1
View File
@@ -57,7 +57,7 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
按当前任务从下表挑一条进入对应文档:
| 场景 | 何时进入 | 详见 |
| -------------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
| -------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
@@ -69,6 +69,7 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
| 发布 | channel / stable 发布到 npmCI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| 二进制分发 | Bun compile / install.sh / GitHub Releases / update 分流 | [docs/agents/binary-distribution.md](docs/agents/binary-distribution.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
+34 -82
View File
@@ -1,125 +1,77 @@
# 阿里云百炼CLI 安装说明(供 AI Agent 阅读)
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**npm 包名 `bailian-cli`命令 `bl` / `bailian`)。用户通常通过 **npm 全局安装** 使用,**无需**访问本仓库源码。不要臆造版本号或路径;以用户环境为准。
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**使用二进制一键安装(无需 Node若环境已有 Node / 需要 Command Pack再用 npm。不要臆造版本号或路径;以用户环境为准。
## 1. 安装前检查
## 1. 推荐:二进制安装(无需 Node
**原则**:先执行检查;若 **Node / 包管理器 / 网络** 任一不满足下文要求Agent 应 **在本机终端内主动完成安装或配置**(例如升级 Node、切换镜像、配置 `npm` 代理等),再继续 `bailian-cli` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。
```bash
curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash
```
1. **Node.js**
- 要求 **≥ 18.17.0**,执行 `node -v` 确认。
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
Windows PowerShell
2. **npm****唯一**允许的包管理器)
- 执行 `npm -v` 确认可用。
- 未安装或不可用:由 Agent **安装或修复 npm**(不要改用 pnpm/yarn 装 `bailian-cli`),再执行 `npm install -g bailian-cli`
- 即使本机已安装 `pnpm``yarn` 等,**也仅用于其它项目**;装 `bailian-cli` 时仍必须用 `npm`
```powershell
irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex
```
3. **网络**
- 需能访问 npm registry默认 **registry.npmjs.org**)。
- 不可达:由 Agent 自行配置镜像或代理后重试。
校验:
---
```bash
bl --version
which bl # Windows: where.exe bl
```
## 2. 安装 CLI 与 Skills
`command not found`:把 `~/.local/bin`Windows 为 `%LOCALAPPDATA%\bailian-cli\bin`)加入 PATH 后开新终端。
**仅允许以下命令**(不要用 `pnpm add -g``yarn global add` 等)。按顺序执行,上一步通过后再进行下一步
> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装
**2.1 安装 CLI**
## 2. 备选npm 安装(需要 Node.js >= 18.17
1. `node -v` 确认版本。
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn
3. 执行:
```bash
npm install -g bailian-cli
```
安装成功后,应能在 PATH 中找到:
4. 校验:`bl --version`
- `bl`(短别名)
- `bailian`(全名)
**校验**Agent 应执行并检查退出码与输出):
```bash
bl --version
which bl # Windows 可用 where bl
```
`command not found`:检查全局 bin 是否在 PATH`npm config get prefix`,其下 `bin` 目录应加入 PATH
**2.2 安装 Skills**
CLI 校验通过后,在本机终端执行:
可选 skills与 CLI 本体无关,按需):
```bash
npx skills add modelstudioai/cli --all -g
```
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
---
## 3. 鉴权(安装后必做才能调 API
### 推荐:浏览器登录(控制台会话)
适用于本机交互式安装,无需用户手动复制 API Key
1. 执行 `bl auth status --output json`,判断是否已配置。
2. 若未配置,在**用户本机终端**执行 `bl auth login --console`;命令会拉起浏览器完成阿里云控制台登录授权
2. 若未配置,在**用户本机终端**执行 `bl auth login --console`
3. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整凭据。
> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。
### 备选API Key / Token Plan
### 备选一:由 Agent 引导用户输入普通 API Key 后登录
适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等):
- 获取入口:[百炼控制台 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key)
1. 执行 `bl auth status --output json`,判断是否已配置。
2. 若未配置或后续 API 校验失败,**请用户粘贴 API Key**(可说明从上述控制台复制;勿要求用户发到公开渠道)。
3. 用户提供了 Key 之后,在**用户本机终端**执行Agent 用终端工具跑,勿把 Key 写进回复正文):`bl auth login --api-key <用户提供的_Key>`
4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整 Key。
### 备选二:使用 Token Plan API Key
- 获取入口:[Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview)
1. 请用户从订阅详情页获取或复制 Token Plan API Key勿要求用户发到公开渠道。
2. 在用户本机终端执行:`bl auth login --config token-plan --api-key <用户提供的_Key>`
3. `token-plan` Profile 已内置默认 Base URL登录命令会先测试 Key通过后才保存并激活该 Profile无需另行配置或重复测试。
4. 执行 `bl auth status --config token-plan --output json` 确认;汇报时只使用 masked 字段。
### 其他方式
- **环境变量**(不落盘到配置文件):在 shell 中配置 API Key 环境变量;变量名见 `bl auth status --help`,勿在对话中向用户解释底层命名。
- **写入配置文件**(持久化,与 `auth login` 落盘相同):`bl config set --key api_key --value <key>``--key api-key` 亦可)。**不会**像 `bl auth login --api-key` 那样先校验 Key 是否可用Agent 引导安装时仍**优先**用 `auth login`
- **命令行临时传入**:需要 API Key 的 `bl` 子命令可在**当次**执行附加全局 `--api-key <key>`,仅本次生效、不落盘(例:`bl text chat --api-key sk-xxx --message "你好"`)。与上文持久化方式不是同一用途。
- 普通 Key`bl auth login --api-key <Key>`
- Token Plan`bl auth login --config token-plan --api-key <Key>`
### Agent 安全约束
- **禁止**把真实 API Key 写入仓库、日志、Skill、聊天记录的可公开部分。
- CI / 非交互环境:使用 `bl ... --non-interactive`;通过密钥管理或环境变量注入,勿在脚本中硬编码 Key
---
- CI / 非交互环境:使用 `bl ... --non-interactive`;通过密钥管理或环境变量注入。
## 4. 配置验证
API Key 登录命令本身已经完成可用性测试,通过后只需确认配置状态:
```bash
bl auth status --output json
```
无需再执行重复的模型调用测试。若登录失败,根据 stderr / JSON 中的 `hint``message` 排查网络、Key 无效、`base_url`。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`
---
## 5. 常见问题Agent 排障清单)
## 5. 常见问题
| 现象 | 可能原因 | 建议动作 |
| ----------------------- | -------------------- | --------------------------------------------------------------- |
| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH |
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 18.17 |
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 |
| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 |
| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm |
| ----------------------- | ---------------------------- | -------------------------------------- |
| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin``npm prefix -g` |
| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` |
| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node或改用 npm 版 CLI |
| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17 |
+8 -2
View File
@@ -77,11 +77,17 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
## Installation
```bash
# Recommended — no Node required
curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash
# Windows (PowerShell)
irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex
# Node users / developers (Node.js >= 18.17)
npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 18.17.
> Binary install does not require Node.js. `npm install -g` remains fully supported.
## Quick Start
+8 -2
View File
@@ -75,11 +75,17 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
## 安装
```bash
# 推荐 — 无需本机 Node.js
curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash
# WindowsPowerShell
irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex
# Node 用户 / 开发者(需要 Node.js >= 18.17
npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 18.17
> 二进制安装不依赖 Node.js。`npm install -g` 长期保留
## 快速开始
+63
View File
@@ -0,0 +1,63 @@
# 二进制分发GitHub Release → 外部 FC → OSS 安装)
> 完整技术方案:[docs/proposals/binary-distribution.md](../proposals/binary-distribution.md)
## 触发条件
- 修改 `packages/cli/src/main.ts``tools/release/lib/binary-*`
- 调整 Release 资产 / manifest / 可选 `BAILIAN_OSS_SYNC_WEBHOOK`
- 发版需要把独立二进制推到 **GitHub Releases**(本仓职责到此)
## 概念
```text
Publish workflow
├─ npm
└─ GitHub Release ← 本仓库
外部 FC → 同步 OSS ← 仓外
外置 install.sh / ps1 ← 仓外,只拉 OSS
```
- **Source of truth**GitHub Release
- **国内安装面**OSS + 外置脚本
- 本仓 `packaging/install.*`:契约**参考**,不挂 Release、不作为生产入口
可选 Secret`BAILIAN_OSS_SYNC_WEBHOOK`(发版后 POST 通知 FC失败仅 warn
## 必查清单
### A. 本仓库构建 / Release
- [ ] `node tools/release/lib/binary-build.mjs --mode stable --host`
- [ ] `dist-bin/` 含矩阵二进制、`SHA256SUMS``latest.json`channel 为 `<name>.json`
- [ ] dry-run`node tools/release/lib/binary-release.mjs --mode stable --skip-build --dry-run`
- [ ] Release **不含** 生产 install 脚本
### B. 仓外(联调时确认)
- [ ] FC 已同步本次 Release 到 OSS路径与参考脚本一致
- [ ] 外置 `install.sh` / `install.ps1` 可从 OSS 安装
### C. 运行时
- [ ] `bl update` 二进制路径读 OSS manifest`BAILIAN_CLI_CDN`
- [ ] 无 npm 时 plugin hint 明确
## 完成后自查
```sh
node tools/release/lib/binary-build.mjs --mode stable --host
node tools/release/lib/binary-release.mjs --mode stable --skip-build --dry-run
vp check
```
## 常见漏点
| 漏点 | 后果 |
| ---------------------- | ------------------------- |
| 只发 npm、未建 Release | FC 无源可同步 |
| FC 未跑完用户就 curl | OSS 404 / 半包 |
| 矩阵变更未通知脚本方 | 装错 arch / 永久失败 |
| webhook 配错当发版失败 | 不应webhook 失败只 warn |
+27 -9
View File
@@ -1,27 +1,39 @@
# 发布npm publish
# 发布npm + GitHub Release 二进制
## 触发条件
- 准备发布 channelbeta/mcp/plugin 等)或正式版到 npm
- 准备打 git tag
- 准备发布 channelmcp/plugin 等)或正式版到 npm **与** GitHub Releases 二进制
- 准备打 git tag(仅 stable
## 发布方式GitHub Actions + npm OIDC
## 发布方式GitHub Actions 总入口
发版**必须**通过 CI 完成,不要本地手动 `pnpm publish`
入口GitHub Actions → **Publish** workflow`.github/workflows/publish.yml`)→ Run workflow。
**编排关系(重要):**
```text
publish-stable.mjs / publish-channel.mjs ← 唯一发版入口
├─ npmpnpm publish
└─ binarylib/binary-release → lib/binary-build + gh release
```
`tools/release/lib/binary-release.mjs` / `binary-build.mjs` 是实现,一般不要单独当发版入口(调试可用)。详细约定见 [binary-distribution 方案](../proposals/binary-distribution.md)。
两种模式:
| 模式 | 用途 | 触发方式 |
| ------- | ------------------------------ | -------------------------------------------------- |
| channel | 发 channel 版本到指定 dist-tag | 选 mode=channel填 dist-tag 名称(如 mcp/plugin |
| stable | 正式发版到 latest | 选 mode=stable需 production environment 审批 |
| ------- | -------------------------------------------------------- | -------------------------------------------------- |
| channel | npm dist-tag + GitHub prerelease + 滚动 `channel-<name>` | 选 mode=channel填 dist-tag 名称(如 mcp/plugin |
| stable | npm latest + GitHub Release `v<ver>`(含 install 脚本) | 选 mode=stable需 production environment 审批 |
可选 flag`--skip-binary`(仅发 npm紧急逃生
### channel 发布
1. 在 GitHub 触发 Publish workflowpackage 选 `bailian-cli``knowledge-studio-cli`mode 选 `channel`channel 填 dist-tag 名(如 `mcp`
2. CI 自动:生成 `0.0.0-beta-<sha7>-<date>` 版本号 → 临时 bump 对应包集合 → 自检 → 构建 → 发布到指定 dist-tag
2. CI 自动:生成 `0.0.0-beta-<sha7>-<date>` → 临时 bump → 自检 → **npm 发到 dist-tag****Bun 编二进制并创建 GitHub prerelease + 滚动 channel manifest** → 还原 package.json
3. 对应脚本:`tools/release/publish-channel.mjs`
### stable 发布
@@ -29,9 +41,10 @@
1. 确保当前 release tooling 覆盖的包(`tools/release/lib/packages.mjs`)已升到目标版本且一致;当前基础集合为 `packages/core` / `packages/runtime` / `packages/commands` / `packages/cli``knowledge-studio-cli` 发布会额外包含 `packages/kscli`
2. 在 GitHub 触发 Publish workflowpackage 选目标包集合mode 选 `stable`
3. 需要 production environment 审批人批准
4. CI 自动:自检 → 构建 → 检查 npm 已发布版本 → 发布到 latest → 打 git tag
4. CI 自动:自检 → **npm 发到 latest****推送 git tag `v<ver>`****Bun 编二进制并创建/更新 GitHub Release** → 完成
5. 如果所选发布集合的当前版本已全部存在于 npmstable 发布会失败并提示先升级版本号如果只有部分包已发布CI 会继续补发缺失包
6. 对应脚本:`tools/release/publish-stable.mjs`
7. 二进制上传使用 `GITHUB_TOKEN``gh release`**不需要** OSS AccessKey
## 自检(`tools/release/check.mjs`
@@ -59,7 +72,9 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
## CI 基础设施
- **认证**npm OIDC Trusted Publishing无 token需要 `id-token: write` 权限
- **GitHub Release**`contents: write` + `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`stable / channel 均需)
- **Node 版本**24npm 11.5+ 才支持 OIDC token 交换)
- **Bun**`oven-sh/setup-bun`,版本钉死在 workflow 中
- **Actions 版本**checkout/setup-node/pnpm-action 均为 v6Node 24 兼容)
- **npm 配置**:当前 release tooling 发布的包(`bailian-cli-core` / `bailian-cli-runtime` / `bailian-cli-commands` / `bailian-cli` / `knowledge-studio-cli`)的 Trusted Publisher 指向 `modelstudioai/cli``publish.yml`;新增发布包时同步 npm Trusted Publisher
@@ -90,6 +105,7 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
- [ ] 验证 npm 上能装:`npm view bailian-cli@<tag> version`;如发布 `knowledge-studio-cli`,同时 `npm view knowledge-studio-cli@<tag> version`
- [ ] 试装一次:`npm i -g bailian-cli@<tag> && bl --version`;如发布 `knowledge-studio-cli`,同时 `npm i -g knowledge-studio-cli@<tag> && kscli --version`
- [ ] 若本次含二进制GitHub Release 页可见资产,`install.sh` / `latest.json` 经 FC 同步到 OSS 后可访问;清单见 [binary-distribution.md](binary-distribution.md)
## 常见漏点(基于历史踩坑)
@@ -105,3 +121,5 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上publish 报 404 |
| CI 用 Node 22npm 10跑 publish | npm 10 不支持 OIDC token 交换publish 报 404 |
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npmCI 明确报错并要求先升级版本号 |
| channel job 缺少 `contents: write` | `gh release create` 失败 |
| stable 未先推 tag 就建 Release | `--verify-tag` 失败 |
+171
View File
@@ -0,0 +1,171 @@
# 技术方案GitHub Release 发布 + 外部 FC 同步 OSS 安装
> 状态:**已定架构**(本仓库不直传 OSS安装脚本外置**不含 Homebrew**)。
> 范围:仅 `bailian-cli``bl`);不含 `kscli` 二进制。
> 操作清单:[docs/agents/binary-distribution.md](../agents/binary-distribution.md)
> 发版编排:[docs/agents/publish.md](../agents/publish.md)
---
## 1. 目标架构
```text
Publish workflow本仓库
├─ npm
└─ GitHub Releasesource of truth
调用外部 FC拉取最新 Release → 同步 OSS
别处维护的 install.sh / install.ps1 → 只拉 OSS
```
| 环节 | 谁负责 | 本仓库是否实现 |
| ------------------------- | ------------ | ------------------------------- |
| npm publish | 本仓库 CI | ✅ |
| Bun 编译 + GitHub Release | 本仓库 CI | ✅ |
| Release → OSS 同步 | **外部 FC** | ❌(仅可选 webhook 触发) |
| 生产安装脚本 | **别处维护** | ❌(`packaging/` 仅作契约参考) |
| 用户 curl / irm | OSS 上的脚本 | ❌ |
本仓库**不存放、不上传 OSS AccessKey**;开源侧只用 `GITHUB_TOKEN`
---
## 2. 本仓库发版Publish
入口:`.github/workflows/publish.yml``publish-stable.mjs` / `publish-channel.mjs`
```text
stable:
check → npm latest → git tag v<ver> → gh release正式
assets: bl-*, SHA256SUMS, latest.json
(不再把 install.sh/ps1 挂到 Release生产脚本外置
channel:
bump beta → npm @channel → gh prerelease v<beta>
+ 滚动 prerelease tag channel-<name>(仅 <name>.json
```
可选:发版成功后 `POST` `BAILIAN_OSS_SYNC_WEBHOOK`,通知 FC 开始同步Secret 配置,无则跳过)。
`--skip-binary`:只发 npm。
### 构建矩阵
| Bun target | 产物 |
| ------------------ | ----------------- |
| `bun-darwin-arm64` | `darwin-arm64` |
| `bun-darwin-x64` | `darwin-x64` |
| `bun-linux-x64` | `linux-x64` |
| `bun-windows-x64` | `windows-x64.exe` |
不构建 `linux-arm64` / `windows-arm64`
---
## 3. GitHub Release 约定source of truth
基址:`https://github.com/modelstudioai/cli/releases`
| 模式 | Tag | 资产 |
| -------------- | ---------------------------------------------- | --------------------------------------- |
| stable | `v<version>`(先 push tag`--verify-tag` | 矩阵二进制、`SHA256SUMS``latest.json` |
| channel 版本化 | `v0.0.0-beta-<sha7>-<date>`prerelease | 二进制、`SHA256SUMS` |
| channel 滚动 | `channel-<name>`prereleaseclobber | `<name>.json` |
Manifest 内 `url` 指向 GitHub download给 FC / 镜像方解析用。FC 同步到 OSS 时应**改写**为 OSS URL或安装脚本忽略 `url`、按固定 OSS 路径拼接。
---
## 4. 外部 FC本仓库不实现
建议契约:
1. 触发Publish webhook或监听 `release` 事件 / 定时拉取 Latest
2. 读取 GitHub Lateststable或约定 channel tag
3. 下载资产 → 上传 OSS建议布局
```text
https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/
channels/latest.json # 改写 url 后的正式版 manifest
channels/<channel>.json # 可选测试渠道
releases/<version>/
bl-<version>-<os>-<arch>[.exe]
SHA256SUMS
install.sh # 由脚本维护方上传,非本仓库 CI
install.ps1
```
4. **完整校验后再对外**SHA256 对齐、矩阵文件齐全避免「Release 已发、OSS 半同步」窗口误导用户
5. 失败告警(钉钉/飞书/SLS因用户安装不经过本仓库 CI
---
## 5. 安装脚本(别处维护)
生产入口(示例,以实际 OSS 域名为准):
```bash
curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash
# Windows:
irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex
```
脚本只依赖 OSS不要求用户访问 GitHub。
本仓 `packaging/install.sh` / `install.ps1`**契约参考**(路径、校验、`install-method=binary`),不作为生产分发物挂 Release。
### 与本仓的契约(脚本方必须遵守)
- 资产名:`bl-<ver>-<os>-<arch>[.exe]`
- 校验:`SHA256SUMS`
- 渠道:默认 `latest`;可选 `BAILIAN_CHANNEL`
- 写入:`~/.bailian/install-method` = `binary`
- 不支持linux-arm64 / windows-arm64 → 提示 `npm i -g bailian-cli`
---
## 6. 运行时(本仓库)
- `BAILIAN_COMPILED=1`、install-method、`bl update` 分流仍在本仓
- 二进制更新默认读 **OSS** manifest`BAILIAN_CLI_CDN` 可覆盖);与用户安装源一致
- GitHub Release 仍是发版真相源;更新链路走 OSS 镜像
---
## 7. 职责边界(验收标准)
| 验收项 | 通过条件 |
| ----------------- | ----------------------------------------------------- |
| 本仓库 Publish 绿 | npm 可装 + GitHub Release 资产齐全 |
| 国内可一键安装 | FC 已同步 + 外置脚本可 curl**不在本仓 CI 门禁内** |
| 无 AK 进 git | OSS 密钥只在 FC / 脚本发布流水线 |
---
## 8. 风险
- Release 成功 ≠ 用户能装(依赖 FC→ 必须有同步监控
- 脚本外置 → 命名/矩阵变更要同步通知脚本方
- `latest.json` 双份GH / OSS→ FC 负责改写与一致性
- channel 是否进 OSS由 FC 与脚本方另定;本仓照常发 prerelease
---
## 9. 本仓库代码落点
| 路径 | 职责 |
| --------------------------------------------- | ------------------------------------- |
| `publish-stable.mjs` / `publish-channel.mjs` | npm + Release + 可选 webhook |
| `lib/binary-build.mjs` / `binary-compile.mjs` | 编译与 manifest |
| `lib/binary-release.mjs` | `gh release`(不含生产 install 脚本) |
| `packaging/*` | 安装契约参考 |
| `.github/workflows/publish.yml` | CI |
调试:
```sh
node tools/release/lib/binary-build.mjs --mode stable --host
node tools/release/lib/binary-release.mjs --mode stable --skip-build --dry-run
node tools/release/publish-stable.mjs --dry-run
```
+8 -2
View File
@@ -77,11 +77,17 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
## Installation
```bash
# Recommended — no Node required
curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash
# Windows (PowerShell)
irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex
# Node users / developers (Node.js >= 18.17)
npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> Requires Node.js >= 18.17.
> Binary install does not require Node.js. `npm install -g` remains fully supported.
## Quick Start
+8 -2
View File
@@ -75,11 +75,17 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
## 安装
```bash
# 推荐 — 无需本机 Node.js
curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash
# WindowsPowerShell
irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex
# Node 用户 / 开发者(需要 Node.js >= 18.17
npm install -g bailian-cli
npx skills add modelstudioai/cli --all -g
```
> 需要预先安装 Node.js >= 18.17
> 二进制安装不依赖 Node.js。`npm install -g` 长期保留
## 快速开始
+71 -27
View File
@@ -1,22 +1,26 @@
import { execSync } from "child_process";
import { writeFileSync } from "fs";
import { join } from "path";
import { defineCommand, getConfigDir } from "bailian-cli-core";
import { ansi, fetchLatestVersion, type AnsiStyles } from "bailian-cli-runtime";
import {
defineCommand,
getConfigDir,
getInstallMethod,
type InstallMethod,
} from "bailian-cli-core";
import {
ansi,
fetchLatestVersion,
fetchBinaryChannelVersion,
performBinaryUpdate,
type AnsiStyles,
} from "bailian-cli-runtime";
const SKILL_SOURCE = "modelstudioai/cli";
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
/** Build the install command for the given npm package. */
function detectInstallCommand(npmPackage: string): { cmd: string; label: string } {
return { cmd: `npm install -g ${npmPackage}@latest`, label: "npm" };
}
function updateAgentSkill(color: AnsiStyles): void {
process.stderr.write("\nUpdating agent skill...\n");
try {
// Reinstall (not `skills update`) into ~/.agents/skills/ and sync to all agent apps.
// `--all` on `skills add` means --skill '*' --agent '*' -y (Cursor, Claude Code, etc.).
execSync(SKILL_INSTALL_CMD, { stdio: "inherit" });
process.stderr.write(`${color.green("\u2713 Agent skill updated.")}\n`);
} catch {
@@ -26,6 +30,25 @@ function updateAgentSkill(color: AnsiStyles): void {
}
}
function writeUpdateState(version: string): void {
try {
const stateFile = join(getConfigDir(), "update-state.json");
writeFileSync(stateFile, JSON.stringify({ lastChecked: Date.now(), latestVersion: version }));
} catch {
/* ignore */
}
}
async function resolveLatest(method: InstallMethod, npmPackage: string): Promise<string | null> {
if (method === "binary") {
return (
(await fetchBinaryChannelVersion("latest", 5000)) ??
(await fetchLatestVersion(5000, npmPackage))
);
}
return fetchLatestVersion(5000, npmPackage);
}
export default defineCommand({
description: "Update the CLI to the latest version",
auth: "none",
@@ -36,46 +59,67 @@ export default defineCommand({
const binName = identity.binName;
const currentVersion = identity.version;
const color = ansi(process.stderr);
const method = getInstallMethod();
process.stderr.write(`Current version: ${color.yellow(currentVersion)}\n`);
// Check latest version first
process.stderr.write(`Install method: ${color.dim(method)}\n`);
process.stderr.write("Checking for updates...\n");
const latest = await fetchLatestVersion(5000, npmPackage);
if (method === "brew" || method === "winget") {
const cmd =
method === "brew" ? "brew upgrade bailian-cli" : "winget upgrade Aliyun.BailianCLI";
process.stderr.write(
`${color.yellow(`This CLI was installed via ${method}. Update with:`)}\n ${cmd}\n`,
);
return;
}
const latest = await resolveLatest(method, npmPackage);
if (latest && latest === currentVersion) {
process.stderr.write(`${color.green(`\u2713 Already up to date (${currentVersion}).`)}\n`);
updateAgentSkill(color);
if (method === "npm") updateAgentSkill(color);
return;
}
if (latest) {
process.stderr.write(`Latest version: ${color.green(latest)}\n\n`);
} else {
process.stderr.write(`${color.yellow("Could not determine the latest version.")}\n`);
return;
}
const { cmd, label } = detectInstallCommand(npmPackage);
process.stderr.write(`Updating ${npmPackage} via ${label}...\n\n`);
if (method === "binary") {
process.stderr.write(`Updating via binary channel...\n\n`);
try {
const newVer = await performBinaryUpdate(latest);
process.stderr.write(
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`,
);
writeUpdateState(newVer);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`\nAutomatic binary update failed: ${message}\n`);
process.stderr.write("Re-run the install script:\n");
process.stderr.write(
" curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash\n\n",
);
}
return;
}
const cmd = `npm install -g ${npmPackage}@latest`;
process.stderr.write(`Updating ${npmPackage} via npm...\n\n`);
try {
execSync(cmd, { stdio: "inherit" });
// Verify the installed version after update
try {
const rawVer = execSync(`${binName} --version 2>/dev/null`, { encoding: "utf-8" }).trim();
// `<bin> --version` outputs "<bin> X.Y.Z" — extract just the version number
const newVer = rawVer.replace(new RegExp(`^${binName}\\s+`), "");
process.stderr.write(
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`,
);
// Update the cached state so the post-run notification doesn't fire
try {
const stateFile = join(getConfigDir(), "update-state.json");
writeFileSync(
stateFile,
JSON.stringify({ lastChecked: Date.now(), latestVersion: newVer }),
);
} catch {
/* ignore */
}
writeUpdateState(newVer);
} catch {
process.stderr.write(`\n${color.green("\u2713 Update complete.")}\n`);
}
+1
View File
@@ -16,3 +16,4 @@ export * from "./types/index.ts";
export * from "./utils/index.ts";
export * from "./telemetry/index.ts";
export * from "./advisor/index.ts";
export * from "./install/index.ts";
+76
View File
@@ -0,0 +1,76 @@
/**
* End-user binary download base (OSS mirror).
* GitHub Releases remain the publish source of truth; an external FC syncs assets here.
* Production install.sh / install.ps1 are maintained outside this repo and read OSS only.
*
* Override with `BAILIAN_CLI_CDN`.
*/
export const DEFAULT_CLI_CDN_BASE = "https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli";
/** GitHub Releases base — used when writing manifests attached to gh release assets. */
export const GITHUB_RELEASES_BASE = "https://github.com/modelstudioai/cli/releases";
export const DEFAULT_INSTALL_SCRIPT_URL = `${DEFAULT_CLI_CDN_BASE}/install.sh`;
export const DEFAULT_INSTALL_PS1_URL = `${DEFAULT_CLI_CDN_BASE}/install.ps1`;
export function getCliCdnBase(): string {
const fromEnv = process.env.BAILIAN_CLI_CDN?.trim();
if (fromEnv) return fromEnv.replace(/\/$/, "");
return DEFAULT_CLI_CDN_BASE;
}
/**
* Channel manifest on OSS (after FC sync): `{base}/channels/{channel}.json`
* Formal releases use `latest.json` (default).
*/
export function channelManifestUrl(channel = "latest"): string {
return `${getCliCdnBase()}/channels/${channel}.json`;
}
export function releaseAssetUrl(version: string, fileName: string): string {
return `${getCliCdnBase()}/releases/${version}/${fileName}`;
}
/** Platform triple used in asset names: `bl-<ver>-<os>-<arch>[.exe]`. */
export function detectBinaryPlatform(): { os: string; arch: string; fileSuffix: string } {
const platform = process.platform;
const arch = process.arch;
let os: string;
if (platform === "darwin") os = "darwin";
else if (platform === "linux") os = "linux";
else if (platform === "win32") os = "windows";
else {
throw new Error(`Unsupported platform for binary updates: ${platform}`);
}
let normalizedArch: string;
if (arch === "arm64") normalizedArch = "arm64";
else if (arch === "x64") normalizedArch = "x64";
else {
throw new Error(`Unsupported architecture for binary updates: ${arch}`);
}
if (os === "linux" && normalizedArch === "arm64") {
throw new Error(
"linux arm64 is not supported for binary updates; use: npm install -g bailian-cli",
);
}
if (os === "windows" && normalizedArch === "arm64") {
throw new Error(
"windows arm64 is not supported for binary updates; use: npm install -g bailian-cli",
);
}
const fileSuffix = platform === "win32" ? ".exe" : "";
return { os, arch: normalizedArch, fileSuffix };
}
export function binaryAssetFileName(
version: string,
os: string,
arch: string,
exe = false,
): string {
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
}
+18
View File
@@ -0,0 +1,18 @@
export {
detectInstallMethod,
getInstallMethod,
isCompiledBinary,
writeInstallMethodSync,
type InstallMethod,
} from "./method.ts";
export {
DEFAULT_CLI_CDN_BASE,
DEFAULT_INSTALL_PS1_URL,
DEFAULT_INSTALL_SCRIPT_URL,
GITHUB_RELEASES_BASE,
binaryAssetFileName,
channelManifestUrl,
detectBinaryPlatform,
getCliCdnBase,
releaseAssetUrl,
} from "./cdn.ts";
+76
View File
@@ -0,0 +1,76 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getConfigDir } from "../config/paths.ts";
/** How the CLI was installed on this machine. */
export type InstallMethod = "binary" | "npm" | "brew" | "winget" | "unknown";
const INSTALL_METHOD_FILE = "install-method";
const VALID_METHODS = new Set<InstallMethod>(["binary", "npm", "brew", "winget", "unknown"]);
function installMethodPath(): string {
return join(getConfigDir(), INSTALL_METHOD_FILE);
}
/**
* True when running a Bun-compiled standalone executable
* rather than via the Node/npm entry shim.
*
* Binary entrypoints set `BAILIAN_COMPILED=1` before other code runs.
*/
export function isCompiledBinary(): boolean {
if (process.env.BAILIAN_COMPILED === "1") return true;
const execPath = process.execPath.replaceAll("\\", "/");
if (/(^|\/)node(\.exe)?$/i.test(execPath) || execPath.includes("/node/")) return false;
if (/(^|\/)bun(\.exe)?$/i.test(execPath) || execPath.includes("/.bun/")) return false;
return /\/(bl|bailian)(\.exe)?$/i.test(execPath);
}
function parseInstallMethod(raw: string | undefined): InstallMethod | null {
if (!raw) return null;
const value = raw.trim().toLowerCase() as InstallMethod;
return VALID_METHODS.has(value) ? value : null;
}
/** Infer install method when no marker file / env override is present. */
export function detectInstallMethod(): InstallMethod {
const fromEnv = parseInstallMethod(process.env.BAILIAN_INSTALL_METHOD);
if (fromEnv) return fromEnv;
if (isCompiledBinary()) {
const execPath = process.execPath.replaceAll("\\", "/");
if (execPath.includes("/Cellar/") || execPath.includes("/homebrew/")) return "brew";
return "binary";
}
return "npm";
}
/** Read the persisted install method, falling back to detection. */
export function getInstallMethod(): InstallMethod {
const fromEnv = parseInstallMethod(process.env.BAILIAN_INSTALL_METHOD);
if (fromEnv) return fromEnv;
try {
const raw = readFileSync(installMethodPath(), "utf-8");
const parsed = parseInstallMethod(raw.split("\n")[0]);
if (parsed) return parsed;
} catch {
/* missing or unreadable */
}
return detectInstallMethod();
}
/** Persist install method under `~/.bailian/install-method` (best-effort). */
export function writeInstallMethodSync(method: InstallMethod): void {
try {
const dir = getConfigDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
writeFileSync(installMethodPath(), `${method}\n`, { mode: 0o600 });
} catch {
/* best effort */
}
}
+6 -1
View File
@@ -2,7 +2,8 @@
* dev, prod
*
* 1. NODE_ENV=development Node ,/CI
* 2. node_modules (pnpm dev /
* 2. Bun BAILIAN_COMPILED=1 prod
* 3. node_modules (pnpm dev /
* npm link / pnpm -F bailian-cli exec tsx src/main.ts),
* prod
*
@@ -16,6 +17,10 @@ export function detectEnv(): "dev" | "prod" {
cachedEnv = "dev";
return cachedEnv;
}
if (process.env.BAILIAN_COMPILED === "1") {
cachedEnv = "prod";
return cachedEnv;
}
cachedEnv = import.meta.url.includes("/node_modules/") ? "prod" : "dev";
return cachedEnv;
}
@@ -0,0 +1,29 @@
import { expect, test } from "vite-plus/test";
import {
detectInstallMethod,
isCompiledBinary,
binaryAssetFileName,
} from "../src/install/index.ts";
test("isCompiledBinary respects BAILIAN_COMPILED", () => {
const previous = process.env.BAILIAN_COMPILED;
process.env.BAILIAN_COMPILED = "1";
expect(isCompiledBinary()).toBe(true);
if (previous === undefined) delete process.env.BAILIAN_COMPILED;
else process.env.BAILIAN_COMPILED = previous;
});
test("detectInstallMethod respects BAILIAN_INSTALL_METHOD", () => {
const previous = process.env.BAILIAN_INSTALL_METHOD;
process.env.BAILIAN_INSTALL_METHOD = "binary";
expect(detectInstallMethod()).toBe("binary");
process.env.BAILIAN_INSTALL_METHOD = "npm";
expect(detectInstallMethod()).toBe("npm");
if (previous === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
else process.env.BAILIAN_INSTALL_METHOD = previous;
});
test("binaryAssetFileName formats windows exe", () => {
expect(binaryAssetFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.exe");
expect(binaryAssetFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64");
});
+13 -1
View File
@@ -1,10 +1,11 @@
import { existsSync } from "node:fs";
import { mkdir, open, stat, unlink, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import {
BailianError,
ExitCode,
isCompiledBinary,
type CommandPackManager,
type CommandPackReport,
type Identity,
@@ -96,6 +97,17 @@ async function ensureSandboxAt(dir: string): Promise<void> {
}
async function runNpm(args: string[], cwd: string): Promise<void> {
const npmCheck = spawnSync("npm", ["--version"], { encoding: "utf-8" });
if (npmCheck.status !== 0) {
const hint = isCompiledBinary()
? "Command Packs need a local npm. Install Node.js, or use `npm install -g bailian-cli` instead of the binary install."
: "Install Node.js / npm and retry.";
throw new BailianError(
"npm is required to install, link, or remove Command Packs, but was not found on PATH.",
ExitCode.GENERAL,
hint,
);
}
await new Promise<void>((resolvePromise, reject) => {
const child = spawn("npm", args, {
cwd,
+5
View File
@@ -65,6 +65,11 @@ export {
NPM_PACKAGE,
NPM_REGISTRY,
} from "./utils/update-checker.ts";
export {
fetchBinaryChannelVersion,
fetchBinaryChannelManifest,
performBinaryUpdate,
} from "./utils/binary-update.ts";
export {
BOOL_FLAG_WATERMARK,
BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE,
+134
View File
@@ -0,0 +1,134 @@
import { mkdir, rename, unlink, writeFile, chmod } from "node:fs/promises";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { createHash } from "node:crypto";
import {
binaryAssetFileName,
channelManifestUrl,
detectBinaryPlatform,
getConfigDir,
releaseAssetUrl,
writeInstallMethodSync,
} from "bailian-cli-core";
export interface ChannelManifest {
version: string;
assets?: Record<string, { file?: string; sha256?: string; url?: string }>;
}
export async function fetchBinaryChannelVersion(
channel = "latest",
timeoutMs = 5000,
): Promise<string | null> {
try {
const response = await fetch(channelManifestUrl(channel), {
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) return null;
const data = (await response.json()) as ChannelManifest;
return data.version ?? null;
} catch {
return null;
}
}
export async function fetchBinaryChannelManifest(
channel = "latest",
timeoutMs = 8000,
): Promise<ChannelManifest | null> {
try {
const response = await fetch(channelManifestUrl(channel), {
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) return null;
return (await response.json()) as ChannelManifest;
} catch {
return null;
}
}
function shareRoot(): string {
if (process.env.BAILIAN_SHARE_DIR) return process.env.BAILIAN_SHARE_DIR;
if (process.platform === "win32") {
return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "bailian-cli");
}
return join(homedir(), ".local", "share", "bailian-cli");
}
function binRoot(): string {
if (process.env.BAILIAN_BIN_DIR) return process.env.BAILIAN_BIN_DIR;
if (process.platform === "win32") {
return join(shareRoot(), "bin");
}
return join(homedir(), ".local", "bin");
}
async function downloadToFile(url: string, dest: string): Promise<Buffer> {
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
if (!response.ok || !response.body) {
throw new Error(`Download failed (${response.status}): ${url}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
await mkdir(dirname(dest), { recursive: true });
await writeFile(dest, buffer);
return buffer;
}
function sha256(buffer: Buffer): string {
return createHash("sha256").update(buffer).digest("hex");
}
/**
* Download and install a newer standalone binary in place of the current install.
* Returns the installed version string.
*/
export async function performBinaryUpdate(targetVersion: string): Promise<string> {
const { os, arch, fileSuffix } = detectBinaryPlatform();
const manifest = await fetchBinaryChannelManifest("latest");
const assetKey = `${os}-${arch}`;
const assetMeta = manifest?.assets?.[assetKey];
const fileName =
assetMeta?.file ?? binaryAssetFileName(targetVersion, os, arch, fileSuffix === ".exe");
const expectedSha = assetMeta?.sha256;
const url = assetMeta?.url ?? releaseAssetUrl(targetVersion, fileName);
const tmpPath = join(shareRoot(), ".tmp", fileName);
const buffer = await downloadToFile(url, tmpPath);
const actualSha = sha256(buffer);
if (expectedSha && expectedSha !== actualSha) {
await unlink(tmpPath).catch(() => {});
throw new Error(`Checksum mismatch for ${fileName}`);
}
const versionDir = join(shareRoot(), "versions", targetVersion);
await mkdir(versionDir, { recursive: true });
const binaryName = process.platform === "win32" ? "bl.exe" : "bl";
const finalPath = join(versionDir, binaryName);
await rename(tmpPath, finalPath);
if (process.platform !== "win32") {
await chmod(finalPath, 0o755);
}
const binDir = binRoot();
await mkdir(binDir, { recursive: true });
if (process.platform === "win32") {
await writeFile(join(binDir, "bl.exe"), buffer);
await writeFile(join(binDir, "bailian.exe"), buffer);
} else {
const { symlink } = await import("node:fs/promises");
for (const name of ["bl", "bailian"] as const) {
const linkPath = join(binDir, name);
try {
await unlink(linkPath);
} catch {
/* missing */
}
await symlink(finalPath, linkPath);
}
}
writeInstallMethodSync("binary");
await mkdir(getConfigDir(), { recursive: true });
return targetVersion;
}
+38 -19
View File
@@ -1,6 +1,6 @@
import { join } from "path";
import { readFileSync, writeFileSync } from "fs";
import { getConfigDir, trackingHeaders } from "bailian-cli-core";
import { getConfigDir, trackingHeaders, getInstallMethod } from "bailian-cli-core";
export const NPM_REGISTRY = "https://registry.npmjs.org";
/** Default npm package; products override per-call via the `npmPackage` argument. */
@@ -207,7 +207,7 @@ function errorMessage(err: unknown): string {
}
/**
* Perform auto-update: install latest version globally and update agent skill.
* Perform auto-update for npm or binary installs.
* Returns true if update succeeded, false otherwise.
*/
export async function performAutoUpdate(
@@ -222,6 +222,11 @@ export async function performAutoUpdate(
const dim = isTTY ? "\x1b[2m" : "";
const reset = isTTY ? "\x1b[0m" : "";
const method = getInstallMethod();
if (method === "brew" || method === "winget") {
return false;
}
const [latestMajor] = parseVersion(latestVersion);
const [currentMajor] = parseVersion(currentVersion);
const isMajorBump = latestMajor > currentMajor;
@@ -240,17 +245,30 @@ export async function performAutoUpdate(
process.stderr.write(` ${dim}Auto-updating to keep your CLI up to date...${reset}\n`);
process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n\n`);
if (method === "binary") {
try {
const { performBinaryUpdate } = await import("./binary-update.ts");
const newVer = await performBinaryUpdate(latestVersion);
writeState({ lastChecked: Date.now(), latestVersion: newVer });
process.stderr.write(` ${green}✓ Update complete: ${currentVersion}${newVer}${reset}\n`);
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
pendingNotification = null;
return true;
} catch (err) {
process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Re-run:${reset} ${cyan}curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash${reset}\n\n`,
);
return false;
}
}
const cmd = `npm install -g ${npmPackage}@latest`;
try {
const { execSync } = await import("child_process");
execSync(cmd, { stdio: "inherit" });
// Verify the actually-installed version by reading the global package.json.
// We must NOT rely on `bl --version`: the user may run via npx, a local
// install, or a custom bin name, in which case `bl` on PATH points at the
// wrong binary (or nothing at all). Reading the installed package directly
// is correct regardless of how the CLI was invoked.
let newVer: string | null = null;
try {
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
@@ -264,8 +282,6 @@ export async function performAutoUpdate(
);
}
// Update cached state. writeState swallows errors internally: state caching
// is non-critical and must never break the CLI startup path.
writeState({ lastChecked: Date.now(), latestVersion: newVer ?? latestVersion });
process.stderr.write(
@@ -273,26 +289,20 @@ export async function performAutoUpdate(
);
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
// Update agent skill
try {
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
} catch (err) {
// Surface the reason the skill sync failed rather than swallowing it
// silently, but keep degradation: the CLI itself already updated.
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
);
}
// Clear pending notification
pendingNotification = null;
return true;
} catch (err) {
// npm install failure — most commonly EACCES (global installs often need
// elevated permissions). Tell the user *why* it failed, not just *that*.
process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`);
process.stderr.write(
` ${yellow} If this is a permissions error (EACCES), retry with sudo or fix npm perms.${reset}\n`,
@@ -309,12 +319,21 @@ export async function checkForUpdate(
const state = readState();
const now = Date.now();
// Inside the throttle window (CHECK_INTERVAL_MS since the last fetch): no
// network call and no notice. The state file is global, so the notice fires at
// most once per window across all processes/sessions — not once per command.
if (state && now - state.lastChecked < CHECK_INTERVAL_MS) return;
const latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
const method = getInstallMethod();
let latest: string | null = null;
if (method === "binary") {
try {
const { fetchBinaryChannelVersion } = await import("./binary-update.ts");
latest = await fetchBinaryChannelVersion("latest", FETCH_TIMEOUT_MS);
} catch {
latest = null;
}
if (!latest) latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
} else {
latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
}
if (!latest) return;
writeState({ lastChecked: now, latestVersion: latest });
+78
View File
@@ -0,0 +1,78 @@
# REFERENCE install script for the OSS layout (production scripts are maintained elsewhere).
# Expected production entry:
# irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex
param()
$ErrorActionPreference = "Stop"
$CdnBase = if ($env:BAILIAN_CLI_CDN) { $env:BAILIAN_CLI_CDN.TrimEnd("/") } else { "https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli" }
$Channel = if ($env:BAILIAN_CHANNEL) { $env:BAILIAN_CHANNEL } else { "latest" }
$Version = $env:BAILIAN_VERSION
$InstallRoot = if ($env:BAILIAN_SHARE_DIR) { $env:BAILIAN_SHARE_DIR } else { Join-Path $env:LOCALAPPDATA "bailian-cli" }
$ConfigDir = if ($env:BAILIAN_CONFIG_DIR) { $env:BAILIAN_CONFIG_DIR } else { Join-Path $env:USERPROFILE ".bailian" }
function Write-Log([string]$Message) {
[Console]::Error.WriteLine($Message)
}
if ([string]::IsNullOrWhiteSpace($Version)) {
$manifestUrl = "$CdnBase/channels/$Channel.json"
Write-Log "Fetching $manifestUrl"
$manifest = Invoke-RestMethod -Uri $manifestUrl
$Version = $manifest.version
if ([string]::IsNullOrWhiteSpace($Version)) {
throw "Could not parse version from $Channel channel manifest"
}
}
$Arch = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "arm64" } else { "x64" }
if ($Arch -eq "arm64") {
throw "windows arm64 is not supported for binary install; use: npm install -g bailian-cli"
}
$Asset = "bl-$Version-windows-$Arch.exe"
$Url = "$CdnBase/releases/$Version/$Asset"
$SumsUrl = "$CdnBase/releases/$Version/SHA256SUMS"
$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("bailian-cli-install-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $TempDir | Out-Null
try {
$AssetPath = Join-Path $TempDir $Asset
$SumsPath = Join-Path $TempDir "SHA256SUMS"
Write-Log "Downloading $Asset"
Invoke-WebRequest -Uri $Url -OutFile $AssetPath
Invoke-WebRequest -Uri $SumsUrl -OutFile $SumsPath
$Expected = $null
Get-Content $SumsPath | ForEach-Object {
if ($_ -match "^([a-fA-F0-9]+)\s+$([regex]::Escape($Asset))\s*$") {
$Expected = $Matches[1].ToLowerInvariant()
}
}
if (-not $Expected) { throw "Checksum for $Asset not found in SHA256SUMS" }
$Actual = (Get-FileHash -Algorithm SHA256 -Path $AssetPath).Hash.ToLowerInvariant()
if ($Expected -ne $Actual) { throw "Checksum mismatch for $Asset" }
$VersionDir = Join-Path $InstallRoot "versions\$Version"
New-Item -ItemType Directory -Force -Path $VersionDir | Out-Null
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
$Target = Join-Path $VersionDir "bl.exe"
Copy-Item -Force $AssetPath $Target
$ShimDir = Join-Path $InstallRoot "bin"
New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null
Copy-Item -Force $Target (Join-Path $ShimDir "bl.exe")
Copy-Item -Force $Target (Join-Path $ShimDir "bailian.exe")
Set-Content -Path (Join-Path $ConfigDir "install-method") -Value "binary" -NoNewline
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not ($UserPath -split ";" | Where-Object { $_ -eq $ShimDir })) {
[Environment]::SetEnvironmentVariable("Path", "$ShimDir;$UserPath", "User")
Write-Log "Added $ShimDir to your user PATH. Open a new terminal to use bl."
}
Write-Log "Installed: $Target (bailian-cli $Version)"
Write-Log "Done. Run: bl --help"
}
finally {
Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue
}
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# REFERENCE install script for the OSS layout (production scripts are maintained elsewhere).
# Production users should curl the copy hosted on OSS after FC sync, not this file from git.
#
# Expected production entry:
# curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash
# BAILIAN_VERSION=1.10.1 bash install.sh
# BAILIAN_CHANNEL=latest bash install.sh
set -euo pipefail
CDN_BASE="${BAILIAN_CLI_CDN:-https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli}"
CDN_BASE="${CDN_BASE%/}"
CHANNEL="${BAILIAN_CHANNEL:-latest}"
VERSION="${BAILIAN_VERSION:-}"
BIN_DIR="${BAILIAN_BIN_DIR:-${HOME}/.local/bin}"
SHARE_DIR="${BAILIAN_SHARE_DIR:-${HOME}/.local/share/bailian-cli}"
CONFIG_DIR="${BAILIAN_CONFIG_DIR:-${HOME}/.bailian}"
log() { printf '%s\n' "$*" >&2; }
die() { log "error: $*"; exit 1; }
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
}
detect_os_arch() {
local uname_s uname_m
uname_s="$(uname -s)"
uname_m="$(uname -m)"
case "${uname_s}" in
Darwin) OS="darwin" ;;
Linux) OS="linux" ;;
*) die "unsupported OS: ${uname_s}" ;;
esac
case "${uname_m}" in
arm64|aarch64) ARCH="arm64" ;;
x86_64|amd64) ARCH="x64" ;;
*) die "unsupported architecture: ${uname_m}" ;;
esac
if [[ "${OS}" == "darwin" ]] && [[ "$(sysctl -n sysctl.proc_translated 2>/dev/null || true)" == "1" ]]; then
ARCH="arm64"
fi
if [[ "${OS}" == "linux" && "${ARCH}" == "arm64" ]]; then
die "linux arm64 is not supported for binary install; use: npm install -g bailian-cli"
fi
}
resolve_version() {
if [[ -n "${VERSION}" ]]; then
return
fi
need_cmd curl
local manifest
manifest="$(mktemp)"
curl -fsSL "${CDN_BASE}/channels/${CHANNEL}.json" -o "${manifest}" \
|| die "failed to download channel manifest: ${CDN_BASE}/channels/${CHANNEL}.json"
VERSION="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "${manifest}" | head -n1)"
rm -f "${manifest}"
[[ -n "${VERSION}" ]] || die "could not parse version from ${CHANNEL} channel manifest"
}
download_and_verify() {
local asset="bl-${VERSION}-${OS}-${ARCH}"
local url="${CDN_BASE}/releases/${VERSION}/${asset}"
local sums_url="${CDN_BASE}/releases/${VERSION}/SHA256SUMS"
local tmp_dir version_dir
tmp_dir="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '${tmp_dir}'" EXIT
log "Downloading ${asset}"
curl -fsSL "${url}" -o "${tmp_dir}/${asset}" || die "download failed: ${url}"
curl -fsSL "${sums_url}" -o "${tmp_dir}/SHA256SUMS" || die "download failed: ${sums_url}"
need_cmd shasum
local expected actual
expected="$(awk -v file="${asset}" '$2 == file { print $1; exit }' "${tmp_dir}/SHA256SUMS")"
[[ -n "${expected}" ]] || die "checksum for ${asset} not found in SHA256SUMS"
actual="$(shasum -a 256 "${tmp_dir}/${asset}" | awk '{ print $1 }')"
[[ "${expected}" == "${actual}" ]] || die "checksum mismatch for ${asset}"
version_dir="${SHARE_DIR}/versions/${VERSION}"
mkdir -p "${version_dir}" "${BIN_DIR}" "${CONFIG_DIR}"
install -m 755 "${tmp_dir}/${asset}" "${version_dir}/bl"
ln -sfn "${version_dir}/bl" "${BIN_DIR}/bl"
ln -sfn "${version_dir}/bl" "${BIN_DIR}/bailian"
printf 'binary\n' > "${CONFIG_DIR}/install-method"
chmod 600 "${CONFIG_DIR}/install-method" 2>/dev/null || true
}
warn_path() {
case ":${PATH}:" in
*":${BIN_DIR}:"*) ;;
*)
log ""
log "Add ${BIN_DIR} to your PATH, then open a new terminal:"
log " export PATH=\"${BIN_DIR}:\$PATH\""
;;
esac
}
warn_npm_conflict() {
if command -v npm >/dev/null 2>&1; then
local npm_bl
npm_bl="$(npm root -g 2>/dev/null)/bailian-cli" || true
if [[ -d "${npm_bl}" ]]; then
log "warning: npm global bailian-cli is also installed; PATH may prefer one over the other."
log " Consider: npm uninstall -g bailian-cli"
fi
fi
}
main() {
need_cmd curl
need_cmd uname
detect_os_arch
resolve_version
log "Installing bailian-cli ${VERSION} (${OS}-${ARCH}) from ${CDN_BASE}"
download_and_verify
warn_npm_conflict
warn_path
log ""
log "Installed: ${BIN_DIR}/bl → bailian-cli ${VERSION}"
if command -v "${BIN_DIR}/bl" >/dev/null 2>&1 || [[ -x "${BIN_DIR}/bl" ]]; then
"${BIN_DIR}/bl" --version >&2 || true
fi
log "Done. Run: bl --help"
}
main "$@"
+239
View File
@@ -0,0 +1,239 @@
/**
* Build standalone `bl` binaries with Bun --compile.
*
* Used by lib/binary-release.mjs (and publish-stable / publish-channel orchestrators).
* Debug:
* node tools/release/lib/binary-build.mjs --mode stable --host
*
* Manifests:
* --mode stable writes latest.json only
* --mode channel writes <channel>.json only (does not touch latest.json)
*/
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { parseArgs } from "node:util";
import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs";
import { assertChannel } from "./validate.mjs";
const BINARY_COMPILE = fileURLToPath(new URL("./binary-compile.mjs", import.meta.url));
const CLI_ENTRY = join(ROOT, "packages/cli/src/main.ts");
const DEFAULT_OUTDIR = join(ROOT, "dist-bin");
const DEFAULT_CDN = "https://github.com/modelstudioai/cli/releases";
const USAGE =
"Usage: node tools/release/lib/binary-build.mjs [--mode stable|channel] [--channel <name>] [--host] [--target <bun-target>] [--outdir <dir>]\n";
/** Bun compile targets → asset (os, arch, exe). */
export const BINARY_TARGETS = [
{ bunTarget: "bun-darwin-arm64", os: "darwin", arch: "arm64", exe: false },
{ bunTarget: "bun-darwin-x64", os: "darwin", arch: "x64", exe: false },
{ bunTarget: "bun-linux-x64", os: "linux", arch: "x64", exe: false },
{ bunTarget: "bun-windows-x64", os: "windows", arch: "x64", exe: true },
];
function log(message = "") {
process.stdout.write(`${message}\n`);
}
function writeJson(path, value) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
function cdnBase() {
return (process.env.BAILIAN_CLI_CDN || DEFAULT_CDN).replace(/\/$/, "");
}
function parseCliArgs(argv) {
const { values } = parseArgs({
args: argv,
options: {
outdir: { type: "string" },
target: { type: "string" },
host: { type: "boolean", default: false },
mode: { type: "string", default: "stable" },
channel: { type: "string" },
help: { type: "boolean", short: "h", default: false },
},
allowPositionals: false,
});
if (values.help) {
process.stdout.write(USAGE);
process.exit(0);
}
return normalizeBuildOptions({
outdir: values.outdir ? resolve(values.outdir) : DEFAULT_OUTDIR,
onlyTarget: values.target ?? null,
hostOnly: values.host,
mode: values.mode,
channel: values.channel ?? null,
});
}
function normalizeBuildOptions({
outdir,
onlyTarget = null,
hostOnly = false,
mode = "stable",
channel = null,
}) {
if (mode !== "stable" && mode !== "channel") {
throw new Error(`--mode must be stable or channel, got: ${mode}`);
}
if (mode === "channel") {
if (!channel) throw new Error("--mode channel requires --channel <name>");
assertChannel(channel);
if (channel === "stable") {
throw new Error(`--channel cannot be "stable"; use --mode stable`);
}
}
return {
outdir: outdir ?? DEFAULT_OUTDIR,
onlyTarget,
hostOnly: Boolean(hostOnly),
mode,
channel: mode === "channel" ? channel : null,
};
}
function hostBunTarget() {
const os = process.platform === "win32" ? "windows" : process.platform;
const match = BINARY_TARGETS.find((target) => target.os === os && target.arch === process.arch);
if (!match) {
throw new Error(`Unsupported host for --host build: ${process.platform}/${process.arch}`);
}
return match.bunTarget;
}
function resolveTargets({ hostOnly, onlyTarget }) {
if (hostOnly) {
const host = hostBunTarget();
return BINARY_TARGETS.filter((target) => target.bunTarget === host);
}
if (!onlyTarget) return BINARY_TARGETS;
const targets = BINARY_TARGETS.filter((target) => target.bunTarget === onlyTarget);
if (targets.length === 0) {
const known = BINARY_TARGETS.map((target) => target.bunTarget).join(", ");
throw new Error(`Unknown --target ${onlyTarget}. Known: ${known}`);
}
return targets;
}
function ensureBun() {
const result = spawnSync("bun", ["--version"], { encoding: "utf-8" });
if (result.status !== 0) {
throw new Error("bun not found on PATH. Install from https://bun.sh");
}
return result.stdout.trim();
}
function sha256File(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
function assetFileName(version, os, arch, exe) {
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
}
function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) {
const fileName = assetFileName(version, os, arch, exe);
const outfile = join(outdir, fileName);
log(`compile ${bunTarget}${fileName}`);
// Bun.build() lives in binary-compile.mjs (must run under Bun); this file stays Node.
const result = spawnSync(
"bun",
[BINARY_COMPILE, "--entry", entry, "--outfile", outfile, "--target", bunTarget],
{ cwd: ROOT, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
);
if (result.status !== 0) {
process.stderr.write(result.stderr || result.stdout || "");
throw new Error(`Bun.build compile failed for ${bunTarget}`);
}
return { fileName, outfile, os, arch, sha256: sha256File(outfile) };
}
function writeChecksums(outdir, artifacts) {
const lines = artifacts.map((item) => `${item.sha256} ${item.fileName}`);
writeFileSync(join(outdir, "SHA256SUMS"), `${lines.join("\n")}\n`);
}
function writeChannelManifest(outdir, version, artifacts, mode, channel) {
const base = cdnBase();
const assets = Object.fromEntries(
artifacts.map((item) => [
`${item.os}-${item.arch}`,
{
file: item.fileName,
sha256: item.sha256,
url: `${base}/download/v${version}/${item.fileName}`,
},
]),
);
const manifest = {
name: "bailian-cli",
channel: mode === "stable" ? "latest" : channel,
version,
releasedAt: new Date().toISOString(),
assets,
};
const names = mode === "stable" ? ["latest.json"] : [`${channel}.json`];
for (const name of names) writeJson(join(outdir, name), manifest);
return names;
}
function cliVersion() {
return readPackageJson(PACKAGES.find((pkg) => pkg.key === "cli")).version;
}
/** Run `--version` on the artifact matching the host platform, if any was built. */
function smokeTestHostBinary(artifacts, outdir) {
const hostOs = process.platform === "win32" ? "windows" : process.platform;
const host = artifacts.find((item) => item.os === hostOs && item.arch === process.arch);
if (!host) return;
const binary = join(outdir, host.fileName);
log(`smoke test ${host.fileName} --version`);
const result = spawnSync(binary, ["--version"], { encoding: "utf-8" });
if (result.status !== 0) {
process.stderr.write(result.stderr || result.stdout || "");
throw new Error(`smoke test failed: ${host.fileName} --version`);
}
}
/** Compile binaries into `outdir` and write checksums + channel manifest. */
export function buildBinaryArtifacts(rawOptions = {}) {
const options = normalizeBuildOptions(rawOptions);
const { outdir, mode, channel } = options;
const bunVersion = ensureBun();
const version = cliVersion();
const targets = resolveTargets(options);
mkdirSync(outdir, { recursive: true });
log(`bun ${bunVersion}`);
log(`bailian-cli ${version}`);
log(`mode ${mode}${channel ? ` channel=${channel}` : ""}`);
log(`outdir ${outdir}`);
const artifacts = targets.map((target) => compileOne(target, version, outdir, CLI_ENTRY));
writeChecksums(outdir, artifacts);
const manifests = writeChannelManifest(outdir, version, artifacts, mode, channel);
smokeTestHostBinary(artifacts, outdir);
log(`\nBuilt ${artifacts.length} binary(ies):`);
for (const item of artifacts) {
log(` ${item.fileName} ${item.sha256.slice(0, 12)}`);
}
log(`Also wrote SHA256SUMS, ${manifests.join(", ")}`);
return { version, mode, channel, outdir, artifacts, manifests };
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
try {
buildBinaryArtifacts(parseCliArgs(process.argv.slice(2)));
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Single-target `Bun.build({ compile })` helper. Must be run with Bun:
* bun tools/release/lib/binary-compile.mjs --entry <path> --outfile <path> --target <bun-target>
*
* Called by binary-build.mjs (Node orchestration stays on Node).
*/
function parseArgs(argv) {
let entry = null;
let outfile = null;
let target = null;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--entry") entry = argv[++index];
else if (arg === "--outfile") outfile = argv[++index];
else if (arg === "--target") target = argv[++index];
else if (arg === "--help" || arg === "-h") {
process.stdout.write(
"Usage: bun tools/release/lib/binary-compile.mjs --entry <path> --outfile <path> --target <bun-target>\n",
);
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!entry || !outfile || !target) {
throw new Error("Required: --entry, --outfile, --target");
}
return { entry, outfile, target };
}
const { entry, outfile, target } = parseArgs(process.argv.slice(2));
const result = await Bun.build({
entrypoints: [entry],
define: {
"process.env.BAILIAN_COMPILED": JSON.stringify("1"),
},
compile: {
target,
outfile,
},
});
if (!result.success) {
for (const log of result.logs) {
console.error(log);
}
process.exit(1);
}
+313
View File
@@ -0,0 +1,313 @@
/**
* Publish bailian-cli binary assets to GitHub Releases via the `gh` CLI.
*
* stable: release `v<version>` (tag must already be on origin; --verify-tag)
* assets: bl-*, SHA256SUMS, latest.json
* channel: versioned prerelease `v<betaVersion>` (assets: bl-*, SHA256SUMS)
* + rolling prerelease tag `channel-<name>` holding only `<name>.json`
*
* Re-runs are idempotent: existing releases get `gh release upload --clobber`.
* Optionally POSTs BAILIAN_OSS_SYNC_WEBHOOK so an external FC can mirror to OSS.
*
* Called by publish-stable.mjs / publish-channel.mjs.
* Debug:
* node tools/release/lib/binary-release.mjs --mode stable --dry-run
* node tools/release/lib/binary-release.mjs --mode channel --channel beta --dry-run
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs";
import { BINARY_TARGETS, buildBinaryArtifacts } from "./binary-build.mjs";
import { run, runCapture, tryRun } from "./proc.mjs";
import { assertChannel } from "./validate.mjs";
const REPO = process.env.GITHUB_REPOSITORY || "modelstudioai/cli";
function parseArgs(argv) {
let dir = join(ROOT, "dist-bin");
let dryRun = false;
let mode = "stable";
let channel = null;
let skipBuild = false;
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === "--dir") dir = resolve(argv[++index]);
else if (arg === "--dry-run") dryRun = true;
else if (arg === "--mode") mode = argv[++index];
else if (arg === "--channel") channel = argv[++index];
else if (arg === "--skip-build") skipBuild = true;
else if (arg === "--help" || arg === "-h") {
process.stdout.write(
"Usage: node tools/release/lib/binary-release.mjs --mode stable|channel [--channel <name>] [--dir dist-bin] [--skip-build] [--dry-run]\n",
);
process.exit(0);
} else throw new Error(`Unknown argument: ${arg}`);
}
return normalizeOptions({ dir, dryRun, mode, channel, skipBuild });
}
function normalizeOptions({ dir, dryRun, mode, channel, skipBuild = false }) {
if (mode !== "stable" && mode !== "channel") {
throw new Error(`--mode must be stable or channel, got: ${mode}`);
}
if (mode === "channel") {
if (!channel) throw new Error("--mode channel requires --channel <name>");
assertChannel(channel);
if (channel === "stable") {
throw new Error(`--channel cannot be "stable"; use --mode stable`);
}
}
return {
dir: dir ?? join(ROOT, "dist-bin"),
dryRun: Boolean(dryRun),
mode,
channel: mode === "channel" ? channel : null,
skipBuild: Boolean(skipBuild),
};
}
function requiredManifestName(mode, channel) {
return mode === "stable" ? "latest.json" : `${channel}.json`;
}
function ensureGh() {
if (tryRun("gh", ["--version"]).status !== 0) {
throw new Error("gh CLI not found on PATH. Install from https://cli.github.com");
}
}
function releaseExists(tag) {
return tryRun("gh", ["release", "view", tag, "--repo", REPO]).status === 0;
}
function verifyReleaseAssets(tag, assetPaths) {
const output = runCapture("gh", [
"release",
"view",
tag,
"--repo",
REPO,
"--json",
"assets",
"--jq",
".assets[].name",
]);
const uploaded = new Set(output.split("\n").filter(Boolean));
const missing = assetPaths.map((path) => basename(path)).filter((name) => !uploaded.has(name));
if (missing.length > 0) {
throw new Error(`release ${tag} is missing assets after upload: ${missing.join(", ")}`);
}
}
/** Extract the `## [<version>]` section from CHANGELOG.md, or null when absent. */
function extractChangelogSection(version) {
const lines = readFileSync(join(ROOT, "CHANGELOG.md"), "utf-8").split("\n");
const start = lines.findIndex((line) => line.startsWith(`## [${version}]`));
if (start === -1) return null;
const rest = lines.slice(start + 1);
const end = rest.findIndex((line) => line.startsWith("## ["));
const section = (end === -1 ? rest : rest.slice(0, end)).join("\n").trim();
return section ? `${section}\n` : null;
}
function printPlanned(tag, assets, extraArgs) {
process.stdout.write(`[dry-run] gh release view ${tag} --repo ${REPO}\n`);
process.stdout.write(
`[dry-run] exists → gh release upload ${tag} --repo ${REPO} --clobber <assets>\n`,
);
process.stdout.write(
`[dry-run] missing → gh release create ${tag} --repo ${REPO} ${extraArgs.join(" ")} <assets>\n`,
);
for (const asset of assets) process.stdout.write(`[dry-run] asset: ${asset}\n`);
}
/**
* Create a release with assets, or clobber-upload onto an existing one.
* options: { tag, title, prerelease, verifyTag, notes, notesFile, assets, dryRun }
*/
function upsertRelease({ tag, title, prerelease, verifyTag, notes, notesFile, assets, dryRun }) {
const createArgs = ["--title", title];
if (prerelease) createArgs.push("--prerelease", "--target", "main");
if (verifyTag) createArgs.push("--verify-tag");
if (notesFile) createArgs.push("--notes-file", notesFile);
else if (notes) createArgs.push("--notes", notes);
else createArgs.push("--generate-notes");
if (dryRun) {
printPlanned(tag, assets, createArgs);
return;
}
if (releaseExists(tag)) {
process.stdout.write(`release ${tag} exists; uploading assets with --clobber\n`);
run("gh", ["release", "upload", tag, "--repo", REPO, "--clobber", ...assets]);
} else {
run("gh", ["release", "create", tag, "--repo", REPO, ...createArgs, ...assets]);
}
verifyReleaseAssets(tag, assets);
}
function uploadStable({ dir, version, files, dryRun }) {
const tag = `v${version}`;
const matrixNames = new Set(
BINARY_TARGETS.map(
(target) => `bl-${version}-${target.os}-${target.arch}${target.exe ? ".exe" : ""}`,
),
);
// Binaries + checksums + latest.json only. Production install.sh/ps1 are maintained
// outside this repo and served from OSS after an external FC sync.
const wanted = files.filter(
(name) => matrixNames.has(name) || name === "SHA256SUMS" || name === "latest.json",
);
const assets = wanted.map((name) => join(dir, name));
const section = extractChangelogSection(version);
upsertRelease({
tag,
title: tag,
verifyTag: true,
notes: section || undefined,
assets,
dryRun,
});
}
function uploadChannel({ dir, version, channel, files, dryRun }) {
const matrixNames = new Set(
BINARY_TARGETS.map(
(target) => `bl-${version}-${target.os}-${target.arch}${target.exe ? ".exe" : ""}`,
),
);
const binaries = files
.filter((name) => matrixNames.has(name) || name === "SHA256SUMS")
.map((name) => join(dir, name));
upsertRelease({
tag: `v${version}`,
title: `v${version}`,
prerelease: true,
notes: `Beta build for the \`${channel}\` channel.`,
assets: binaries,
dryRun,
});
upsertRelease({
tag: `channel-${channel}`,
title: `channel: ${channel}`,
prerelease: true,
notes: `Rolling manifest for the \`${channel}\` channel. Latest beta: ${version}.`,
assets: [join(dir, `${channel}.json`)],
dryRun,
});
}
/**
* Optional hook for an external FC that mirrors GitHub Releases OSS.
* Set BAILIAN_OSS_SYNC_WEBHOOK to an HTTP endpoint; unset no-op.
*/
function notifyOssSyncWebhook({ version, mode, channel, dryRun }) {
const webhook = process.env.BAILIAN_OSS_SYNC_WEBHOOK?.trim();
if (!webhook) {
process.stdout.write(
"\n[info] BAILIAN_OSS_SYNC_WEBHOOK unset; skip notifying external OSS sync FC\n",
);
return;
}
const tag = mode === "stable" ? `v${version}` : `v${version}`;
const body = {
repo: REPO,
mode,
channel,
version,
tag,
rollingChannelTag: mode === "channel" ? `channel-${channel}` : null,
};
if (dryRun) {
process.stdout.write(`[dry-run] POST ${webhook}\n${JSON.stringify(body, null, 2)}\n`);
return;
}
process.stdout.write(`\n==> notify OSS sync FC: ${webhook}\n`);
const result = tryRun("curl", [
"-fsS",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
JSON.stringify(body),
webhook,
]);
if (result.status !== 0) {
process.stdout.write(
`[warn] OSS sync webhook failed (release already published): ${result.stderr || result.stdout}\n`,
);
return;
}
if (result.stdout) process.stdout.write(`${result.stdout}\n`);
}
/**
* Build (unless skipped) and upload binary artifacts to GitHub Releases.
* Called by publish-stable / publish-channel orchestrators.
*/
export function releaseBinaryArtifacts(rawOptions) {
const { dir, dryRun, mode, channel, skipBuild } = normalizeOptions(rawOptions);
const cliPkg = readPackageJson(PACKAGES.find((pkg) => pkg.key === "cli"));
const version = cliPkg.version;
if (!skipBuild) {
process.stdout.write(
`\n==> build binary (mode=${mode}${channel ? ` channel=${channel}` : ""})\n`,
);
buildBinaryArtifacts({ mode, channel, outdir: dir });
}
if (!existsSync(dir)) {
throw new Error(
`Missing ${dir}. Run binary-build or omit --skip-build (mode=${mode}${channel ? ` channel=${channel}` : ""}).`,
);
}
const files = readdirSync(dir).filter((name) => !name.startsWith("."));
const manifestName = requiredManifestName(mode, channel);
if (!files.includes(manifestName)) {
throw new Error(
`Missing ${manifestName} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`,
);
}
if (!files.includes("SHA256SUMS")) {
throw new Error(`Missing SHA256SUMS in ${dir}`);
}
process.stdout.write(`repo ${REPO}\n`);
process.stdout.write(`version ${version}\n`);
process.stdout.write(`mode ${mode}${channel ? ` channel=${channel}` : ""}\n`);
process.stdout.write(`artifacts in ${dir}:\n`);
for (const name of files) process.stdout.write(` ${name}\n`);
if (dryRun) {
process.stdout.write("\n[dry-run] skipping GitHub Release upload\n");
} else {
ensureGh();
}
if (mode === "stable") {
uploadStable({ dir, version, files, dryRun });
} else {
uploadChannel({ dir, version, channel, files, dryRun });
}
notifyOssSyncWebhook({ version, mode, channel, dryRun });
return { version, mode, channel, dryRun };
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
try {
releaseBinaryArtifacts(parseArgs(process.argv.slice(2)));
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
}
+16 -3
View File
@@ -13,6 +13,7 @@ import {
writePackageJson,
} from "./lib/packages.mjs";
import { assertChannel } from "./lib/validate.mjs";
import { releaseBinaryArtifacts } from "./lib/binary-release.mjs";
function log(msg = "") {
process.stdout.write(`${msg}\n`);
@@ -27,12 +28,14 @@ const { values } = parseArgs({
channel: { type: "string" },
"dry-run": { type: "boolean", default: false },
knowledge: { type: "boolean", default: false },
"skip-binary": { type: "boolean", default: false },
},
allowPositionals: false,
});
const channel = values.channel;
const dryRun = values["dry-run"];
const knowledge = values.knowledge;
const skipBinary = values["skip-binary"];
const packages = knowledge ? ALL_PACKAGES : PACKAGES;
assertChannel(channel);
@@ -76,9 +79,9 @@ try {
log(`${pkg.name}@${betaVersion}: ${exists ? "already published" : "to publish"}`);
}
if (packages.every((pkg) => published.get(pkg.key))) {
log("\nall packages already published; nothing to do.");
log("\nall packages already published; nothing to do for npm.");
} else {
// Publish in dependency order (core → runtime → commands → cli [→ kscli]).
// 1) npm (dependency order: core → runtime → commands → cli [→ kscli])
for (const pkg of packages) {
if (published.get(pkg.key)) continue;
step(`publish ${pkg.name}@${betaVersion} (tag=${channel}, provenance)`);
@@ -86,7 +89,17 @@ try {
}
}
log(`\nchannel release complete: ${channel}@${betaVersion}`);
// 2) binary GitHub Release — must run before finally restores package.json versions
if (skipBinary) {
log("\n[skip-binary] skipping Bun binary build/upload");
} else {
step(
`publish binary GitHub Release (mode=channel, channel=${channel}, version=${betaVersion})`,
);
releaseBinaryArtifacts({ mode: "channel", channel, dryRun });
}
log(`\nchannel release complete: ${channel}@${betaVersion} (npm + binary)`);
} catch (error) {
process.stderr.write(`\nrelease publish-channel failed: ${error.message}\n`);
process.exitCode = 1;
+16 -7
View File
@@ -5,6 +5,7 @@ import { runCheck } from "./check.mjs";
import { createTag, currentBranch, isWorkingTreeClean, pushTag, tagExists } from "./lib/git.mjs";
import { npmViewExists, pnpmPublish } from "./lib/npm.mjs";
import { ALL_PACKAGES, PACKAGES } from "./lib/packages.mjs";
import { releaseBinaryArtifacts } from "./lib/binary-release.mjs";
function log(msg = "") {
process.stdout.write(`${msg}\n`);
@@ -18,11 +19,13 @@ const { values } = parseArgs({
options: {
"dry-run": { type: "boolean", default: false },
knowledge: { type: "boolean", default: false },
"skip-binary": { type: "boolean", default: false },
},
allowPositionals: false,
});
const dryRun = values["dry-run"];
const knowledge = values.knowledge;
const skipBinary = values["skip-binary"];
const packages = knowledge ? ALL_PACKAGES : PACKAGES;
try {
@@ -59,20 +62,18 @@ try {
);
}
// Publish in dependency order (core → runtime → commands → cli [→ kscli]).
// 1) npm (dependency order: core → runtime → commands → cli [→ kscli])
for (const pkg of packages) {
if (published.get(pkg.key)) continue;
step(`publish ${pkg.name}@${version} (tag=latest, provenance)`);
pnpmPublish(pkg, { tag: "latest", provenance: true, dryRun });
}
// 2) git tag — must be on origin before the GitHub Release step (--verify-tag)
const tag = `v${version}`;
if (dryRun) {
log("\n[dry-run] skipping git tag");
process.exit(0);
}
const tag = `v${version}`;
if (tagExists(tag)) {
} else if (tagExists(tag)) {
log(`tag ${tag} already exists; skipping tag push`);
} else {
step(`tag ${tag} and push`);
@@ -80,7 +81,15 @@ try {
pushTag(tag);
}
log("\nstable release complete.");
// 3) binary GitHub Release (same version; orchestrated here, not a separate release entry)
if (skipBinary) {
log("\n[skip-binary] skipping Bun binary build/upload");
} else {
step(`publish binary GitHub Release (mode=stable, version=${version})`);
releaseBinaryArtifacts({ mode: "stable", dryRun });
}
log("\nstable release complete (npm + binary).");
} catch (error) {
process.stderr.write(`\nrelease publish-stable failed: ${error.message}\n`);
process.exit(1);