merge: merged main to current branch

This commit is contained in:
clh02467605
2026-08-05 10:59:26 +08:00
52 changed files with 3085 additions and 212 deletions
+52 -5
View File
@@ -18,7 +18,7 @@ on:
- channel
- stable
channel:
description: "dist-tag (channel mode only, e.g. mcp/plugin/advisor)"
description: "Required when mode=channel. npm dist-tag only (lowercase, digits, dashes), e.g. mcp / plugin / sync-release. bailian-cli binary CDN always overwrites sync-release.json; knowledge-studio-cli is npm-only."
required: false
type: string
@@ -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
@@ -55,19 +55,47 @@ jobs:
| sudo tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Ensure zip (per-platform binary archives)
run: sudo apt-get update && sudo apt-get install -y zip
- run: pnpm install --frozen-lockfile
# Binary compile uses `bun build --compile` CLI (not Bun.build API).
# Keep this pin in sync with any local smoke tests of binary-compile.mjs.
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2.19"
- name: publish-stable
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# OSS release channel runs fully in CI: upload + reconcile + manifest.json.
# All values come from repo Settings → Secrets — no OSS defaults live in
# code. Leave AK/SK unset to skip the OSS channel; once enabled,
# bucket/region/prefix are required.
BAILIAN_OSS_AK: ${{ secrets.BAILIAN_OSS_AK }}
BAILIAN_OSS_SK: ${{ secrets.BAILIAN_OSS_SK }}
BAILIAN_OSS_BUCKET: ${{ secrets.BAILIAN_OSS_BUCKET }}
BAILIAN_OSS_REGION: ${{ secrets.BAILIAN_OSS_REGION }}
BAILIAN_OSS_ENDPOINT: ${{ secrets.BAILIAN_OSS_ENDPOINT }}
BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }}
BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }}
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:
- name: Require channel input
if: ${{ inputs.channel == '' }}
run: |
echo "::error::mode=channel requires the workflow input \"channel\" (npm dist-tag, e.g. mcp / plugin / sync-release). Leave mode=stable if you do not need a dist-tag."
exit 1
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
@@ -87,7 +115,26 @@ jobs:
| sudo tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Ensure zip (per-platform binary archives)
run: sudo apt-get update && sudo apt-get install -y zip
- run: pnpm install --frozen-lockfile
# Binary compile uses `bun build --compile` CLI (not Bun.build API).
# Keep this pin in sync with any local smoke tests of binary-compile.mjs.
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.2.19"
- name: publish-channel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# OSS release channel — same Settings-injected values as stable.
BAILIAN_OSS_AK: ${{ secrets.BAILIAN_OSS_AK }}
BAILIAN_OSS_SK: ${{ secrets.BAILIAN_OSS_SK }}
BAILIAN_OSS_BUCKET: ${{ secrets.BAILIAN_OSS_BUCKET }}
BAILIAN_OSS_REGION: ${{ secrets.BAILIAN_OSS_REGION }}
BAILIAN_OSS_ENDPOINT: ${{ secrets.BAILIAN_OSS_ENDPOINT }}
BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }}
BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }}
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
+11
View File
@@ -6,6 +6,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
## [1.14.0] - 2026-08-04
### Added
- **Standalone installation without Node.js** — binary packages are available for macOS on Apple Silicon and Intel, Linux x64, and Windows x64; npm installation remains supported.
- **Exact-version updates** — binary and npm installations can use `bl update --to <version>` to update or switch to a specified version.
### Changed
- **Binary self-updates** — binary installations now check and download updates through a dedicated release channel. `bl update` no longer replaces the running executable, and the next invocation automatically uses the new version.
## [1.13.1] - 2026-08-03
### Changed
+11
View File
@@ -6,6 +6,17 @@
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
## [1.14.0] - 2026-08-04
### 新增
- **免 Node.js 的二进制安装** — 支持 macOS Apple Silicon / Intel、Linux x64 和 Windows x64npm 安装方式继续保留。
- **指定版本更新** — 二进制和 npm 安装均可通过 `bl update --to <version>` 更新或切换到指定版本。
### 变更
- **二进制自更新** — 二进制安装现在通过独立的发布通道检查和下载更新;执行 `bl update` 时不会覆盖正在运行的程序,下次运行自动使用新版本。
## [1.13.1] - 2026-08-03
### 变更
+49 -76
View File
@@ -1,53 +1,57 @@
# 阿里云百炼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.aliyun.com/cli/install.sh | bash
```
1. **Node.js**
- 要求 **≥ 18.17.0**,执行 `node -v` 确认。
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
可选:正式安装读 CDN 上的 `manifest.json`。预发 / channel 验证一律读 `sync-release.json`bailian-cli 的 channel 发版都会覆盖它):
2. **npm****唯一**允许的包管理器)
- 执行 `npm -v` 确认可用。
- 未安装或不可用:由 Agent **安装或修复 npm**(不要改用 pnpm/yarn 装 `bailian-cli`),再执行 `npm install -g bailian-cli`
- 即使本机已安装 `pnpm``yarn` 等,**也仅用于其它项目**;装 `bailian-cli` 时仍必须用 `npm`
```bash
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash -s -- --channel sync-release
```
3. **网络**
- 需能访问 npm registry默认 **registry.npmjs.org**)。
- 不可达:由 Agent 自行配置镜像或代理后重试。
也可用 `--version` / `--cdn`(或环境变量 `BAILIAN_CLI_CDN`)覆盖资源根。安装脚本本身不在本仓库维护。
---
Windows PowerShell
## 2. 安装 CLI 与 Skills
```powershell
# 正式manifest.json
irm https://bailian.aliyun.com/cli/install.ps1 | iex
**仅允许以下命令**(不要用 `pnpm add -g``yarn global add` 等)。按顺序执行,上一步通过后再进行下一步。
# channel / 预发验证sync-release.json
$env:BAILIAN_CHANNEL = 'sync-release'; irm 'https://bailian.aliyun.com/cli/install.ps1' | iex
```
**2.1 安装 CLI**
带其它参时先落盘再执行(`irm | iex` 不便传参),或使用仓外静态资源文档中的预发入口。
二进制安装布局为 `versions/<ver>/` + `current` 指针;`bl update` 只切换指针并清理旧版本(保留当前与上一版)。更新进程退出后,下次执行 `bl` 即使用新版本(无需「重启应用」)。
校验:
```bash
bl --version
which bl # Windows: where.exe bl
```
> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装。
## 2. 备选npm 安装(要求 **≥ 18.17.0**
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
@@ -70,39 +74,14 @@ npx skills add modelstudioai/cli -g -s bailian-protocol -s bailian-gen
### 推荐:浏览器登录(控制台会话)
适用于本机交互式安装,无需用户手动复制 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 安全约束
@@ -113,22 +92,16 @@ npx skills add modelstudioai/cli -g -s bailian-protocol -s bailian-gen
## 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. 常见问题
---
## 5. 常见问题Agent 排障清单)
| 现象 | 可能原因 | 建议动作 |
| ----------------------- | -------------------- | --------------------------------------------------------------- |
| `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` |
| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 |
| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node或改用 npm 版 CLI |
| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 |
+12 -5
View File
@@ -77,14 +77,20 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
## Installation
```bash
# Recommended — no Node required
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
# Windows (PowerShell)
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node users / developers (Node.js >= 18.17)
npm install -g bailian-cli
# Recommended: full bailian-* skill family (includes bailian-protocol)
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> Supported install is `--all -g`. The skills CLI does **not** auto-install skill dependencies. Subset `-s` installs are advanced / not recommended; if you use them, you must include `bailian-protocol` yourself (e.g. `-s bailian-protocol -s bailian-gen`).
> Requires Node.js >= 18.17.
> Binary install does not require Node.js. `npm install -g` remains fully supported.
## Quick Start
@@ -213,8 +219,9 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# Self-update to latest version
# Self-update to latest or a specific version
bl update
bl update --to 0.1.14
```
Config file location: `~/.bailian/config.json`
+13 -4
View File
@@ -75,14 +75,20 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
## 安装
```bash
# 推荐 — 无需本机 Node.js
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
# WindowsPowerShell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node 用户 / 开发者(需要 Node.js >= 18.17
npm install -g bailian-cli
# 推荐:安装完整 bailian-* skill 家族(含 bailian-protocol
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> 官方支持路径为 `--all -g`。`npx skills` **不会**按 metadata 自动安装依赖。子集 `-s` 为高级用法 / 不推荐;若坚持子集,须自行带上 `bailian-protocol`(例如 `-s bailian-protocol -s bailian-gen`
> 需要预先安装 Node.js >= 18.17。
> 二进制安装依赖 Node.js。`npm install -g` 长期保留
## 快速开始
@@ -213,6 +219,9 @@ bl config set --key timeout --value 600
# 自更新到最新版本
bl update
# 安装指定版本
bl update --to 0.1.14
```
配置文件位置:`~/.bailian/config.json`
+41 -11
View File
@@ -1,27 +1,53 @@
# 发布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
→ binary-build
→ gh-release
→ oss-direct-upload
```
`tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。
两种模式:
| 模式 | 用途 | 触发方式 |
| ------- | ------------------------------ | -------------------------------------------------- |
| channel | 发 channel 版本到指定 dist-tag | 选 mode=channel填 dist-tag 名称(如 mcp/plugin |
| stable | 正式发版到 latest | 选 mode=stable需 production environment 审批 |
| 模式 | 用途 | 触发方式 |
| ------- | --------------------------------------------------------------------------------------- | -------------------------------------------- |
| channel | npm dist-tag +(仅 bailian-cli二进制 + CDN **一律**覆盖 `sync-release.json` | mode=channelchannel 填 **npm dist-tag** |
| stable | npm latest + GitHub Release `v<ver>` + CDN **`manifest.json`**(及 `latest.json` 别名) | mode=stable需 production environment 审批 |
可选 flag`--skip-binary`(仅发 npm紧急逃生
### CDN 滚动指针bailian-cli
| 发布模式 | CDN 指针 | 本机安装 / 更新 |
| -------- | ---------------------------------- | ----------------------------------------------------------------- |
| channel | 始终覆盖 `sync-release.json` | `BAILIAN_CHANNEL=sync-release` / `install --channel sync-release` |
| stable | `manifest.json`+ `latest.json` | 默认安装 / `bl update`(无 channel |
workflow 的 `channel` 输入**只决定 npm dist-tag**(如 `mcp` / `plugin` / `sync-release`**不再**生成 `release-test.json` 这类旁路文件。
### 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
1. 在 GitHub 触发 Publish workflowmode 选 `channel`channel 填 npm dist-tag 名
- **`bailian-cli`**npm 发到该 tag二进制同时刷新 CDN `sync-release.json`(与 tag 名无关)。本机验证:`BAILIAN_CHANNEL=sync-release`
- **`knowledge-studio-cli`**:仅 npm自动跳过 binary不碰 `sync-release.json`
2. CI 自动:生成 `0.0.0-beta-<sha7>-<YYYYMMDDHHMM>`UTC 到分钟;同 commit 同分钟重跑会覆盖同号)→ 临时 bump → 自检 → **npm 发到 dist-tag**bailian-cli**Bun 编二进制 + GH prerelease + 覆盖 `sync-release.json`** → 还原 package.json
3. 对应脚本:`tools/release/publish-channel.mjs`
### stable 发布
@@ -29,7 +55,7 @@
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**bailian-cli维护 CDN **`manifest.json`** → 完成
5. 如果所选发布集合的当前版本已全部存在于 npmstable 发布会失败并提示先升级版本号如果只有部分包已发布CI 会继续补发缺失包
6. 对应脚本:`tools/release/publish-stable.mjs`
@@ -59,7 +85,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
@@ -105,3 +133,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` 失败 |
+1
View File
@@ -25,6 +25,7 @@
"wiki:crawl": "node tools/wiki-crawler/index.mjs",
"test:stress": "node packages/cli/tests/stress/run.mjs"
},
"dependencies": {},
"devDependencies": {
"tsx": "catalog:",
"vite-plus": "catalog:"
+12 -5
View File
@@ -77,14 +77,20 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide
## Installation
```bash
# Recommended — no Node required
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
# Windows (PowerShell)
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node users / developers (Node.js >= 18.17)
npm install -g bailian-cli
# Recommended: full bailian-* skill family (includes bailian-protocol)
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> Supported install is `--all -g`. The skills CLI does **not** auto-install skill dependencies. Subset `-s` installs are advanced / not recommended; if you use them, you must include `bailian-protocol` yourself (e.g. `-s bailian-protocol -s bailian-gen`).
> Requires Node.js >= 18.17.
> Binary install does not require Node.js. `npm install -g` remains fully supported.
## Quick Start
@@ -213,8 +219,9 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
bl config set --key default_text_model --value qwen-turbo
bl config set --key timeout --value 600
# Self-update to latest version
# Self-update to latest or a specific version
bl update
bl update --to 0.1.14
```
Config file location: `~/.bailian/config.json`
+13 -4
View File
@@ -75,14 +75,20 @@ _专为 AI Agent 打造每个命令均可作为结构化工具调用。_
## 安装
```bash
# 推荐 — 无需本机 Node.js
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
# WindowsPowerShell
irm https://bailian.aliyun.com/cli/install.ps1 | iex
# Node 用户 / 开发者(需要 Node.js >= 18.17
npm install -g bailian-cli
# 推荐:安装完整 bailian-* skill 家族(含 bailian-protocol
# Agent skills
npx skills add modelstudioai/cli --all -g
```
> 官方支持路径为 `--all -g`。`npx skills` **不会**按 metadata 自动安装依赖。子集 `-s` 为高级用法 / 不推荐;若坚持子集,须自行带上 `bailian-protocol`(例如 `-s bailian-protocol -s bailian-gen`
> 需要预先安装 Node.js >= 18.17。
> 二进制安装依赖 Node.js。`npm install -g` 长期保留
## 快速开始
@@ -213,6 +219,9 @@ bl config set --key timeout --value 600
# 自更新到最新版本
bl update
# 安装指定版本
bl update --to 0.1.14
```
配置文件位置:`~/.bailian/config.json`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli",
"version": "1.13.1",
"version": "1.14.0",
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
"keywords": [
"agent",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-commands",
"version": "1.13.1",
"version": "1.14.0",
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+127 -34
View File
@@ -1,22 +1,31 @@
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 {
BailianError,
DEFAULT_INSTALL_PS1_URL,
DEFAULT_INSTALL_SCRIPT_URL,
defineCommand,
getConfigDir,
getUpdateInstallMethod,
type InstallMethod,
} from "bailian-cli-core";
import {
ansi,
fetchLatestVersion,
fetchBinaryChannelVersion,
isValidUpdateTargetVersion,
normalizeBinaryVersion,
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,56 +35,140 @@ 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);
}
function binaryReinstallHint(): string {
if (process.platform === "win32") {
return ` irm ${DEFAULT_INSTALL_PS1_URL} | iex\n`;
}
return ` curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash\n`;
}
export default defineCommand({
description: "Update the CLI to the latest version",
description: "Update the CLI to the latest or a specified version",
auth: "none",
exampleArgs: [""],
usageArgs: "[--to <version>]",
flags: {
to: {
type: "string",
valueHint: "<version>",
description: "Install this exact version instead of the latest",
},
},
exampleArgs: ["", "--to 0.1.14"],
validate(flags) {
if (flags.to === undefined) return undefined;
if (!flags.to.trim()) return "--to requires a non-empty version";
if (!isValidUpdateTargetVersion(flags.to)) {
return `--to must be a semver version (e.g. 1.13.0, v1.13.0, 0.0.0-beta-<sha>-<YYYYMMDDHHMM>), got: ${flags.to.trim()}`;
}
return undefined;
},
async run(ctx) {
const { identity } = ctx;
const npmPackage = identity.npmPackage;
const binName = identity.binName;
const currentVersion = identity.version;
const color = ansi(process.stderr);
const method = getUpdateInstallMethod(identity);
const requestedTo = ctx.flags.to?.trim();
const pinnedVersion = requestedTo ? normalizeBinaryVersion(requestedTo) : undefined;
process.stderr.write(`Current version: ${color.yellow(currentVersion)}\n`);
process.stderr.write(`Install method: ${color.dim(method)}\n`);
if (pinnedVersion) {
process.stderr.write(`Target version: ${color.green(pinnedVersion)}\n`);
} else {
process.stderr.write("Checking for updates...\n");
}
// Check latest version first
process.stderr.write("Checking for updates...\n");
const latest = await fetchLatestVersion(5000, npmPackage);
if (latest && latest === currentVersion) {
process.stderr.write(`${color.green(`\u2713 Already up to date (${currentVersion}).`)}\n`);
updateAgentSkill(color);
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`,
);
if (pinnedVersion) {
process.stderr.write(
`${color.dim(`Note: --to is not supported for ${method} installs.`)}\n`,
);
}
return;
}
if (latest) {
process.stderr.write(`Latest version: ${color.green(latest)}\n\n`);
const targetVersion = pinnedVersion ?? (await resolveLatest(method, npmPackage));
if (!targetVersion) {
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 (targetVersion === currentVersion) {
const message = pinnedVersion
? `\u2713 Already at ${currentVersion}.`
: `\u2713 Already up to date (${currentVersion}).`;
process.stderr.write(`${color.green(message)}\n`);
if (method === "npm") updateAgentSkill(color);
return;
}
if (!pinnedVersion) {
process.stderr.write(`Latest version: ${color.green(targetVersion)}\n\n`);
} else {
process.stderr.write("\n");
}
if (method === "binary") {
process.stderr.write(`Updating via binary channel...\n\n`);
try {
const newVer = await performBinaryUpdate(targetVersion);
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);
const reinstall =
error instanceof BailianError && error.hint
? error.hint.replace(/^Re-run:\s*/i, "")
: binaryReinstallHint().trim();
process.stderr.write(`\nAutomatic binary update failed: ${message}\n`);
process.stderr.write("Re-run the install script:\n");
process.stderr.write(` ${reinstall}\n\n`);
}
return;
}
const npmSpec = pinnedVersion ? `${npmPackage}@${pinnedVersion}` : `${npmPackage}@latest`;
const cmd = `npm install -g ${npmSpec}`;
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`);
}
@@ -10,6 +10,10 @@ export const AUTH_ROUTES: E2eRouteExports = {
"auth logout": "authLogout",
};
export const UPDATE_ROUTES: E2eRouteExports = {
update: "update",
};
export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" };
export const CONFIG_ROUTES: E2eRouteExports = {
@@ -0,0 +1,33 @@
import { describe, expect, test } from "vite-plus/test";
import { runCommandE2e } from "./helpers.ts";
import { UPDATE_ROUTES } from "./topic-routes.ts";
describe("e2e: update", () => {
test("update --help 正常退出并展示 --to", async () => {
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toMatch(/--to/);
expect(stderr).toMatch(/<version>/);
});
test("update --help 包含 --to 示例", async () => {
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--help"]);
expect(exitCode, stderr).toBe(0);
expect(stderr).toContain("--to 0.1.14");
});
test("update --to 缺值时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--to"]);
expect(exitCode, stderr).toBe(2);
});
test("update --to 非法版本时退出为用法错误 (2)", async () => {
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, [
"update",
"--to",
"not-a-version",
]);
expect(exitCode, stderr).toBe(2);
expect(stderr).toMatch(/semver|--to/i);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-core",
"version": "1.13.1",
"version": "1.14.0",
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+1
View File
@@ -16,4 +16,5 @@ 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";
export * from "./skills/index.ts";
+107
View File
@@ -0,0 +1,107 @@
/**
* End-user binary download base (OSS). CI publishes release assets and rolling
* channel manifests here directly (tools/release/lib/oss-direct-upload.mjs);
* no external FC is involved.
*
* Layout under the base:
* v<version>/<asset>.zip immutable per-version binaries + SHA256SUMS
* manifest.json stable install/update pointer (rolling-manifest shape)
* latest.json stable alias; same body as manifest.json
* sync-release.json official channel/verify rolling pointer (all bailian-cli
* channel publishes overwrite this; npm dist-tag is separate)
*
* Legacy `{name}.json` files may still exist on CDN; install may resolve them, but
* release tooling no longer creates per-dist-tag manifests.
*
* Override with `BAILIAN_CLI_CDN`.
*/
export const DEFAULT_CLI_CDN_BASE = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release";
/** GitHub Releases base — used when writing manifests attached to gh release assets. */
export const GITHUB_RELEASES_BASE = "https://github.com/modelstudioai/cli/releases";
/** User-facing install entry (docs / update hints); asset downloads still use getCliCdnBase(). */
export const DEFAULT_INSTALL_SCRIPT_URL = "https://bailian.aliyun.com/cli/install.sh";
export const DEFAULT_INSTALL_PS1_URL = "https://bailian.aliyun.com/cli/install.ps1";
export function getCliCdnBase(): string {
const fromEnv = process.env.BAILIAN_CLI_CDN?.trim();
if (fromEnv) return fromEnv.replace(/\/$/, "");
return DEFAULT_CLI_CDN_BASE;
}
/**
* Rolling manifest URL at the CDN base root.
* Stable (`latest` / `stable` / empty) `manifest.json`.
* Official verify line `sync-release.json` (`channel=sync-release`).
* Other names still map to `{channel}.json` for backward compatibility only.
* All share the same rolling-manifest shape from binary-build.
*/
export function channelManifestUrl(channel = "latest"): string {
const normalized = channel.trim();
if (!normalized || normalized === "latest" || normalized === "stable") {
return `${getCliCdnBase()}/manifest.json`;
}
return `${getCliCdnBase()}/${normalized}.json`;
}
/** Immutable per-version asset: `{base}/v{version}/{fileName}`. */
export function releaseAssetUrl(version: string, fileName: string): string {
const tag = version.startsWith("v") ? version : `v${version}`;
return `${getCliCdnBase()}/${tag}/${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 };
}
/** Release download asset: `bl-<ver>-<os>-<arch>.zip`. */
export function binaryAssetFileName(
version: string,
os: string,
arch: string,
_exe = false,
): string {
return `bl-${version}-${os}-${arch}.zip`;
}
/** Uncompressed binary name inside the zip. */
export function binaryInnerFileName(
version: string,
os: string,
arch: string,
exe = false,
): string {
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
}
+23
View File
@@ -0,0 +1,23 @@
export {
BINARY_PRODUCT_CLIENT_NAME,
detectInstallMethod,
getInstallMethod,
getUpdateInstallMethod,
isCompiledBinary,
writeInstallMethodSync,
type InstallMethod,
type InstallMethodIdentity,
} from "./method.ts";
export {
DEFAULT_CLI_CDN_BASE,
DEFAULT_INSTALL_PS1_URL,
DEFAULT_INSTALL_SCRIPT_URL,
GITHUB_RELEASES_BASE,
binaryAssetFileName,
binaryInnerFileName,
channelManifestUrl,
detectBinaryPlatform,
getCliCdnBase,
releaseAssetUrl,
} from "./cdn.ts";
export { extractZipEntryToFile } from "./unzip-asset.ts";
+131
View File
@@ -0,0 +1,131 @@
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";
/** Product that currently ships standalone binary artifacts (`bl` / `bailian`). */
export const BINARY_PRODUCT_CLIENT_NAME = "bailian-cli";
const INSTALL_METHOD_FILE = "install-method";
const VALID_METHODS = new Set<InstallMethod>(["binary", "npm", "brew", "winget", "unknown"]);
export type InstallMethodIdentity = {
clientName: string;
};
function installMethodPath(clientName?: string): string {
if (!clientName) return join(getConfigDir(), INSTALL_METHOD_FILE);
return join(getConfigDir(), `${INSTALL_METHOD_FILE}.${clientName}`);
}
/**
* 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;
}
function readInstallMethodFile(path: string): InstallMethod | null {
try {
const raw = readFileSync(path, "utf-8");
return parseInstallMethod(raw.split("\n")[0]);
} catch {
return 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.
*
* When `identity` is provided, prefer `install-method.<clientName>`.
* Legacy `~/.bailian/install-method` is only consulted for `bailian-cli`
* so other products (e.g. kscli) are not polluted by a shared binary marker.
*/
export function getInstallMethod(identity?: InstallMethodIdentity): InstallMethod {
const fromEnv = parseInstallMethod(process.env.BAILIAN_INSTALL_METHOD);
if (fromEnv) return fromEnv;
if (identity?.clientName) {
const productMethod = readInstallMethodFile(installMethodPath(identity.clientName));
if (productMethod) return productMethod;
if (identity.clientName === BINARY_PRODUCT_CLIENT_NAME) {
const legacyMethod = readInstallMethodFile(installMethodPath());
if (legacyMethod) return legacyMethod;
}
return detectInstallMethod();
}
const legacyMethod = readInstallMethodFile(installMethodPath());
if (legacyMethod) return legacyMethod;
return detectInstallMethod();
}
/**
* Install method for update / auto-update routing.
* Only `bailian-cli` may follow the binary channel; other products always use npm
* even if env or a mistaken marker claims `binary`.
*/
export function getUpdateInstallMethod(identity: {
clientName: string;
npmPackage: string;
}): InstallMethod {
const method = getInstallMethod(identity);
if (method === "binary" && identity.npmPackage !== BINARY_PRODUCT_CLIENT_NAME) {
return "npm";
}
return method;
}
/**
* Persist install method under `~/.bailian/install-method.<clientName>` (best-effort).
* For `bailian-cli`, also write the legacy `install-method` file for older readers.
*/
export function writeInstallMethodSync(
method: InstallMethod,
identity: InstallMethodIdentity = { clientName: BINARY_PRODUCT_CLIENT_NAME },
): void {
try {
const dir = getConfigDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
writeFileSync(installMethodPath(identity.clientName), `${method}\n`, { mode: 0o600 });
if (identity.clientName === BINARY_PRODUCT_CLIENT_NAME) {
writeFileSync(installMethodPath(), `${method}\n`, { mode: 0o600 });
}
} catch {
/* best effort */
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Extract a single file entry from a ZIP into `destPath` (overwrites).
* Uses yauzl (already a core dependency for dataset ZIP validation).
*/
import { createWriteStream } from "node:fs";
import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { pipeline } from "node:stream/promises";
import * as yauzl from "yauzl";
function openZip(zipPath: string): Promise<yauzl.ZipFile> {
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (error, zipfile) => {
if (error || !zipfile) {
reject(error ?? new Error(`Failed to open zip: ${zipPath}`));
return;
}
resolve(zipfile);
});
});
}
function entryBaseName(fileName: string): string {
const normalized = fileName.replace(/\\/g, "/");
return normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized;
}
/**
* Extract `entryName` (or the first non-directory entry) from `zipPath` to `destPath`.
* Returns the archive entry basename that was extracted.
*/
export async function extractZipEntryToFile(
zipPath: string,
destPath: string,
entryName?: string,
): Promise<string> {
const zipfile = await openZip(zipPath);
return new Promise((resolve, reject) => {
let settled = false;
const fail = (error: unknown) => {
if (settled) return;
settled = true;
try {
zipfile.close();
} catch {
/* ignore */
}
reject(error instanceof Error ? error : new Error(String(error)));
};
const succeed = (baseName: string) => {
if (settled) return;
settled = true;
try {
zipfile.close();
} catch {
/* ignore */
}
resolve(baseName);
};
zipfile.on("error", fail);
zipfile.on("end", () => {
if (settled) return;
fail(
new Error(
entryName
? `Zip entry not found: ${entryName} in ${zipPath}`
: `Zip has no file entries: ${zipPath}`,
),
);
});
zipfile.on("entry", (current: yauzl.Entry) => {
if (settled) return;
const name = current.fileName.replace(/\\/g, "/");
if (name.endsWith("/")) {
zipfile.readEntry();
return;
}
const base = entryBaseName(name);
const isMatch = entryName ? name === entryName || base === entryName : true;
if (!isMatch) {
zipfile.readEntry();
return;
}
zipfile.openReadStream(current, (streamError, readStream) => {
if (streamError || !readStream) {
fail(streamError ?? new Error(`Failed to read zip entry: ${current.fileName}`));
return;
}
void (async () => {
try {
await mkdir(dirname(destPath), { recursive: true });
await pipeline(readStream, createWriteStream(destPath));
succeed(base);
} catch (error) {
fail(error);
}
})();
});
});
zipfile.readEntry();
});
}
+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;
}
+126
View File
@@ -0,0 +1,126 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vite-plus/test";
import {
BINARY_PRODUCT_CLIENT_NAME,
detectInstallMethod,
getInstallMethod,
getUpdateInstallMethod,
isCompiledBinary,
binaryAssetFileName,
binaryInnerFileName,
writeInstallMethodSync,
} 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("getInstallMethod isolates products from shared legacy binary marker", () => {
const savedConfigDir = process.env.BAILIAN_CONFIG_DIR;
const savedInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
const dir = mkdtempSync(join(tmpdir(), "bl-install-method-"));
process.env.BAILIAN_CONFIG_DIR = dir;
delete process.env.BAILIAN_INSTALL_METHOD;
try {
writeFileSync(join(dir, "install-method"), "binary\n", { mode: 0o600 });
expect(getInstallMethod({ clientName: BINARY_PRODUCT_CLIENT_NAME })).toBe("binary");
expect(getInstallMethod({ clientName: "knowledge-studio-cli" })).toBe("npm");
expect(
getUpdateInstallMethod({
clientName: "knowledge-studio-cli",
npmPackage: "knowledge-studio-cli",
}),
).toBe("npm");
} finally {
if (savedConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
else process.env.BAILIAN_CONFIG_DIR = savedConfigDir;
if (savedInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
else process.env.BAILIAN_INSTALL_METHOD = savedInstallMethod;
rmSync(dir, { recursive: true, force: true });
}
});
test("writeInstallMethodSync writes product marker and legacy for bailian-cli", () => {
const savedConfigDir = process.env.BAILIAN_CONFIG_DIR;
const savedInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
const dir = mkdtempSync(join(tmpdir(), "bl-install-method-write-"));
process.env.BAILIAN_CONFIG_DIR = dir;
delete process.env.BAILIAN_INSTALL_METHOD;
try {
writeInstallMethodSync("binary", { clientName: BINARY_PRODUCT_CLIENT_NAME });
expect(getInstallMethod({ clientName: BINARY_PRODUCT_CLIENT_NAME })).toBe("binary");
expect(getInstallMethod()).toBe("binary");
expect(getInstallMethod({ clientName: "knowledge-studio-cli" })).toBe("npm");
} finally {
if (savedConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
else process.env.BAILIAN_CONFIG_DIR = savedConfigDir;
if (savedInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
else process.env.BAILIAN_INSTALL_METHOD = savedInstallMethod;
rmSync(dir, { recursive: true, force: true });
}
});
test("getUpdateInstallMethod forces npm for non-bailian products even with binary env", () => {
const previous = process.env.BAILIAN_INSTALL_METHOD;
process.env.BAILIAN_INSTALL_METHOD = "binary";
expect(
getUpdateInstallMethod({
clientName: "knowledge-studio-cli",
npmPackage: "knowledge-studio-cli",
}),
).toBe("npm");
expect(
getUpdateInstallMethod({
clientName: BINARY_PRODUCT_CLIENT_NAME,
npmPackage: BINARY_PRODUCT_CLIENT_NAME,
}),
).toBe("binary");
if (previous === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
else process.env.BAILIAN_INSTALL_METHOD = previous;
});
test("binaryAssetFileName uses per-platform zip", () => {
expect(binaryAssetFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.zip");
expect(binaryAssetFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64.zip");
});
test("binaryInnerFileName keeps exe suffix inside zip", () => {
expect(binaryInnerFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.exe");
expect(binaryInnerFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64");
});
test("channelManifestUrl maps stable to manifest.json", async () => {
const { channelManifestUrl } = await import("../src/install/cdn.ts");
const previous = process.env.BAILIAN_CLI_CDN;
delete process.env.BAILIAN_CLI_CDN;
expect(channelManifestUrl()).toBe(
"https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/manifest.json",
);
expect(channelManifestUrl("latest")).toBe(
"https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/manifest.json",
);
expect(channelManifestUrl("sync-release")).toBe(
"https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/sync-release.json",
);
if (previous === undefined) delete process.env.BAILIAN_CLI_CDN;
else process.env.BAILIAN_CLI_CDN = previous;
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "knowledge-studio-cli",
"version": "1.13.1",
"version": "1.14.0",
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
"keywords": [
"alibaba-cloud",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bailian-cli-runtime",
"version": "1.13.1",
"version": "1.14.0",
"description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.",
"homepage": "https://bailian.console.aliyun.com/cli",
"bugs": {
+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,
+16
View File
@@ -67,6 +67,22 @@ export {
NPM_PACKAGE,
NPM_REGISTRY,
} from "./utils/update-checker.ts";
export {
ensureBinaryPathEntries,
fetchBinaryChannelVersion,
fetchBinaryChannelManifest,
getBinaryBinRoot,
getBinaryCurrentPath,
getBinaryShareRoot,
getBinaryVersionsDir,
isValidUpdateTargetVersion,
normalizeBinaryVersion,
performBinaryUpdate,
pruneBinaryVersions,
readCurrentVersionDir,
resolveBinaryDownloadSpec,
switchCurrentToVersion,
} from "./utils/binary-update.ts";
export {
BOOL_FLAG_WATERMARK,
BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE,
+11 -2
View File
@@ -133,7 +133,11 @@ export const telemetryStage: Middleware = (ctx, next) => {
* if `next()` throws, the notice is skipped (no update nag on failure).
*/
export const versionCheckStage: Middleware = async (ctx, next) => {
const pending = checkForUpdate(ctx.identity.version, ctx.identity.npmPackage).catch(() => {});
const pending = checkForUpdate(
ctx.identity.version,
ctx.identity.npmPackage,
ctx.identity.clientName,
).catch(() => {});
await next();
await pending;
@@ -142,7 +146,12 @@ export const versionCheckStage: Middleware = async (ctx, next) => {
if (newVersion && !ctx.settings.quiet && !isUpdateCommand) {
if (shouldAutoUpdate(newVersion, ctx.identity.version)) {
// 大版本差距且目标为稳定版,自动更新
await performAutoUpdate(ctx.identity.version, newVersion, ctx.identity.npmPackage);
await performAutoUpdate(
ctx.identity.version,
newVersion,
ctx.identity.npmPackage,
ctx.identity.clientName,
);
} else {
const color = ansi(process.stderr);
process.stderr.write(
+461
View File
@@ -0,0 +1,461 @@
import {
chmod,
copyFile,
lstat,
mkdir,
readdir,
readlink,
rename,
rm,
unlink,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { createHash } from "node:crypto";
import {
BINARY_PRODUCT_CLIENT_NAME,
binaryAssetFileName,
binaryInnerFileName,
channelManifestUrl,
detectBinaryPlatform,
extractZipEntryToFile,
getConfigDir,
releaseAssetUrl,
writeInstallMethodSync,
} from "bailian-cli-core";
export interface ChannelManifest {
version: string;
assets?: Record<string, { file?: string; sha256?: string; url?: string; inner?: string }>;
}
/** Product share root: versions/, current, and (on Windows) bin/. */
export function getBinaryShareRoot(): 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");
}
/** PATH directory that should expose `bl` / `bailian`. */
export function getBinaryBinRoot(): string {
if (process.env.BAILIAN_BIN_DIR) return process.env.BAILIAN_BIN_DIR;
if (process.platform === "win32") {
return join(getBinaryShareRoot(), "bin");
}
return join(homedir(), ".local", "bin");
}
export function getBinaryVersionsDir(): string {
return join(getBinaryShareRoot(), "versions");
}
export function getBinaryCurrentPath(): string {
return join(getBinaryShareRoot(), "current");
}
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;
}
}
/** Strip a leading `v` from release-style tags (`v1.2.3` → `1.2.3`). */
export function normalizeBinaryVersion(raw: string): string {
const trimmed = raw.trim();
if (/^v\d/i.test(trimmed)) return trimmed.slice(1);
return trimmed;
}
/**
* Semver core + optional pre-release / build metadata.
* Accepts this repo's channel betas (`0.0.0-beta-<sha7>-<YYYYMMDDHHMM>`) and
* ordinary releases (`1.13.0`, `1.4.2-beta.1`). Optional leading `v` is allowed.
*/
const UPDATE_TARGET_VERSION_RE =
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
/** True if `raw` is a usable `--to` target after trim (optional `v` prefix). */
export function isValidUpdateTargetVersion(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
return UPDATE_TARGET_VERSION_RE.test(trimmed);
}
async function fetchSha256FromVersionSums(
version: string,
fileName: string,
timeoutMs = 8000,
): Promise<string | undefined> {
try {
const response = await fetch(releaseAssetUrl(version, "SHA256SUMS"), {
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) return undefined;
const text = await response.text();
for (const line of text.split("\n")) {
const match = line.trim().match(/^([a-fA-F0-9]{64})\s+(\S+)$/);
if (match?.[2] === fileName) return match[1].toLowerCase();
}
} catch {
/* optional checksum source */
}
return undefined;
}
export interface BinaryDownloadSpec {
zipName: string;
innerName: string;
url: string;
expectedSha?: string;
}
/**
* Resolve download URL / names for an exact binary version.
* Always targets `v{version}/` assets; never reuses another version's rolling
* manifest `url` / `file`. Checksum prefers per-version SHA256SUMS, then the
* latest rolling manifest only when it points at the same version.
*/
export async function resolveBinaryDownloadSpec(
targetVersion: string,
): Promise<BinaryDownloadSpec> {
const version = normalizeBinaryVersion(targetVersion);
const { os, arch, fileSuffix } = detectBinaryPlatform();
const exe = fileSuffix === ".exe";
const zipName = binaryAssetFileName(version, os, arch, exe);
const innerName = binaryInnerFileName(version, os, arch, exe);
const url = releaseAssetUrl(version, zipName);
let expectedSha = await fetchSha256FromVersionSums(version, zipName);
if (!expectedSha) {
const manifest = await fetchBinaryChannelManifest("latest");
if (manifest?.version === version) {
expectedSha = manifest.assets?.[`${os}-${arch}`]?.sha256;
}
}
return { zipName, innerName, url, expectedSha };
}
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");
}
function binaryFileName(): string {
return process.platform === "win32" ? "bl.exe" : "bl";
}
function aliasFileName(): string {
return process.platform === "win32" ? "bailian.exe" : "bailian";
}
/** Resolve which version directory `current` points at, if any. */
export async function readCurrentVersionDir(): Promise<string | null> {
const currentPath = getBinaryCurrentPath();
try {
const target = await readlink(currentPath);
return target.startsWith("/") || /^[A-Za-z]:[\\/]/.test(target)
? target
: join(dirname(currentPath), target);
} catch {
return null;
}
}
function versionNameFromDir(versionDir: string): string | null {
const versionsRoot = getBinaryVersionsDir();
const normalizedDir = versionDir.replaceAll("\\", "/");
const normalizedRoot = versionsRoot.replaceAll("\\", "/").replace(/\/$/, "");
if (!normalizedDir.startsWith(`${normalizedRoot}/`) && normalizedDir !== normalizedRoot) {
// Also accept basename match when paths differ by symlink resolution
const base = versionDir.replaceAll("\\", "/").split("/").pop();
return base && base !== "versions" ? base : null;
}
return normalizedDir.slice(normalizedRoot.length + 1).split("/")[0] ?? null;
}
/**
* Point `shareRoot/current` at `versions/<version>/`.
* Unix: directory symlink. Windows: directory junction.
* Retargets in place so PATH entries that go through `current` keep working.
*/
export async function switchCurrentToVersion(version: string): Promise<string> {
const versionDir = join(getBinaryVersionsDir(), version);
const currentPath = getBinaryCurrentPath();
await mkdir(getBinaryShareRoot(), { recursive: true });
try {
await unlink(currentPath);
} catch {
try {
await rm(currentPath, { recursive: true, force: true });
} catch {
/* missing */
}
}
const { symlink } = await import("node:fs/promises");
if (process.platform === "win32") {
await symlink(versionDir, currentPath, "junction");
} else {
await symlink(versionDir, currentPath);
}
return versionDir;
}
/**
* Ensure PATH bin entries resolve through `current` (Codex-style).
* - Unix: `~/.local/bin/{bl,bailian}` `current/bl`
* - Windows: `shareRoot/bin` is a junction `current` (contains bl.exe + bailian.exe)
*/
export async function ensureBinaryPathEntries(version: string): Promise<void> {
const versionDir = join(getBinaryVersionsDir(), version);
const binaryName = binaryFileName();
const currentBinary = join(getBinaryCurrentPath(), binaryName);
const binDir = getBinaryBinRoot();
if (process.platform === "win32") {
await ensureWindowsBinJunction(binDir);
// Version dir must expose both aliases for the bin junction to work.
const primary = join(versionDir, binaryName);
const aliasPath = join(versionDir, aliasFileName());
try {
await lstat(aliasPath);
} catch {
try {
const { link } = await import("node:fs/promises");
await link(primary, aliasPath);
} catch {
await copyFile(primary, aliasPath);
}
}
return;
}
await mkdir(binDir, { recursive: true });
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(currentBinary, linkPath);
}
}
function errnoCode(error: unknown): string {
if (error && typeof error === "object" && "code" in error) {
return String((error as { code?: unknown }).code ?? "");
}
return "";
}
/**
* Ensure `shareRoot/bin` is a junction `current`.
*
* Install scripts / older layouts may leave a real `bin/` directory with
* `bl.exe` inside. Deleting that directory fails with EACCES while this
* process is the running image rename-away first (Windows allows that),
* then create the junction. Stale `bin.migrating-*` dirs are best-effort GC.
*/
export async function ensureWindowsBinJunction(binDir: string): Promise<void> {
const currentPath = getBinaryCurrentPath();
const { symlink, rename } = await import("node:fs/promises");
let migratedAside: string | null = null;
try {
const stats = await lstat(binDir);
if (stats.isSymbolicLink()) {
const target = await readlink(binDir);
const resolved =
target.startsWith("/") || /^[A-Za-z]:[\\/]/.test(target)
? target
: join(dirname(binDir), target);
if (
resolved.replaceAll("\\", "/").toLowerCase() ===
currentPath.replaceAll("\\", "/").toLowerCase()
) {
return;
}
await unlink(binDir);
} else if (stats.isDirectory()) {
// Prefer rename over rm: a running bl.exe inside bin locks delete/rm,
// but rename of the directory usually succeeds on Windows.
migratedAside = `${binDir}.migrating.${process.pid}`;
try {
await rename(binDir, migratedAside);
} catch (renameError) {
// Fallback: empty / unlocked real dirs can still be removed.
try {
await rm(binDir, { recursive: true, force: true });
migratedAside = null;
} catch (rmError) {
const code = errnoCode(renameError) || errnoCode(rmError) || "EACCES";
throw new Error(
`Failed to migrate ${binDir} to a junction pointing at current (${code}). ` +
`Close other bl sessions and re-run update, or re-run the install script once.`,
{ cause: rmError },
);
}
}
} else {
await unlink(binDir).catch(() => rm(binDir, { recursive: true, force: true }));
}
} catch (error) {
const code = errnoCode(error);
if (code && code !== "ENOENT") {
if (error instanceof Error && error.message.includes("Failed to migrate")) throw error;
throw new Error(
`Failed to migrate ${binDir} to a junction pointing at current (${code}). ` +
`Close other bl sessions and re-run update, or re-run the install script once.`,
{ cause: error },
);
}
}
await mkdir(dirname(binDir), { recursive: true });
await symlink(currentPath, binDir, "junction");
if (migratedAside) {
// Best-effort: locked exes may keep the aside dir until process exit.
await rm(migratedAside, { recursive: true, force: true }).catch(() => {});
}
}
/**
* Keep only the listed version directory names under `versions/`.
* Always preserves directories that are still the live `current` target.
*/
export async function pruneBinaryVersions(keepVersions: string[]): Promise<void> {
const versionsDir = getBinaryVersionsDir();
const keep = new Set(keepVersions.filter(Boolean));
const currentDir = await readCurrentVersionDir();
const currentName = currentDir ? versionNameFromDir(currentDir) : null;
if (currentName) keep.add(currentName);
let entries: string[];
try {
entries = await readdir(versionsDir);
} catch {
return;
}
for (const entry of entries) {
if (entry.startsWith(".")) {
await rm(join(versionsDir, entry), { recursive: true, force: true }).catch(() => {});
continue;
}
if (keep.has(entry)) continue;
await rm(join(versionsDir, entry), { recursive: true, force: true }).catch(() => {});
}
}
/**
* Download and install a newer standalone binary using Codex-style layout:
* `versions/<ver>/` + retarget `current` + path entries through `current`.
* After a successful switch, prune so only current + previous version remain.
*
* Does not overwrite a running executable image: old version files stay locked
* by the current process; the next invocation follows the updated pointer.
*/
export async function performBinaryUpdate(targetVersion: string): Promise<string> {
const version = normalizeBinaryVersion(targetVersion);
const { zipName, innerName, url, expectedSha } = await resolveBinaryDownloadSpec(version);
const share = getBinaryShareRoot();
const versionsDir = getBinaryVersionsDir();
await mkdir(join(share, ".tmp"), { recursive: true });
await mkdir(versionsDir, { recursive: true });
const previousVersionDir = await readCurrentVersionDir();
const previousVersion = previousVersionDir ? versionNameFromDir(previousVersionDir) : null;
const tmpZip = join(share, ".tmp", zipName);
const buffer = await downloadToFile(url, tmpZip);
const actualSha = sha256(buffer);
if (expectedSha && expectedSha !== actualSha) {
await unlink(tmpZip).catch(() => {});
throw new Error(`Checksum mismatch for ${zipName}`);
}
const stagingDir = join(versionsDir, `.staging.${version}.${process.pid}`);
await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
await mkdir(stagingDir, { recursive: true });
const binaryName = binaryFileName();
const stagingBinary = join(stagingDir, binaryName);
const tmpBinary = join(share, ".tmp", `${binaryName}.${process.pid}`);
await extractZipEntryToFile(tmpZip, tmpBinary, innerName);
await unlink(tmpZip).catch(() => {});
await rename(tmpBinary, stagingBinary);
if (process.platform !== "win32") {
await chmod(stagingBinary, 0o755);
} else {
const aliasPath = join(stagingDir, aliasFileName());
try {
const { link } = await import("node:fs/promises");
await link(stagingBinary, aliasPath);
} catch {
await copyFile(stagingBinary, aliasPath);
}
}
const versionDir = join(versionsDir, version);
await rm(versionDir, { recursive: true, force: true }).catch(() => {});
await rename(stagingDir, versionDir);
await switchCurrentToVersion(version);
await ensureBinaryPathEntries(version);
const keep = [version];
if (previousVersion && previousVersion !== version) {
keep.push(previousVersion);
}
await pruneBinaryVersions(keep);
writeInstallMethodSync("binary", { clientName: BINARY_PRODUCT_CLIENT_NAME });
await mkdir(getConfigDir(), { recursive: true });
return version;
}
+51 -19
View File
@@ -1,6 +1,13 @@
import { join } from "path";
import { readFileSync, writeFileSync } from "fs";
import { getConfigDir, trackingHeaders } from "bailian-cli-core";
import {
BailianError,
DEFAULT_INSTALL_PS1_URL,
DEFAULT_INSTALL_SCRIPT_URL,
getConfigDir,
trackingHeaders,
getUpdateInstallMethod,
} from "bailian-cli-core";
export const NPM_REGISTRY = "https://registry.npmjs.org";
/** Default npm package; products override per-call via the `npmPackage` argument. */
@@ -207,13 +214,14 @@ 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(
currentVersion: string,
latestVersion: string,
npmPackage: string = NPM_PACKAGE,
clientName: string = NPM_PACKAGE,
): Promise<boolean> {
const isTTY = process.stderr.isTTY;
const green = isTTY ? "\x1b[32m" : "";
@@ -222,6 +230,11 @@ export async function performAutoUpdate(
const dim = isTTY ? "\x1b[2m" : "";
const reset = isTTY ? "\x1b[0m" : "";
const method = getUpdateInstallMethod({ clientName, npmPackage });
if (method === "brew" || method === "winget") {
return false;
}
const [latestMajor] = parseVersion(latestVersion);
const [currentMajor] = parseVersion(currentVersion);
const isMajorBump = latestMajor > currentMajor;
@@ -240,17 +253,34 @@ 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`);
const reinstall =
err instanceof BailianError && err.hint
? err.hint.replace(/^Re-run:\s*/i, "")
: process.platform === "win32"
? `irm ${DEFAULT_INSTALL_PS1_URL} | iex`
: `curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash`;
process.stderr.write(` ${yellow} Re-run:${reset} ${cyan}${reinstall}${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 +294,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 +301,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`,
@@ -305,16 +327,26 @@ export async function performAutoUpdate(
export async function checkForUpdate(
currentVersion: string,
npmPackage: string = NPM_PACKAGE,
clientName: string = NPM_PACKAGE,
): Promise<void> {
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 = getUpdateInstallMethod({ clientName, npmPackage });
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 });
@@ -0,0 +1,187 @@
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readlinkSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vite-plus/test";
import {
ensureBinaryPathEntries,
ensureWindowsBinJunction,
getBinaryBinRoot,
getBinaryCurrentPath,
getBinaryShareRoot,
getBinaryVersionsDir,
isValidUpdateTargetVersion,
normalizeBinaryVersion,
pruneBinaryVersions,
readCurrentVersionDir,
resolveBinaryDownloadSpec,
switchCurrentToVersion,
} from "../src/utils/binary-update.ts";
import { binaryAssetFileName, releaseAssetUrl } from "bailian-cli-core";
function withTempBinaryRoots(run: () => Promise<void>): Promise<void> {
const root = mkdtempSync(join(tmpdir(), "bl-binary-layout-"));
const previousShare = process.env.BAILIAN_SHARE_DIR;
const previousBin = process.env.BAILIAN_BIN_DIR;
process.env.BAILIAN_SHARE_DIR = root;
process.env.BAILIAN_BIN_DIR = join(root, "path-bin");
return run().finally(() => {
if (previousShare === undefined) delete process.env.BAILIAN_SHARE_DIR;
else process.env.BAILIAN_SHARE_DIR = previousShare;
if (previousBin === undefined) delete process.env.BAILIAN_BIN_DIR;
else process.env.BAILIAN_BIN_DIR = previousBin;
rmSync(root, { recursive: true, force: true });
});
}
function seedVersion(version: string): string {
const versionDir = join(getBinaryVersionsDir(), version);
mkdirSync(versionDir, { recursive: true });
const binaryName = process.platform === "win32" ? "bl.exe" : "bl";
writeFileSync(join(versionDir, binaryName), "fake-binary");
if (process.platform === "win32") {
writeFileSync(join(versionDir, "bailian.exe"), "fake-binary");
}
return versionDir;
}
test("share/bin roots respect BAILIAN_* overrides", async () => {
await withTempBinaryRoots(async () => {
expect(getBinaryShareRoot()).toContain("bl-binary-layout-");
expect(getBinaryBinRoot()).toBe(join(getBinaryShareRoot(), "path-bin"));
expect(getBinaryCurrentPath()).toBe(join(getBinaryShareRoot(), "current"));
});
});
test("switchCurrentToVersion retargets current pointer", async () => {
await withTempBinaryRoots(async () => {
const firstDir = seedVersion("1.0.0");
await switchCurrentToVersion("1.0.0");
expect(await readCurrentVersionDir()).toBe(firstDir);
const secondDir = seedVersion("1.1.0");
await switchCurrentToVersion("1.1.0");
expect(await readCurrentVersionDir()).toBe(secondDir);
});
});
test("pruneBinaryVersions keeps current and requested previous only", async () => {
await withTempBinaryRoots(async () => {
seedVersion("1.0.0");
seedVersion("1.1.0");
seedVersion("1.2.0");
await switchCurrentToVersion("1.2.0");
await pruneBinaryVersions(["1.2.0", "1.1.0"]);
expect(existsSync(join(getBinaryVersionsDir(), "1.2.0"))).toBe(true);
expect(existsSync(join(getBinaryVersionsDir(), "1.1.0"))).toBe(true);
expect(existsSync(join(getBinaryVersionsDir(), "1.0.0"))).toBe(false);
});
});
test("normalizeBinaryVersion strips release-style v prefix", () => {
expect(normalizeBinaryVersion("v1.2.3")).toBe("1.2.3");
expect(normalizeBinaryVersion("1.2.3")).toBe("1.2.3");
expect(normalizeBinaryVersion(" 0.1.14-channel.1 ")).toBe("0.1.14-channel.1");
});
test("isValidUpdateTargetVersion accepts semver and channel betas", () => {
expect(isValidUpdateTargetVersion("1.13.0")).toBe(true);
expect(isValidUpdateTargetVersion("v1.13.0")).toBe(true);
expect(isValidUpdateTargetVersion("1.4.2-beta.1")).toBe(true);
expect(isValidUpdateTargetVersion("0.0.0-beta-be3033b-202607311142")).toBe(true);
expect(isValidUpdateTargetVersion("v0.0.0-beta-be3033b-202607311142")).toBe(true);
expect(isValidUpdateTargetVersion("latest")).toBe(false);
expect(isValidUpdateTargetVersion("1.2")).toBe(false);
expect(isValidUpdateTargetVersion("foo")).toBe(false);
expect(isValidUpdateTargetVersion("")).toBe(false);
// Path-traversal / path-separator inputs must never reach versions/<ver>/
expect(isValidUpdateTargetVersion("../../../..")).toBe(false);
expect(isValidUpdateTargetVersion("..\\..\\..")).toBe(false);
expect(isValidUpdateTargetVersion("1.2.3/../x")).toBe(false);
expect(isValidUpdateTargetVersion("1.2.3\\..\\x")).toBe(false);
expect(isValidUpdateTargetVersion("/etc/passwd")).toBe(false);
expect(isValidUpdateTargetVersion("versions/../../tmp")).toBe(false);
expect(isValidUpdateTargetVersion("1.2.3/")).toBe(false);
expect(isValidUpdateTargetVersion("..")).toBe(false);
});
test("resolveBinaryDownloadSpec targets version assets, not latest manifest url", async () => {
const version = "0.1.14-channel.1";
const { detectBinaryPlatform } = await import("bailian-cli-core");
const { os, arch } = detectBinaryPlatform();
const zipName = binaryAssetFileName(version, os, arch);
const sumsSha = "deadbeef".repeat(8);
const previousFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/SHA256SUMS")) {
return new Response(`${sumsSha} ${zipName}\n`, { status: 200 });
}
if (url.endsWith("/manifest.json")) {
return new Response(
JSON.stringify({
version: "9.9.9",
assets: {
[`${os}-${arch}`]: {
file: `bl-9.9.9-${os}-${arch}.zip`,
sha256: "aa".repeat(32),
url: "https://example.invalid/wrong.zip",
},
},
}),
{ status: 200 },
);
}
return new Response("not found", { status: 404 });
}) as typeof fetch;
try {
const spec = await resolveBinaryDownloadSpec(`v${version}`);
expect(spec.zipName).toBe(zipName);
expect(spec.url).toBe(releaseAssetUrl(version, zipName));
expect(spec.url).not.toContain("wrong.zip");
expect(spec.expectedSha).toBe(sumsSha);
} finally {
globalThis.fetch = previousFetch;
}
});
test("ensureBinaryPathEntries wires PATH entries through current", async () => {
await withTempBinaryRoots(async () => {
seedVersion("2.0.0");
await switchCurrentToVersion("2.0.0");
await ensureBinaryPathEntries("2.0.0");
if (process.platform === "win32") {
const binRoot = getBinaryBinRoot();
const target = readlinkSync(binRoot);
expect(target.replaceAll("/", "\\").toLowerCase()).toBe(
getBinaryCurrentPath().replaceAll("/", "\\").toLowerCase(),
);
} else {
const blLink = readlinkSync(join(getBinaryBinRoot(), "bl"));
const bailianLink = readlinkSync(join(getBinaryBinRoot(), "bailian"));
expect(blLink).toBe(join(getBinaryCurrentPath(), "bl"));
expect(bailianLink).toBe(join(getBinaryCurrentPath(), "bl"));
}
});
});
test("ensureWindowsBinJunction migrates a real bin directory via rename", async () => {
await withTempBinaryRoots(async () => {
seedVersion("3.0.0");
await switchCurrentToVersion("3.0.0");
const binRoot = getBinaryBinRoot();
mkdirSync(binRoot, { recursive: true });
writeFileSync(join(binRoot, "bl.exe"), "old-copy");
await ensureWindowsBinJunction(binRoot);
const target = readlinkSync(binRoot);
expect(target.replaceAll("\\", "/").toLowerCase()).toBe(
getBinaryCurrentPath().replaceAll("\\", "/").toLowerCase(),
);
expect(existsSync(`${binRoot}.migrating.${process.pid}`)).toBe(false);
});
});
+13 -10
View File
@@ -58,6 +58,11 @@ fs.appendFileSync(
}) + "\\n",
);
if (args[0] === "--version") {
process.stdout.write("10.0.0\\n");
process.exit(0);
}
const manifestPath = path.join(cwd, "package.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
manifest.dependencies ??= {};
@@ -205,8 +210,9 @@ test("installs once on success and restores the previous version after validatio
env: Record<string, string | null>;
},
);
expect(successfulCalls).toHaveLength(1);
expect(successfulCalls[0]?.env).toEqual({
// runNpm probes `npm --version` before each install/uninstall.
expect(successfulCalls.map((call) => call.args[0])).toEqual(["--version", "install"]);
expect(successfulCalls[1]?.env).toEqual({
registry: "https://registry.example.test",
catalog: null,
recursive: null,
@@ -228,14 +234,11 @@ test("installs once on success and restores the previous version after validatio
env: Record<string, string | null>;
},
);
expect(rollbackCalls).toHaveLength(2);
expect(rollbackCalls[0]?.args.slice(0, 2)).toEqual([
"install",
"@ali/bailian-plugin-agent@broken",
]);
expect(rollbackCalls[1]?.args.slice(0, 2)).toEqual([
"install",
"@ali/bailian-plugin-agent@1.0.0",
expect(rollbackCalls.map((call) => call.args.slice(0, 2))).toEqual([
["--version"],
["install", "@ali/bailian-plugin-agent@broken"],
["--version"],
["install", "@ali/bailian-plugin-agent@1.0.0"],
]);
const manifest = JSON.parse(
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-cli
metadata:
version: "1.13.1"
version: "1.14.0"
requires:
bins: ["bl"]
description: >-
+1 -1
View File
@@ -60,7 +60,7 @@ Use this index for the skill-scoped quick index and global flags.
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
| `bl update` | Update the CLI to the latest version | [update.md](update.md) |
| `bl update` | Update the CLI to the latest or a specified version | [update.md](update.md) |
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) |
+15 -9
View File
@@ -7,26 +7,32 @@ Index: [index.md](index.md)
## Commands in this group
| Command | Description |
| ----------- | ------------------------------------ |
| `bl update` | Update the CLI to the latest version |
| Command | Description |
| ----------- | --------------------------------------------------- |
| `bl update` | Update the CLI to the latest or a specified version |
## Command details
### `bl update`
| Field | Value |
| --------------- | ------------------------------------ |
| **Name** | `update` |
| **Description** | Update the CLI to the latest version |
| **Usage** | `bl update` |
| Field | Value |
| --------------- | --------------------------------------------------- |
| **Name** | `update` |
| **Description** | Update the CLI to the latest or a specified version |
| **Usage** | `bl update [--to <version>]` |
#### Flags
_No command-specific flags._
| Flag | Type | Required | Description |
| ---------------- | ------ | -------- | ------------------------------------------------ |
| `--to <version>` | string | no | Install this exact version instead of the latest |
#### Examples
```bash
bl update
```
```bash
bl update --to 0.1.14
```
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-finetune
metadata:
version: "1.13.1"
version: "1.14.0"
requires:
bins: ["bl"]
description: >-
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-gen
metadata:
version: "1.13.1"
version: "1.14.0"
requires:
bins: ["bl"]
description: >-
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-managed-agent
metadata:
version: "1.13.1"
version: "1.14.0"
requires:
bins: ["bl"]
description: >-
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: bailian-protocol
metadata:
version: "1.13.1"
version: "1.14.0"
requires:
bins: ["bl"]
description: >-
+253
View File
@@ -0,0 +1,253 @@
/**
* Build standalone `bl` binaries with Bun --compile, then pack each as a
* per-platform `.zip` via binary-zip.mjs (Release / OSS download asset).
*
* 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 (fed into OSS manifest.json + latest.json)
* --mode channel always writes sync-release.json (npm --channel is dist-tag only)
*/
import { chmodSync, mkdirSync, 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 {
normalizeModeChannel,
rollingManifestChannelId,
rollingManifestFileName,
} from "./binary-options.mjs";
import { ensureZip, zipOne } from "./binary-zip.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 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 },
];
/** Uncompressed binary basename inside the zip: `bl-<ver>-<os>-<arch>[.exe]`. */
export function binaryInnerName(version, { os, arch, exe }) {
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
}
/** Release asset basename: `bl-<ver>-<os>-<arch>.zip`. */
export function binaryAssetName(version, { os, arch }) {
return `bl-${version}-${os}-${arch}.zip`;
}
/** Full matrix zip basenames for a version (order matches BINARY_TARGETS). */
export function matrixAssetNames(version) {
return BINARY_TARGETS.map((target) => binaryAssetName(version, target));
}
function log(message = "") {
process.stdout.write(`${message}\n`);
}
function writeJson(path, value) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
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,
}) {
const modeChannel = normalizeModeChannel(mode, channel);
return {
outdir: outdir ?? DEFAULT_OUTDIR,
onlyTarget,
hostOnly: Boolean(hostOnly),
...modeChannel,
};
}
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 compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) {
const innerName = binaryInnerName(version, { os, arch, exe });
const innerPath = join(outdir, innerName);
log(`compile ${bunTarget}${innerName}`);
const result = spawnSync(
"bun",
[BINARY_COMPILE, "--entry", entry, "--outfile", innerPath, "--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 compile failed for ${bunTarget}`);
}
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
// Bun 1.2.19 writes windows-x64 .exe with mode 000 on Unix hosts (oven-sh/bun#21308).
chmodSync(innerPath, 0o755);
return { innerName, innerPath, os, arch, exe };
}
function writeChecksums(outdir, artifacts) {
const lines = artifacts.map((item) => `${item.sha256} ${item.fileName}`);
writeFileSync(join(outdir, "SHA256SUMS"), `${lines.join("\n")}\n`);
}
/** Write the rolling channel manifest (`latest.json` / `sync-release.json`) with per-platform zip + sha256. */
function writeChannelManifest(outdir, version, artifacts, mode) {
const channel = rollingManifestChannelId(mode);
const assets = Object.fromEntries(
artifacts.map((item) => [
`${item.os}-${item.arch}`,
{
file: item.fileName,
sha256: item.sha256,
inner: item.innerName,
},
]),
);
const manifest = {
name: "bailian-cli",
channel,
version,
releasedAt: new Date().toISOString(),
assets,
};
const name = rollingManifestFileName(mode);
writeJson(join(outdir, name), manifest);
return name;
}
function cliVersion() {
return readPackageJson(PACKAGES.find((pkg) => pkg.key === "cli")).version;
}
/** Run `--version` on the host platform's uncompressed binary, if present. */
function smokeTestHostBinary(compiled, outdir) {
const hostOs = process.platform === "win32" ? "windows" : process.platform;
const host = compiled.find((item) => item.os === hostOs && item.arch === process.arch);
if (!host) return;
const binary = join(outdir, host.innerName);
log(`smoke test ${host.innerName} --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.innerName} --version`);
}
}
/** Compile binaries into `outdir`, zip per platform, write checksums (+ channel manifest). */
export function buildBinaryArtifacts(rawOptions = {}) {
const options = normalizeBuildOptions(rawOptions);
const { outdir, mode, channel } = options;
const bunVersion = ensureBun();
ensureZip();
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 compiled = targets.map((target) => compileOne(target, version, outdir, CLI_ENTRY));
smokeTestHostBinary(compiled, outdir);
const artifacts = compiled.map((item) =>
zipOne(item, { outdir, zipFileName: binaryAssetName(version, item), log }),
);
writeChecksums(outdir, artifacts);
const extras = ["SHA256SUMS"];
extras.push(writeChannelManifest(outdir, version, artifacts, mode));
log(`\nBuilt ${artifacts.length} zip(s):`);
for (const item of artifacts) {
log(` ${item.fileName} ${item.sha256.slice(0, 12)}… (inner ${item.innerName})`);
}
log(`Also wrote ${extras.join(", ")}`);
return {
version,
mode,
channel,
outdir,
artifacts,
manifests: extras.filter((name) => name.endsWith(".json")),
};
}
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);
}
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Single-target Bun compile helper. Must be run with Bun on PATH:
* bun tools/release/lib/binary-compile.mjs --entry <path> --outfile <path> --target <bun-target>
*
* Uses `bun build --compile` (CLI). The Bun.build({ compile }) API on 1.2.19
* can exit 0 without writing outfile; CI pins 1.2.19 so we stay on the CLI.
*
* Called by binary-build.mjs (Node orchestration stays on Node).
*/
import { existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
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 = spawnSync(
"bun",
[
"build",
entry,
"--compile",
"--outfile",
outfile,
"--target",
target,
"--define",
'process.env.BAILIAN_COMPILED="1"',
],
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
);
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
if (!existsSync(outfile)) {
console.error(`bun build --compile exited 0 but outfile missing: ${outfile}`);
process.exit(1);
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Shared mode / channel / manifest naming for binary-build and binary-release.
*
* Rolling CDN pointers (bailian-cli binary):
* - stable build writes `latest.json`; OSS maintains `manifest.json` (+ `latest.json`)
* - channel always `sync-release.json` on OSS / GH rolling release `channel-sync-release`
*
* The workflow `--channel` value remains the npm dist-tag (and versioned GH release notes).
* It does not choose the CDN rolling filename.
*/
import { assertChannel } from "./validate.mjs";
/** Official bailian-cli channel/verify rolling manifest name on CDN. */
export const SYNC_RELEASE_CHANNEL = "sync-release";
/** Stable build-local rolling manifest (fed into maintainReleaseManifest). */
export const STABLE_ROLLING_CHANNEL = "latest";
/**
* @param {string} mode
* @param {string | null | undefined} channel
* @returns {{ mode: "stable" | "channel", channel: string | null }}
*/
export function normalizeModeChannel(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 { mode, channel };
}
return { mode: "stable", channel: null };
}
/**
* Rolling-manifest basename for a logical channel id inside a build artifact.
* Prefer {@link rollingManifestFileName} for mode-aware naming.
*/
export function channelManifestFileName(channel) {
if (!channel) throw new Error("channelManifestFileName requires a channel name");
return `${channel}.json`;
}
/**
* Rolling manifest basename written by binary-build / uploaded to OSS root.
* - stable `latest.json`
* - channel always `sync-release.json` (ignores npm dist-tag name)
*
* @param {"stable" | "channel"} mode
* @returns {string}
*/
export function rollingManifestFileName(mode) {
if (mode === "stable") return channelManifestFileName(STABLE_ROLLING_CHANNEL);
if (mode === "channel") return channelManifestFileName(SYNC_RELEASE_CHANNEL);
throw new Error(`rollingManifestFileName: unknown mode ${mode}`);
}
/** Channel id embedded in the rolling manifest JSON body. */
export function rollingManifestChannelId(mode) {
if (mode === "stable") return STABLE_ROLLING_CHANNEL;
if (mode === "channel") return SYNC_RELEASE_CHANNEL;
throw new Error(`rollingManifestChannelId: unknown mode ${mode}`);
}
/** GitHub rolling prerelease tag that holds only the CDN channel pointer. */
export function rollingChannelReleaseTag(mode) {
if (mode !== "channel") {
throw new Error("rollingChannelReleaseTag is only valid for mode=channel");
}
return `channel-${SYNC_RELEASE_CHANNEL}`;
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vite-plus/test";
import {
channelManifestFileName,
normalizeModeChannel,
rollingChannelReleaseTag,
rollingManifestChannelId,
rollingManifestFileName,
STABLE_ROLLING_CHANNEL,
SYNC_RELEASE_CHANNEL,
} from "./binary-options.mjs";
describe("rolling CDN manifest naming", () => {
test("keeps sync-release and latest constants", () => {
expect(SYNC_RELEASE_CHANNEL).toBe("sync-release");
expect(STABLE_ROLLING_CHANNEL).toBe("latest");
});
test("channel mode always rolls sync-release.json regardless of npm dist-tag", () => {
const { mode, channel } = normalizeModeChannel("channel", "mcp");
expect(mode).toBe("channel");
expect(channel).toBe("mcp");
expect(rollingManifestFileName(mode)).toBe("sync-release.json");
expect(rollingManifestChannelId(mode)).toBe("sync-release");
expect(rollingChannelReleaseTag(mode)).toBe("channel-sync-release");
});
test("stable mode rolls latest.json for maintainReleaseManifest input", () => {
const { mode, channel } = normalizeModeChannel("stable", null);
expect(mode).toBe("stable");
expect(channel).toBeNull();
expect(rollingManifestFileName(mode)).toBe("latest.json");
expect(rollingManifestChannelId(mode)).toBe("latest");
});
test("channelManifestFileName still formats arbitrary names for helpers", () => {
expect(channelManifestFileName("release-test")).toBe("release-test.json");
expect(channelManifestFileName(SYNC_RELEASE_CHANNEL)).toBe("sync-release.json");
});
});
+295
View File
@@ -0,0 +1,295 @@
/**
* Publish bailian-cli binary assets to GitHub Releases.
*
* stable: release `v<version>` (tag must already be on origin; --verify-tag)
* assets: bl-*.zip, SHA256SUMS (no latest.json on the GH release)
* OSS: rewrite release/manifest.json + latest.json from build latest.json
* channel: versioned prerelease `v<betaVersion>` (assets: bl-*.zip, SHA256SUMS)
* + rolling prerelease `channel-sync-release` holding only sync-release.json
* OSS: always overwrite prefix-root sync-release.json
*
* Workflow `--channel` is the npm dist-tag (and versioned release notes); it does
* not choose the CDN rolling filename. Same commit/day channel publishes share one
* `v<betaVersion>` Release (identical binaries).
*
* Re-runs are idempotent via `gh release upload --clobber` (see gh-release.mjs).
* After the GitHub upload the same assets are pushed straight to OSS from the
* runner and HEAD-reconciled all in-process, no external FC (see oss-direct-upload.mjs).
*
* 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 { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs as parseCliArgs } from "node:util";
import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs";
import { buildBinaryArtifacts, matrixAssetNames } from "./binary-build.mjs";
import {
normalizeModeChannel,
rollingChannelReleaseTag,
rollingManifestFileName,
SYNC_RELEASE_CHANNEL,
} from "./binary-options.mjs";
import { ensureGh, GITHUB_REPOSITORY, upsertRelease } from "./gh-release.mjs";
import {
maintainReleaseManifest,
mirrorReleaseAssetsToOss,
syncStaticFilesToOss,
} from "./oss-direct-upload.mjs";
const DEFAULT_DIR = join(ROOT, "dist-bin");
/** 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 assertFullMatrix(files, version) {
const missing = matrixAssetNames(version).filter((name) => !files.includes(name));
if (missing.length > 0) {
throw new Error(
`Incomplete binary matrix in dist-bin (missing: ${missing.join(", ")}). ` +
`Rebuild the full matrix before upload (do not use --host / partial --target for release).`,
);
}
}
function versionBinaryAssets(dir, version, files) {
assertFullMatrix(files, version);
const matrixNames = new Set(matrixAssetNames(version));
return files
.filter((name) => matrixNames.has(name) || name === "SHA256SUMS")
.map((name) => join(dir, name));
}
function uploadStable({ dir, version, files, dryRun }) {
const section = extractChangelogSection(version);
upsertRelease({
tag: `v${version}`,
title: `v${version}`,
verifyTag: true,
notes: section || undefined,
assets: versionBinaryAssets(dir, version, files),
dryRun,
});
}
function uploadChannel({ dir, version, channel, files, dryRun }) {
// Versioned tag is shared across npm dist-tags built from the same beta version.
upsertRelease({
tag: `v${version}`,
title: `v${version}`,
prerelease: true,
notes: `Beta build (npm dist-tag \`${channel}\`). CDN rolling pointer: ${SYNC_RELEASE_CHANNEL}.json.`,
assets: versionBinaryAssets(dir, version, files),
dryRun,
});
const rollingTag = rollingChannelReleaseTag("channel");
upsertRelease({
tag: rollingTag,
title: `channel: ${SYNC_RELEASE_CHANNEL}`,
prerelease: true,
notes: `Rolling CDN manifest (${SYNC_RELEASE_CHANNEL}.json). Latest beta: ${version} (npm dist-tag \`${channel}\`).`,
assets: [join(dir, rollingManifestFileName("channel"))],
dryRun,
});
}
/** Dry-run path when dist-bin is absent: plan tags/assets without compiling. */
function planDryRunWithoutArtifacts({ version, mode, channel }) {
const matrix = matrixAssetNames(version);
if (mode === "stable") {
upsertRelease({
tag: `v${version}`,
title: `v${version}`,
verifyTag: true,
notes: extractChangelogSection(version) || undefined,
assets: [...matrix, "SHA256SUMS"],
dryRun: true,
});
return;
}
upsertRelease({
tag: `v${version}`,
title: `v${version}`,
prerelease: true,
notes: `Beta build (npm dist-tag \`${channel}\`). CDN rolling pointer: ${SYNC_RELEASE_CHANNEL}.json.`,
assets: [...matrix, "SHA256SUMS"],
dryRun: true,
});
upsertRelease({
tag: rollingChannelReleaseTag("channel"),
title: `channel: ${SYNC_RELEASE_CHANNEL}`,
prerelease: true,
notes: `Rolling CDN manifest (${SYNC_RELEASE_CHANNEL}.json). Latest beta: ${version} (npm dist-tag \`${channel}\`).`,
assets: [rollingManifestFileName("channel")],
dryRun: true,
});
}
/**
* Build the OSS mirror plan the same tag/asset pairs as the GitHub Release
* upload. `files == null` means dry-run planning without artifacts on disk,
* so bare basenames stand in for real paths.
*/
function ossMirrorPlans({ dir, version, mode, files }) {
const paths = files
? versionBinaryAssets(dir, version, files)
: [...matrixAssetNames(version), "SHA256SUMS"];
const plans = [{ tag: `v${version}`, paths }];
if (mode === "channel") {
const manifest = rollingManifestFileName("channel");
// Always sync-release.json at the OSS prefix root (next to manifest.json).
plans.push({ tag: "", paths: [files ? join(dir, manifest) : manifest] });
}
return plans;
}
/**
* Build (unless skipped / dry-run) and upload binary artifacts to GitHub Releases.
* Called by publish-stable / publish-channel orchestrators.
*
* `--dry-run` never compiles; it plans gh release steps. Prebuilt `dist-bin` is
* optional (used only to list real paths when present).
*/
export async function releaseBinaryArtifacts(rawOptions = {}) {
const { mode, channel } = normalizeModeChannel(rawOptions.mode, rawOptions.channel);
const dir = rawOptions.dir ? resolve(rawOptions.dir) : DEFAULT_DIR;
const dryRun = Boolean(rawOptions.dryRun);
const skipBuild = Boolean(rawOptions.skipBuild);
const cliPkg = readPackageJson(PACKAGES.find((pkg) => pkg.key === "cli"));
const version = cliPkg.version;
if (dryRun) {
process.stdout.write(
`\n[dry-run] skipping binary build (mode=${mode}${channel ? ` channel=${channel}` : ""})\n`,
);
} else if (!skipBuild) {
process.stdout.write(
`\n==> build binary (mode=${mode}${channel ? ` channel=${channel}` : ""})\n`,
);
buildBinaryArtifacts({ mode, channel, outdir: dir });
}
process.stdout.write(`repo ${GITHUB_REPOSITORY}\n`);
process.stdout.write(`version ${version}\n`);
process.stdout.write(`mode ${mode}${channel ? ` channel=${channel}` : ""}\n`);
if (dryRun && !existsSync(dir)) {
process.stdout.write(`[dry-run] ${dir} missing; planning expected assets\n`);
planDryRunWithoutArtifacts({ version, mode, channel });
const plans = ossMirrorPlans({ dir, version, mode, files: null });
await syncStaticFilesToOss({
filePaths: [join(ROOT, "CHANGELOG.md"), join(ROOT, "CHANGELOG.zh.md")],
dryRun: true,
});
await mirrorReleaseAssetsToOss({ plans, dryRun: true });
if (mode === "stable") {
await maintainReleaseManifest({
tag: `v${version}`,
channelJsonPath: null,
dryRun: true,
});
}
return { version, mode, channel, dryRun };
}
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("."));
if (!files.includes("SHA256SUMS")) {
throw new Error(`Missing SHA256SUMS in ${dir}`);
}
const rollingManifest = rollingManifestFileName(mode);
if (!files.includes(rollingManifest)) {
throw new Error(
`Missing ${rollingManifest} in ${dir}. Rebuild with matching --mode (found: ${files.join(", ") || "(empty)"}).`,
);
}
process.stdout.write(`artifacts in ${dir}:\n`);
for (const name of files) process.stdout.write(` ${name}\n`);
// Validate matrix before touching gh, so --skip-build mistakes fail without network/CLI.
assertFullMatrix(files, version);
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 });
}
// Sync changelogs (and other static files) to OSS before the binary mirror.
await syncStaticFilesToOss({
filePaths: [join(ROOT, "CHANGELOG.md"), join(ROOT, "CHANGELOG.zh.md")],
dryRun,
});
// Push the exact Release assets straight to OSS from the runner, then
// HEAD-reconcile. Stable releases additionally maintain release/manifest.json
// (newer-version guard). Throws on failure — CI is the only OSS writer.
const plans = ossMirrorPlans({ dir, version, mode, files });
const mirror = await mirrorReleaseAssetsToOss({ plans, dryRun });
if (mode === "stable" && !mirror.skipped) {
await maintainReleaseManifest({
tag: `v${version}`,
channelJsonPath: join(dir, rollingManifest),
dryRun,
});
}
return { version, mode, channel, dryRun };
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
const USAGE =
"Usage: node tools/release/lib/binary-release.mjs --mode stable|channel [--channel <name>] [--dir dist-bin] [--skip-build] [--dry-run]\n";
try {
const { values } = parseCliArgs({
args: process.argv.slice(2),
options: {
dir: { type: "string" },
"dry-run": { type: "boolean", default: false },
mode: { type: "string", default: "stable" },
channel: { type: "string" },
"skip-build": { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
},
allowPositionals: false,
});
if (values.help) {
process.stdout.write(USAGE);
process.exit(0);
}
await releaseBinaryArtifacts({
dir: values.dir ? resolve(values.dir) : undefined,
dryRun: values["dry-run"],
mode: values.mode,
channel: values.channel ?? null,
skipBuild: values["skip-build"],
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Pack a Bun-compiled binary into a per-platform `.zip` Release asset.
*
* Called by binary-build.mjs after compile + smoke test.
* Naming (`bl-<ver>-<os>-<arch>.zip`) stays in binary-build (contract source).
*/
import { createHash } from "node:crypto";
import { readFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
function defaultLog(message = "") {
process.stdout.write(`${message}\n`);
}
function sha256File(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
export function ensureZip() {
const result = spawnSync("zip", ["-h"], { encoding: "utf-8" });
if (result.error?.code === "ENOENT") {
throw new Error("zip not found on PATH. Install zip (e.g. apt-get install zip).");
}
}
/**
* Pack compiled binary into `zipFileName` under `outdir` and remove the raw file.
*
* @param {{ innerName: string, innerPath: string, os: string, arch: string }} compiled
* @param {{ outdir: string, zipFileName: string, log?: (message?: string) => void }} options
*/
export function zipOne(compiled, { outdir, zipFileName, log = defaultLog }) {
const zipPath = join(outdir, zipFileName);
log(`zip ${compiled.innerName}${zipFileName}`);
// -j: store basename only (no directory path inside the archive)
const result = spawnSync("zip", ["-j", "-q", zipFileName, compiled.innerName], {
cwd: outdir,
encoding: "utf-8",
});
if (result.status !== 0) {
process.stderr.write(result.stderr || result.stdout || "");
throw new Error(`zip failed for ${compiled.innerName}`);
}
unlinkSync(compiled.innerPath);
return {
fileName: zipFileName,
outfile: zipPath,
innerName: compiled.innerName,
os: compiled.os,
arch: compiled.arch,
sha256: sha256File(zipPath),
};
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Thin wrappers around `gh release` for create / clobber-upload / verify.
* Shared by binary-release (and any future publish path that needs GitHub Releases).
*/
import { basename } from "node:path";
import { run, runCapture, tryRun } from "./proc.mjs";
export const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || "modelstudioai/cli";
export function ensureGh() {
if (tryRun("gh", ["--version"]).status !== 0) {
throw new Error("gh CLI not found on PATH. Install from https://cli.github.com");
}
}
export function releaseExists(tag, repo = GITHUB_REPOSITORY) {
return tryRun("gh", ["release", "view", tag, "--repo", repo]).status === 0;
}
export function verifyReleaseAssets(tag, assetPaths, repo = GITHUB_REPOSITORY) {
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(", ")}`);
}
}
function printPlanned(tag, assets, extraArgs, repo) {
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.
*
* @param {{
* tag: string,
* title: string,
* prerelease?: boolean,
* verifyTag?: boolean,
* notes?: string,
* notesFile?: string,
* assets: string[],
* dryRun?: boolean,
* repo?: string,
* }} options
*/
export function upsertRelease({
tag,
title,
prerelease,
verifyTag,
notes,
notesFile,
assets,
dryRun,
repo = GITHUB_REPOSITORY,
}) {
const createArgs = ["--title", title];
if (prerelease) {
createArgs.push("--prerelease");
// Point the tag at the commit that produced the assets (workflow HEAD),
// not a hard-coded main tip that may diverge from a feature-branch build.
const target = process.env.GITHUB_SHA || "HEAD";
createArgs.push("--target", target);
}
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, repo);
return;
}
if (releaseExists(tag, repo)) {
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, repo);
}
+2 -2
View File
@@ -34,7 +34,7 @@ export function pushTag(tag, remote = "origin") {
run("git", ["push", remote, tag]);
}
/** UTC stamp for channel beta versions: `YYYYMMDDHHMM` (minute resolution). */
export function utcDateStamp() {
const iso = runCapture("date", ["-u", "+%Y%m%d"]);
return iso;
return runCapture("date", ["-u", "+%Y%m%d%H%M"]);
}
+484
View File
@@ -0,0 +1,484 @@
/**
* Publish binary release assets to OSS entirely from the CI runner:
* upload HEAD-reconcile byte sizes maintain release/manifest.json.
* No external FC is involved anymore; CI is the single writer.
*
* Flow:
* - Every mode uploads its assets to `<prefix>/<tag>/<basename>`; channel
* mode also uploads `sync-release.json` to the prefix root (empty tag).
* - After upload, every object is HEAD-verified against the local byte size
* (reconciliation the runner has the ground-truth artifacts on disk).
* - Stable only: when the tag is a NEWER version than the current manifest
* (compareVersions), rewrite `<prefix>/manifest.json` and the rolling
* `<prefix>/latest.json` both carry the SAME rolling-manifest body
* written by binary-build.mjs. Channel mode never touches those two files.
*
* Zero-dependency: OSS V1 header signature (HMAC-SHA1) over plain fetch.
*
* Gating / failure model:
* - BAILIAN_OSS_AK / BAILIAN_OSS_SK unset warn + no-op (npm/GitHub publish
* still succeed; set the secrets to enable the OSS channel).
* - Once enabled, any upload/reconcile/manifest failure THROWS and fails the
* release step re-running the workflow is idempotent (uploads overwrite).
*
* Environment variables (all injected from GitHub repo Settings Secrets;
* no OSS defaults are hardcoded in this repo):
* BAILIAN_OSS_AK / BAILIAN_OSS_SK RAM AccessKey; needs oss:PutObject and
* oss:GetObject on the release prefix
* BAILIAN_OSS_BUCKET / BAILIAN_OSS_REGION / BAILIAN_RELEASE_PREFIX
* required once the channel is enabled
* BAILIAN_STATIC_PREFIX prefix for static files (changelogs, etc.);
* same bucket/creds, separate namespace
* BAILIAN_OSS_ENDPOINT optional request endpoint override;
* public manifest URLs always use the
* region endpoint
*/
import { createHash, createHmac } from "node:crypto";
import { readFileSync, statSync } from "node:fs";
import { basename } from "node:path";
/**
* Resolve the OSS context from the environment. Returns null when the channel
* is disabled (no credentials). Throws when credentials are present but the
* non-credential configuration is incomplete a misconfigured release must
* fail loudly instead of uploading to a guessed location.
*/
function ossContext() {
const ak = process.env.BAILIAN_OSS_AK?.trim();
const sk = process.env.BAILIAN_OSS_SK?.trim();
if (!ak || !sk) return null;
const cfg = {
bucket: process.env.BAILIAN_OSS_BUCKET?.trim() || "",
region: process.env.BAILIAN_OSS_REGION?.trim() || "",
endpoint: process.env.BAILIAN_OSS_ENDPOINT?.trim() || "",
prefix: process.env.BAILIAN_RELEASE_PREFIX?.trim() || "",
};
const missing = [
["BAILIAN_OSS_BUCKET", cfg.bucket],
["BAILIAN_OSS_REGION", cfg.region],
["BAILIAN_RELEASE_PREFIX", cfg.prefix],
]
.filter(([, value]) => !value)
.map(([name]) => name);
if (missing.length > 0) {
throw new Error(`OSS channel misconfigured; missing env: ${missing.join(", ")}`);
}
return { creds: { ak, sk }, cfg };
}
/** Virtual-hosted-style request host: <bucket>.<endpoint-or-region>. */
function ossHost(cfg) {
return `${cfg.bucket}.${cfg.endpoint || `${cfg.region}.aliyuncs.com`}`;
}
function contentTypeFor(name) {
if (name.endsWith(".md")) return "text/markdown; charset=utf-8";
if (name.endsWith(".zip")) return "application/zip";
if (name.endsWith(".json")) return "application/json";
return "application/octet-stream";
}
/**
* Compare two version strings (strip a leading v/V, split on `.`, numeric
* per-segment; non-numeric / missing segments count as 0).
* @returns {number} 1 if a>b, -1 if a<b, 0 if equal
*/
export function compareVersions(a, b) {
const norm = (v) =>
String(v ?? "")
.trim()
.replace(/^[vV]/, "")
.split(".")
.map((s) => parseInt(s, 10) || 0);
const pa = norm(a);
const pb = norm(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const x = pa[i] ?? 0;
const y = pb[i] ?? 0;
if (x > y) return 1;
if (x < y) return -1;
}
return 0;
}
/**
* Signed OSS request (V1 header signature). Keys here are [A-Za-z0-9._/-] only,
* so no URL encoding is needed and the signed resource matches the request path.
*/
async function ossRequest(
method,
key,
{ creds, cfg, body = null, contentType = "", extraHeaders = {} },
) {
const date = new Date().toUTCString();
const contentMd5 = body ? createHash("md5").update(body).digest("base64") : "";
const canonical = `${method}\n${contentMd5}\n${contentType}\n${date}\n/${cfg.bucket}/${key}`;
const signature = createHmac("sha1", creds.sk).update(canonical).digest("base64");
const headers = { Date: date, Authorization: `OSS ${creds.ak}:${signature}`, ...extraHeaders };
if (contentType) headers["Content-Type"] = contentType;
if (contentMd5) headers["Content-MD5"] = contentMd5;
const options = { method, headers };
if (body) options.body = body;
return fetch(`https://${ossHost(cfg)}/${key}`, options);
}
async function putObject({ creds, cfg, key, body, contentType }) {
const res = await ossRequest("PUT", key, { creds, cfg, body, contentType });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`OSS PUT ${key} failed: HTTP ${res.status} ${text.slice(0, 200)}`);
}
}
/** PUT with exponential-backoff retries (runner → OSS can flake too). */
async function putWithRetry(params, attempts = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await putObject(params);
} catch (err) {
if (attempt >= attempts) throw err;
const delay = 1000 * 2 ** (attempt - 1);
process.stdout.write(
` [oss] retry ${attempt}/${attempts - 1} for ${params.key} in ${delay}ms (${err.message})\n`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
/**
* Remote object byte size; null when the object does not exist.
* Forces the identity encoding: for compressible types (e.g. JSON) OSS gzips
* the transfer and undici then strips the content-length header, which would
* otherwise read as a bogus size 0 here.
*/
async function headObjectSize(key, creds, cfg) {
const res = await ossRequest("HEAD", key, {
creds,
cfg,
extraHeaders: { "Accept-Encoding": "identity" },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`OSS HEAD ${key} failed: HTTP ${res.status}`);
const length = res.headers.get("content-length");
if (length == null) throw new Error(`OSS HEAD ${key} returned no content-length`);
return Number(length);
}
/** GET + parse a JSON object; null when missing or corrupt. */
async function getObjectJson(key, creds, cfg) {
const res = await ossRequest("GET", key, { creds, cfg });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`OSS GET ${key} failed: HTTP ${res.status}`);
try {
return await res.json();
} catch {
return null;
}
}
/**
* Run async task factories with a bounded concurrency pool.
* Returns results in the same order as the input tasks array.
* (Same contract as packages/commands/src/commands/skill/shared.ts)
*
* @template T
* @param {Array<() => Promise<T>>} tasks
* @param {number} limit
* @returns {Promise<T[]>}
*/
async function runWithConcurrency(tasks, limit) {
const results = Array.from({ length: tasks.length });
let nextIndex = 0;
async function worker() {
while (nextIndex < tasks.length) {
const currentIndex = nextIndex++;
results[currentIndex] = await tasks[currentIndex]();
}
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => worker());
await Promise.all(workers);
return results;
}
/**
* HEAD-reconcile: verify every uploaded object exists remotely with the same
* byte size as the local file. Runs HEAD requests concurrently.
*
* @param {Array<{ path: string, key: string }>} jobs
* @param {{ ak: string, sk: string }} creds
* @param {object} cfg
* @param {string} label Context for error messages (e.g. "release", "static-files")
*/
async function reconcileUploads(jobs, creds, cfg, label) {
const results = await runWithConcurrency(
jobs.map((job) => async () => {
const remote = await headObjectSize(job.key, creds, cfg);
const local = statSync(job.path).size;
if (remote !== local) {
return {
ok: false,
key: job.key,
error: `OSS ${label} reconcile mismatch for ${job.key}: local ${local}B vs remote ${remote ?? "missing"}`,
};
}
return { ok: true, key: job.key };
}),
4,
);
const mismatches = results.filter((result) => !result.ok);
if (mismatches.length > 0) {
throw new Error(mismatches.map((item) => item.error).join("\n"));
}
process.stdout.write(`${label} reconcile ok: ${jobs.length}/${jobs.length} object(s) verified\n`);
}
/**
* Upload release assets to OSS under `<prefix>/<tag>/<basename>`, then
* HEAD-reconcile every object against the local byte size.
* Throws on any upload or reconcile failure (CI is the only writer now).
*
* @param {{
* plans: Array<{ tag: string, paths: string[] }>,
* dryRun?: boolean,
* }} options `paths` may be bare basenames in dry-run planning mode.
* @returns {Promise<{ uploaded: number, skipped: boolean }>}
*/
export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) {
const ctx = ossContext();
if (!ctx) {
process.stdout.write(
"\n[warn] BAILIAN_OSS_AK/SK unset; skip the OSS release channel entirely\n",
);
return { uploaded: 0, skipped: true };
}
const { creds, cfg } = ctx;
// An empty tag means the object lives at the prefix root (rolling manifests).
const jobs = plans.flatMap(({ tag, paths }) =>
paths.map((path) => ({
path,
key: [cfg.prefix, tag, basename(path)].filter(Boolean).join("/"),
})),
);
if (jobs.length === 0) return { uploaded: 0, skipped: true };
process.stdout.write(
`\n==> OSS upload: ${jobs.length} object(s) → ${cfg.bucket} (${cfg.endpoint || cfg.region})\n`,
);
if (dryRun) {
for (const job of jobs) {
process.stdout.write(`[dry-run] PUT oss://${cfg.bucket}/${job.key}\n`);
}
process.stdout.write(`[dry-run] reconcile (HEAD size check) ${jobs.length} object(s)\n`);
return { uploaded: 0, skipped: false };
}
const results = await runWithConcurrency(
jobs.map((job) => async () => {
const startedAt = Date.now();
try {
const body = readFileSync(job.path);
await putWithRetry({
creds,
cfg,
key: job.key,
body,
contentType: contentTypeFor(job.key),
});
process.stdout.write(
` [oss] ok ${job.key} (${(body.length / 1024 / 1024).toFixed(1)}MB, ${Date.now() - startedAt}ms)\n`,
);
return { ok: true, key: job.key };
} catch (error) {
process.stdout.write(` [oss] FAIL ${job.key}: ${error.message}\n`);
return { ok: false, key: job.key, error: error.message };
}
}),
4,
);
const failed = results.filter((result) => !result.ok);
if (failed.length > 0) {
throw new Error(
`OSS upload failed for ${failed.length}/${jobs.length} object(s): ${failed
.map((item) => item.key)
.join(", ")}`,
);
}
await reconcileUploads(jobs, creds, cfg, "release");
return { uploaded: jobs.length, skipped: false };
}
/**
* Maintain the STABLE pointers at the prefix root: rewrite `manifest.json`
* and the rolling `latest.json` when `tag` is a newer version than the
* current manifest (first write included). Both objects carry the SAME
* rolling-manifest body produced by binary-build.mjs (`channelJsonPath`):
* `{ name, channel, version, releasedAt, assets: { "<os>-<arch>": { file, sha256, inner } } }`
* identical in shape to the channel `<channel>.json` manifests.
*
* @param {{
* tag: string,
* channelJsonPath?: string | null,
* dryRun?: boolean,
* }} options `channelJsonPath` is required outside dry-run.
* @returns {Promise<{ updated: boolean, latest: string | null }>}
*/
export async function maintainReleaseManifest({ tag, channelJsonPath = null, dryRun = false }) {
const ctx = ossContext();
if (!ctx) {
process.stdout.write("[info] BAILIAN_OSS_AK/SK unset; skip manifest.json maintenance\n");
return { updated: false, latest: null };
}
const { creds, cfg } = ctx;
const key = `${cfg.prefix}/manifest.json`;
if (dryRun) {
process.stdout.write(
`[dry-run] manifest: GET oss://${cfg.bucket}/${key} → rewrite manifest.json + latest.json from ${channelJsonPath ?? "<rolling manifest>"} when ${tag} > latest\n`,
);
return { updated: false, latest: null };
}
if (!channelJsonPath) {
throw new Error("maintainReleaseManifest requires channelJsonPath outside dry-run");
}
const current = await getObjectJson(key, creds, cfg);
// Rolling-manifest shape carries `version`; fall back to the legacy
// `{ latest }` pointer shape so the first migrated write still compares.
const currentLatest =
typeof current?.version === "string"
? current.version
: typeof current?.latest === "string"
? current.latest
: null;
const newer = currentLatest == null || compareVersions(tag, currentLatest) > 0;
if (!newer) {
process.stdout.write(`manifest unchanged: latest=${currentLatest} is not older than ${tag}\n`);
return { updated: false, latest: currentLatest };
}
const body = readFileSync(channelJsonPath);
await putObject({ creds, cfg, key, body, contentType: "application/json" });
process.stdout.write(`manifest.json → latest=${tag} (was ${currentLatest ?? "none"})\n`);
await putObject({
creds,
cfg,
key: `${cfg.prefix}/latest.json`,
body,
contentType: "application/json",
});
process.stdout.write(`latest.json → ${tag}\n`);
return { updated: true, latest: tag };
}
/**
* Resolve the OSS context for the static-files channel. Same bucket/creds as
* the release channel but uses BAILIAN_STATIC_PREFIX instead of
* BAILIAN_RELEASE_PREFIX. Returns null when credentials are absent (channel
* disabled); throws when creds exist but required config is incomplete.
*/
function staticOssContext() {
const ak = process.env.BAILIAN_OSS_AK?.trim();
const sk = process.env.BAILIAN_OSS_SK?.trim();
if (!ak || !sk) return null;
const cfg = {
bucket: process.env.BAILIAN_OSS_BUCKET?.trim() || "",
region: process.env.BAILIAN_OSS_REGION?.trim() || "",
endpoint: process.env.BAILIAN_OSS_ENDPOINT?.trim() || "",
prefix: process.env.BAILIAN_STATIC_PREFIX?.trim() || "",
};
const missing = [
["BAILIAN_OSS_BUCKET", cfg.bucket],
["BAILIAN_OSS_REGION", cfg.region],
["BAILIAN_STATIC_PREFIX", cfg.prefix],
]
.filter(([, value]) => !value)
.map(([name]) => name);
if (missing.length > 0) {
throw new Error(`OSS static-files channel misconfigured; missing env: ${missing.join(", ")}`);
}
return { creds: { ak, sk }, cfg };
}
/**
* Sync a list of local files to OSS under `<BAILIAN_STATIC_PREFIX>/<basename>`.
* Generic utility for any repo files that need to be mirrored to the static
* prefix (changelogs today; docs, banners, etc. in the future).
*
* Gating: BAILIAN_OSS_AK/SK unset warn + no-op. BAILIAN_STATIC_PREFIX unset
* (with creds present) throw (misconfiguration).
*
* @param {{
* filePaths: string[],
* dryRun?: boolean,
* }} options
* @returns {Promise<{ uploaded: number, skipped: boolean }>}
*/
export async function syncStaticFilesToOss({ filePaths, dryRun = false }) {
const ctx = staticOssContext();
if (!ctx) {
process.stdout.write("\n[warn] BAILIAN_OSS_AK/SK unset; skip static-files sync to OSS\n");
return { uploaded: 0, skipped: true };
}
const { creds, cfg } = ctx;
const jobs = filePaths.map((path) => ({
path,
key: `${cfg.prefix}/${basename(path)}`,
}));
if (jobs.length === 0) return { uploaded: 0, skipped: true };
process.stdout.write(
`\n==> OSS static-files sync: ${jobs.length} file(s) → ${cfg.bucket}/${cfg.prefix}/\n`,
);
if (dryRun) {
for (const job of jobs) {
process.stdout.write(`[dry-run] PUT oss://${cfg.bucket}/${job.key}\n`);
}
return { uploaded: 0, skipped: false };
}
const results = await runWithConcurrency(
jobs.map((job) => async () => {
const startedAt = Date.now();
try {
const body = readFileSync(job.path);
await putWithRetry({
creds,
cfg,
key: job.key,
body,
contentType: contentTypeFor(job.key),
});
process.stdout.write(
` [oss] ok ${job.key} (${(body.length / 1024).toFixed(1)}KB, ${Date.now() - startedAt}ms)\n`,
);
return { ok: true, key: job.key };
} catch (error) {
process.stdout.write(` [oss] FAIL ${job.key}: ${error.message}\n`);
return { ok: false, key: job.key, error: error.message };
}
}),
4,
);
const failed = results.filter((result) => !result.ok);
if (failed.length > 0) {
throw new Error(
`OSS static-files sync failed for ${failed.length}/${jobs.length} file(s): ${failed
.map((item) => item.key)
.join(", ")}`,
);
}
await reconcileUploads(jobs, creds, cfg, "static-files");
return { uploaded: jobs.length, skipped: false };
}
+10 -1
View File
@@ -54,7 +54,16 @@ export function loadAndValidatePackages({ packages } = {}) {
return { coreJson, cliJson };
}
const RESERVED_CHANNELS = new Set(["latest", "beta", "alpha", "next", "rc", "canary", "dev"]);
const RESERVED_CHANNELS = new Set([
"latest",
"manifest", // would collide with manifest.json at the OSS prefix root
"beta",
"alpha",
"next",
"rc",
"canary",
"dev",
]);
const CHANNEL_FORMAT = /^[a-z][a-z0-9-]{1,30}$/;
export function assertChannel(channel) {
+27 -5
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,16 @@ 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;
// knowledge-studio-cli channel publishes are npm-only: binary artifacts are `bl`
// and must not overwrite CDN sync-release.json used by bailian-cli install verify.
const skipBinary = values["skip-binary"] || knowledge;
const packages = knowledge ? ALL_PACKAGES : PACKAGES;
assertChannel(channel);
@@ -55,8 +60,8 @@ function restoreOriginals() {
try {
step("compute channel version");
const sha = headSha7();
const date = utcDateStamp();
const betaVersion = `0.0.0-beta-${sha}-${date}`;
const stamp = utcDateStamp(); // YYYYMMDDHHMM (UTC)
const betaVersion = `0.0.0-beta-${sha}-${stamp}`;
log(`channel=${channel} version=${betaVersion}`);
step("temporarily bump package.json (not committed)");
@@ -76,9 +81,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,9 +91,26 @@ try {
}
}
log(`\nchannel release complete: ${channel}@${betaVersion}`);
// 2) binary GitHub Release — must run before finally restores package.json versions.
// Channel binary always refreshes OSS sync-release.json (npm tag is independent).
if (skipBinary) {
const reason = knowledge
? "[knowledge] skipping binary (npm-only; does not touch sync-release.json)"
: "[skip-binary] skipping binary GitHub Release";
log(`\n${reason}`);
} else {
step(
`publish binary GitHub Release (mode=channel, npm-tag=${channel}, CDN=sync-release, version=${betaVersion})`,
);
await releaseBinaryArtifacts({ mode: "channel", channel, dryRun });
}
const parts = ["npm"];
if (!skipBinary) parts.push("binary/sync-release");
log(`\nchannel release complete: ${channel}@${betaVersion} (${parts.join(" + ")})`);
} catch (error) {
process.stderr.write(`\nrelease publish-channel failed: ${error.message}\n`);
// Use exitCode (not process.exit) so `finally` can restore package.json bumps.
process.exitCode = 1;
} finally {
restoreOriginals();
+18 -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,17 @@ 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 binary GitHub Release");
} else {
step(`publish binary GitHub Release (mode=stable, version=${version})`);
await releaseBinaryArtifacts({ mode: "stable", dryRun });
}
const parts = ["npm"];
if (!skipBinary) parts.push("binary");
log(`\nstable release complete (${parts.join(" + ")}).`);
} catch (error) {
process.stderr.write(`\nrelease publish-stable failed: ${error.message}\n`);
process.exit(1);