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