From 66a797203cd6d3d09cc7664f13ec5d5b66e1d7ec Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Thu, 23 Jul 2026 17:34:30 +0800 Subject: [PATCH 01/21] multi channel install test --- .github/workflows/publish.yml | 23 +- .gitignore | 1 + AGENTS.md | 33 +- INSTALL.md | 116 ++----- README.md | 10 +- README.zh.md | 10 +- docs/agents/binary-distribution.md | 63 ++++ docs/agents/publish.md | 38 ++- docs/proposals/binary-distribution.md | 171 ++++++++++ packages/cli/README.md | 10 +- packages/cli/README.zh.md | 10 +- packages/commands/src/commands/update.ts | 98 ++++-- packages/core/src/index.ts | 1 + packages/core/src/install/cdn.ts | 76 +++++ packages/core/src/install/index.ts | 18 + packages/core/src/install/method.ts | 76 +++++ packages/core/src/telemetry/env.ts | 7 +- packages/core/tests/install-method.test.ts | 29 ++ packages/runtime/src/command-packs/manager.ts | 14 +- packages/runtime/src/index.ts | 5 + packages/runtime/src/utils/binary-update.ts | 134 ++++++++ packages/runtime/src/utils/update-checker.ts | 57 ++-- packaging/install.ps1 | 78 +++++ packaging/install.sh | 134 ++++++++ tools/release/lib/binary-build.mjs | 239 +++++++++++++ tools/release/lib/binary-compile.mjs | 49 +++ tools/release/lib/binary-release.mjs | 313 ++++++++++++++++++ tools/release/publish-channel.mjs | 19 +- tools/release/publish-stable.mjs | 23 +- 29 files changed, 1677 insertions(+), 178 deletions(-) create mode 100644 docs/agents/binary-distribution.md create mode 100644 docs/proposals/binary-distribution.md create mode 100644 packages/core/src/install/cdn.ts create mode 100644 packages/core/src/install/index.ts create mode 100644 packages/core/src/install/method.ts create mode 100644 packages/core/tests/install-method.test.ts create mode 100644 packages/runtime/src/utils/binary-update.ts create mode 100644 packaging/install.ps1 create mode 100644 packaging/install.sh create mode 100644 tools/release/lib/binary-build.mjs create mode 100644 tools/release/lib/binary-compile.mjs create mode 100644 tools/release/lib/binary-release.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c45f8b4e..23a33a34 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 }}" diff --git a/.gitignore b/.gitignore index f1c88cb7..6b33a8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* # Dependencies & build output node_modules dist +dist-bin dist-ssr tools/generated .node-version diff --git a/AGENTS.md b/AGENTS.md index d26a7a51..7612e4de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,22 +56,23 @@ 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) | -| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | -| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | -| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | -| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | -| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | -| 配置项扩展 | 新 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) | -| 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) | +| 场景 | 何时进入 | 详见 | +| -------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | +| 命令增删改 | 增加 / 删除 / 重命名 `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) | +| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | +| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | +| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | +| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | +| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | +| 配置项扩展 | 新 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) | 如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/.md`,把清单沉淀下来。 diff --git a/INSTALL.md b/INSTALL.md index 7e31ec61..6136efd2 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。 - -1. **Node.js** - - 要求 **≥ 18.17.0**,执行 `node -v` 确认。 - - 未安装或版本不足:由 Agent 自行安装或升级 Node。 - -2. **npm**(**唯一**允许的包管理器) - - 执行 `npm -v` 确认可用。 - - 未安装或不可用:由 Agent **安装或修复 npm**(不要改用 pnpm/yarn 装 `bailian-cli`),再执行 `npm install -g bailian-cli`。 - - 即使本机已安装 `pnpm`、`yarn` 等,**也仅用于其它项目**;装 `bailian-cli` 时仍必须用 `npm`。 - -3. **网络** - - 需能访问 npm registry(默认 **registry.npmjs.org**)。 - - 不可达:由 Agent 自行配置镜像或代理后重试。 - ---- +```bash +curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash +``` -## 2. 安装 CLI 与 Skills +Windows PowerShell: -**仅允许以下命令**(不要用 `pnpm add -g`、`yarn global add` 等)。按顺序执行,上一步通过后再进行下一步。 +```powershell +irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex +``` -**2.1 安装 CLI** +校验: ```bash -npm install -g bailian-cli +bl --version +which bl # Windows: where.exe bl ``` -安装成功后,应能在 PATH 中找到: +若 `command not found`:把 `~/.local/bin`(Windows 为 `%LOCALAPPDATA%\bailian-cli\bin`)加入 PATH 后开新终端。 + +> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装。 -- `bl`(短别名) -- `bailian`(全名) +## 2. 备选:npm 安装(需要 Node.js >= 18.17) -**校验**(Agent 应执行并检查退出码与输出): +1. `node -v` 确认版本。 +2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn)。 +3. 执行: ```bash -bl --version -which bl # Windows 可用 where bl +npm install -g bailian-cli ``` -若 `command not found`:检查全局 bin 是否在 PATH(`npm config get prefix`,其下 `bin` 目录应加入 PATH)。 +4. 校验:`bl --version`。 -**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 调用所需的鉴权信息。 - -### 备选一:由 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 +### 备选:API Key / Token Plan -- 获取入口:[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 api-key` 亦可)。**不会**像 `bl auth login --api-key` 那样先校验 Key 是否可用;Agent 引导安装时仍**优先**用 `auth login`。 -- **命令行临时传入**:需要 API Key 的 `bl` 子命令可在**当次**执行附加全局 `--api-key `,仅本次生效、不落盘(例:`bl text chat --api-key sk-xxx --message "你好"`)。与上文持久化方式不是同一用途。 +- 普通 Key:`bl auth login --api-key ` +- Token Plan:`bl auth login --config token-plan --api-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 | diff --git a/README.md b/README.md index 5a8347b1..0bdd1b66 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh.md b/README.zh.md index ed4c1ac6..b726a9e0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -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` 长期保留。 ## 快速开始 diff --git a/docs/agents/binary-distribution.md b/docs/agents/binary-distribution.md new file mode 100644 index 00000000..066f7e95 --- /dev/null +++ b/docs/agents/binary-distribution.md @@ -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 为 `.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 | diff --git a/docs/agents/publish.md b/docs/agents/publish.md index f51de972..ab8bba6b 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -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-` | 选 mode=channel,填 dist-tag 名称(如 mcp/plugin) | +| stable | npm latest + GitHub Release `v`(含 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--` 版本号 → 临时 bump 对应包集合 → 自检 → 构建 → 发布到指定 dist-tag +2. CI 自动:生成 `0.0.0-beta--` → 临时 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`** → **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@ version`;如发布 `knowledge-studio-cli`,同时 `npm view knowledge-studio-cli@ version` - [ ] 试装一次:`npm i -g bailian-cli@ && bl --version`;如发布 `knowledge-studio-cli`,同时 `npm i -g knowledge-studio-cli@ && 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` 失败 | diff --git a/docs/proposals/binary-distribution.md b/docs/proposals/binary-distribution.md new file mode 100644 index 00000000..90861ed1 --- /dev/null +++ b/docs/proposals/binary-distribution.md @@ -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 → gh release(正式) + assets: bl-*, SHA256SUMS, latest.json + (不再把 install.sh/ps1 挂到 Release;生产脚本外置) + +channel: + bump beta → npm @channel → gh prerelease v + + 滚动 prerelease tag channel-(仅 .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`(先 push tag,再 `--verify-tag`) | 矩阵二进制、`SHA256SUMS`、`latest.json` | +| channel 版本化 | `v0.0.0-beta--`(prerelease) | 二进制、`SHA256SUMS` | +| channel 滚动 | `channel-`(prerelease,clobber) | `.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/.json # 可选测试渠道 + releases// + bl---[.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---[.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 +``` diff --git a/packages/cli/README.md b/packages/cli/README.md index 5a8347b1..0bdd1b66 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index ed4c1ac6..b726a9e0 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -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` 长期保留。 ## 快速开始 diff --git a/packages/commands/src/commands/update.ts b/packages/commands/src/commands/update.ts index a49554d5..53f2d591 100644 --- a/packages/commands/src/commands/update.ts +++ b/packages/commands/src/commands/update.ts @@ -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 { + 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; + } + + 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, label } = detectInstallCommand(npmPackage); - process.stderr.write(`Updating ${npmPackage} via ${label}...\n\n`); + 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(); - // ` --version` outputs " 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`); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e49ee6c4..5930cf61 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/install/cdn.ts b/packages/core/src/install/cdn.ts new file mode 100644 index 00000000..4ec9585b --- /dev/null +++ b/packages/core/src/install/cdn.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---[.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" : ""}`; +} diff --git a/packages/core/src/install/index.ts b/packages/core/src/install/index.ts new file mode 100644 index 00000000..87659025 --- /dev/null +++ b/packages/core/src/install/index.ts @@ -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"; diff --git a/packages/core/src/install/method.ts b/packages/core/src/install/method.ts new file mode 100644 index 00000000..55e22bfe --- /dev/null +++ b/packages/core/src/install/method.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(["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 */ + } +} diff --git a/packages/core/src/telemetry/env.ts b/packages/core/src/telemetry/env.ts index c449e9d4..d2772510 100644 --- a/packages/core/src/telemetry/env.ts +++ b/packages/core/src/telemetry/env.ts @@ -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; } diff --git a/packages/core/tests/install-method.test.ts b/packages/core/tests/install-method.test.ts new file mode 100644 index 00000000..57f866b1 --- /dev/null +++ b/packages/core/tests/install-method.test.ts @@ -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"); +}); diff --git a/packages/runtime/src/command-packs/manager.ts b/packages/runtime/src/command-packs/manager.ts index 223279b4..444f81af 100644 --- a/packages/runtime/src/command-packs/manager.ts +++ b/packages/runtime/src/command-packs/manager.ts @@ -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 { } async function runNpm(args: string[], cwd: string): Promise { + 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((resolvePromise, reject) => { const child = spawn("npm", args, { cwd, diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 4db579c1..80caa0f2 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -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, diff --git a/packages/runtime/src/utils/binary-update.ts b/packages/runtime/src/utils/binary-update.ts new file mode 100644 index 00000000..e107e4d6 --- /dev/null +++ b/packages/runtime/src/utils/binary-update.ts @@ -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; +} + +export async function fetchBinaryChannelVersion( + channel = "latest", + timeoutMs = 5000, +): Promise { + 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 { + 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 { + 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 { + 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; +} diff --git a/packages/runtime/src/utils/update-checker.ts b/packages/runtime/src/utils/update-checker.ts index c774a9f8..956d769a 100644 --- a/packages/runtime/src/utils/update-checker.ts +++ b/packages/runtime/src/utils/update-checker.ts @@ -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 }); diff --git a/packaging/install.ps1 b/packaging/install.ps1 new file mode 100644 index 00000000..07bea413 --- /dev/null +++ b/packaging/install.ps1 @@ -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 +} diff --git a/packaging/install.sh b/packaging/install.sh new file mode 100644 index 00000000..f3952b97 --- /dev/null +++ b/packaging/install.sh @@ -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 "$@" diff --git a/tools/release/lib/binary-build.mjs b/tools/release/lib/binary-build.mjs new file mode 100644 index 00000000..737c7978 --- /dev/null +++ b/tools/release/lib/binary-build.mjs @@ -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 .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 ] [--host] [--target ] [--outdir ]\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 "); + 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); + } +} diff --git a/tools/release/lib/binary-compile.mjs b/tools/release/lib/binary-compile.mjs new file mode 100644 index 00000000..92b3e065 --- /dev/null +++ b/tools/release/lib/binary-compile.mjs @@ -0,0 +1,49 @@ +/** + * Single-target `Bun.build({ compile })` helper. Must be run with Bun: + * bun tools/release/lib/binary-compile.mjs --entry --outfile --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 --outfile --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); +} diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs new file mode 100644 index 00000000..098fcc5e --- /dev/null +++ b/tools/release/lib/binary-release.mjs @@ -0,0 +1,313 @@ +/** + * Publish bailian-cli binary assets to GitHub Releases via the `gh` CLI. + * + * stable: release `v` (tag must already be on origin; --verify-tag) + * assets: bl-*, SHA256SUMS, latest.json + * channel: versioned prerelease `v` (assets: bl-*, SHA256SUMS) + * + rolling prerelease tag `channel-` holding only `.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 ] [--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 "); + 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 `## []` 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 \n`, + ); + process.stdout.write( + `[dry-run] missing → gh release create ${tag} --repo ${REPO} ${extraArgs.join(" ")} \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); + } +} diff --git a/tools/release/publish-channel.mjs b/tools/release/publish-channel.mjs index 990c7115..4f7d498a 100644 --- a/tools/release/publish-channel.mjs +++ b/tools/release/publish-channel.mjs @@ -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; diff --git a/tools/release/publish-stable.mjs b/tools/release/publish-stable.mjs index 69aaaceb..986ecefe 100644 --- a/tools/release/publish-stable.mjs +++ b/tools/release/publish-stable.mjs @@ -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); From 08bdc3be97764cf09e3991a84de5fac6c2262e67 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Thu, 23 Jul 2026 17:36:14 +0800 Subject: [PATCH 02/21] build: multi channel install test --- docs/proposals/binary-distribution.md | 171 -------------------------- packaging/install.ps1 | 78 ------------ packaging/install.sh | 134 -------------------- 3 files changed, 383 deletions(-) delete mode 100644 docs/proposals/binary-distribution.md delete mode 100644 packaging/install.ps1 delete mode 100644 packaging/install.sh diff --git a/docs/proposals/binary-distribution.md b/docs/proposals/binary-distribution.md deleted file mode 100644 index 90861ed1..00000000 --- a/docs/proposals/binary-distribution.md +++ /dev/null @@ -1,171 +0,0 @@ -# 技术方案: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 → gh release(正式) - assets: bl-*, SHA256SUMS, latest.json - (不再把 install.sh/ps1 挂到 Release;生产脚本外置) - -channel: - bump beta → npm @channel → gh prerelease v - + 滚动 prerelease tag channel-(仅 .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`(先 push tag,再 `--verify-tag`) | 矩阵二进制、`SHA256SUMS`、`latest.json` | -| channel 版本化 | `v0.0.0-beta--`(prerelease) | 二进制、`SHA256SUMS` | -| channel 滚动 | `channel-`(prerelease,clobber) | `.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/.json # 可选测试渠道 - releases// - bl---[.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---[.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 -``` diff --git a/packaging/install.ps1 b/packaging/install.ps1 deleted file mode 100644 index 07bea413..00000000 --- a/packaging/install.ps1 +++ /dev/null @@ -1,78 +0,0 @@ -# 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 -} diff --git a/packaging/install.sh b/packaging/install.sh deleted file mode 100644 index f3952b97..00000000 --- a/packaging/install.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/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 "$@" From 4bd84e934c49116a101257d5c57d5d4ee4217ead Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Thu, 23 Jul 2026 17:52:52 +0800 Subject: [PATCH 03/21] build: multi channel install test --- tools/release/lib/binary-compile.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/release/lib/binary-compile.mjs b/tools/release/lib/binary-compile.mjs index 92b3e065..250c70ae 100644 --- a/tools/release/lib/binary-compile.mjs +++ b/tools/release/lib/binary-compile.mjs @@ -32,6 +32,8 @@ const { entry, outfile, target } = parseArgs(process.argv.slice(2)); const result = await Bun.build({ entrypoints: [entry], + // Bundler defaults to "browser"; CLI needs Node/Bun builtins. + target: "bun", define: { "process.env.BAILIAN_COMPILED": JSON.stringify("1"), }, From 9fbd2e4ec6fee8158cd2f70fcd194a7b9e38287b Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Thu, 23 Jul 2026 18:12:42 +0800 Subject: [PATCH 04/21] build: multi channel install test --- .github/workflows/publish.yml | 4 +++ docs/agents/binary-distribution.md | 6 ++++ tools/release/lib/binary-build.mjs | 4 ++- tools/release/lib/binary-compile.mjs | 50 +++++++++++++++++++--------- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 23a33a34..0346059d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -57,6 +57,8 @@ jobs: - 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" @@ -97,6 +99,8 @@ jobs: - 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" diff --git a/docs/agents/binary-distribution.md b/docs/agents/binary-distribution.md index 066f7e95..c521def2 100644 --- a/docs/agents/binary-distribution.md +++ b/docs/agents/binary-distribution.md @@ -61,3 +61,9 @@ vp check | FC 未跑完用户就 curl | OSS 404 / 半包 | | 矩阵变更未通知脚本方 | 装错 arch / 永久失败 | | webhook 配错当发版失败 | 不应;webhook 失败只 warn | +| 用 `Bun.build({ compile })` 代替 CLI | Bun ≤1.2.19 可能 exit 0 但不写 outfile → `sha256` ENOENT | + +## 编译实现注意 + +- `binary-compile.mjs` 必须走 **`bun build --compile --outfile …`**,不要用 `Bun.build({ compile })`(CI 钉 `1.2.19` 时 API 会假成功)。 +- 编译后校验 outfile 存在再算 SHA256。 diff --git a/tools/release/lib/binary-build.mjs b/tools/release/lib/binary-build.mjs index 737c7978..d076aca7 100644 --- a/tools/release/lib/binary-build.mjs +++ b/tools/release/lib/binary-build.mjs @@ -150,8 +150,10 @@ function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) { ); if (result.status !== 0) { process.stderr.write(result.stderr || result.stdout || ""); - throw new Error(`Bun.build compile failed for ${bunTarget}`); + throw new Error(`Bun compile failed for ${bunTarget}`); } + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); return { fileName, outfile, os, arch, sha256: sha256File(outfile) }; } diff --git a/tools/release/lib/binary-compile.mjs b/tools/release/lib/binary-compile.mjs index 250c70ae..8cd288ca 100644 --- a/tools/release/lib/binary-compile.mjs +++ b/tools/release/lib/binary-compile.mjs @@ -1,9 +1,16 @@ /** - * Single-target `Bun.build({ compile })` helper. Must be run with Bun: + * Single-target Bun compile helper. Must be run with Bun on PATH: * bun tools/release/lib/binary-compile.mjs --entry --outfile --target * + * Uses `bun build --compile` (CLI), not `Bun.build({ compile })`. + * Bun ≤1.2.19's Build API can report success without writing `compile.outfile` + * (API support landed properly around 1.2.21). CLI works on the pinned CI version. + * * 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; @@ -30,22 +37,33 @@ function parseArgs(argv) { const { entry, outfile, target } = parseArgs(process.argv.slice(2)); -const result = await Bun.build({ - entrypoints: [entry], - // Bundler defaults to "browser"; CLI needs Node/Bun builtins. - target: "bun", - define: { - "process.env.BAILIAN_COMPILED": JSON.stringify("1"), - }, - compile: { - target, +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.success) { - for (const log of result.logs) { - console.error(log); - } +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}\n` + + `(Bun Build API compile.outfile is unreliable on some versions; this helper uses the CLI.)`, + ); process.exit(1); } From 168e2b5ccb52bd71a4ea9cdaba2e2a5027495173 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Thu, 23 Jul 2026 18:18:46 +0800 Subject: [PATCH 05/21] build: multi channel install test --- docs/agents/binary-distribution.md | 2 ++ tools/release/lib/binary-build.mjs | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/agents/binary-distribution.md b/docs/agents/binary-distribution.md index c521def2..6a7f0253 100644 --- a/docs/agents/binary-distribution.md +++ b/docs/agents/binary-distribution.md @@ -62,8 +62,10 @@ vp check | 矩阵变更未通知脚本方 | 装错 arch / 永久失败 | | webhook 配错当发版失败 | 不应;webhook 失败只 warn | | 用 `Bun.build({ compile })` 代替 CLI | Bun ≤1.2.19 可能 exit 0 但不写 outfile → `sha256` ENOENT | +| 编译后未 `chmod` windows `.exe` | Bun 1.2.19 在 Unix 上写出 mode `000` → `sha256` / upload `EACCES` | ## 编译实现注意 - `binary-compile.mjs` 必须走 **`bun build --compile --outfile …`**,不要用 `Bun.build({ compile })`(CI 钉 `1.2.19` 时 API 会假成功)。 - 编译后校验 outfile 存在再算 SHA256。 +- 每个产物在哈希前 `chmod 0755`(规避 Bun 1.2.19 windows cross-compile 无权限,见 oven-sh/bun#21308)。 diff --git a/tools/release/lib/binary-build.mjs b/tools/release/lib/binary-build.mjs index d076aca7..030b1180 100644 --- a/tools/release/lib/binary-build.mjs +++ b/tools/release/lib/binary-build.mjs @@ -10,7 +10,7 @@ * --mode channel → writes .json only (does not touch latest.json) */ import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; @@ -154,6 +154,9 @@ function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) { } 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). + // chmod so sha256 / gh upload can open the file; harmless for other targets. + chmodSync(outfile, 0o755); return { fileName, outfile, os, arch, sha256: sha256File(outfile) }; } From 7b949d3d3c64cd6a59c9df3ddd46f2739bc42a45 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Fri, 24 Jul 2026 10:34:39 +0800 Subject: [PATCH 06/21] fix(release): fix binary CI publish and clarify release modules Stabilize Bun compile on 1.2.19, align manifests with OSS consumers, and split gh / webhook / mode helpers out of binary-release. --- docs/agents/binary-distribution.md | 33 ++- docs/agents/publish.md | 7 +- tools/release/lib/binary-build.mjs | 59 +++-- tools/release/lib/binary-compile.mjs | 10 +- tools/release/lib/binary-options.mjs | 29 +++ tools/release/lib/binary-release.mjs | 309 ++++++++++--------------- tools/release/lib/gh-release.mjs | 95 ++++++++ tools/release/lib/oss-sync-webhook.mjs | 63 +++++ tools/release/publish-channel.mjs | 7 +- tools/release/publish-stable.mjs | 6 +- 10 files changed, 372 insertions(+), 246 deletions(-) create mode 100644 tools/release/lib/binary-options.mjs create mode 100644 tools/release/lib/gh-release.mjs create mode 100644 tools/release/lib/oss-sync-webhook.mjs diff --git a/docs/agents/binary-distribution.md b/docs/agents/binary-distribution.md index 6a7f0253..27159fb6 100644 --- a/docs/agents/binary-distribution.md +++ b/docs/agents/binary-distribution.md @@ -31,8 +31,9 @@ Publish workflow ### A. 本仓库构建 / Release - [ ] `node tools/release/lib/binary-build.mjs --mode stable --host` -- [ ] `dist-bin/` 含矩阵二进制、`SHA256SUMS`、`latest.json`(channel 为 `.json`) -- [ ] dry-run:`node tools/release/lib/binary-release.mjs --mode stable --skip-build --dry-run` +- [ ] `dist-bin/` 含**完整**矩阵二进制、`SHA256SUMS`、`latest.json`(channel 为 `.json`) +- [ ] manifest asset 只有 `file` + `sha256`(无硬编码 `url`;客户端按 OSS `{base}/releases/{version}/{file}` 拼) +- [ ] dry-run:`node tools/release/lib/binary-release.mjs --mode stable --dry-run`(不编译) - [ ] Release **不含** 生产 install 脚本 ### B. 仓外(联调时确认) @@ -53,19 +54,31 @@ node tools/release/lib/binary-release.mjs --mode stable --skip-build --dry-run vp check ``` +实现分层(均在 `tools/release/lib/`): + +- `binary-build.mjs` / `binary-compile.mjs` — 编译 + manifest +- `binary-options.mjs` — 共享 `--mode` / `--channel` 校验 +- `binary-release.mjs` — 编排(stable/channel 上传哪些资产) +- `gh-release.mjs` — `gh release` create / clobber / verify +- `oss-sync-webhook.mjs` — 可选 FC 通知 + ## 常见漏点 -| 漏点 | 后果 | -| ---------------------- | ------------------------- | -| 只发 npm、未建 Release | FC 无源可同步 | -| FC 未跑完用户就 curl | OSS 404 / 半包 | -| 矩阵变更未通知脚本方 | 装错 arch / 永久失败 | -| webhook 配错当发版失败 | 不应;webhook 失败只 warn | -| 用 `Bun.build({ compile })` 代替 CLI | Bun ≤1.2.19 可能 exit 0 但不写 outfile → `sha256` ENOENT | -| 编译后未 `chmod` windows `.exe` | Bun 1.2.19 在 Unix 上写出 mode `000` → `sha256` / upload `EACCES` | +| 漏点 | 后果 | +| ------------------------------------ | ----------------------------------------------------------------- | +| 只发 npm、未建 Release | FC 无源可同步 | +| FC 未跑完用户就 curl | OSS 404 / 半包 | +| 矩阵变更未通知脚本方 | 装错 arch / 永久失败 | +| webhook 配错当发版失败 | 不应;webhook 失败只 warn | +| 用 `Bun.build({ compile })` 代替 CLI | Bun ≤1.2.19 可能 exit 0 但不写 outfile → `sha256` ENOENT | +| 编译后未 `chmod` windows `.exe` | Bun 1.2.19 在 Unix 上写出 mode `000` → `sha256` / upload `EACCES` | +| manifest 写死 GitHub `url` | FC 同步后 `bl update` 仍打 GitHub,绕开 OSS | +| `--dry-run` 仍全量 compile | 本地验证极慢;dry-run 应只规划 gh / webhook | +| 用 `--host` 产物去 upload | 半包上架;release 路径会校验完整矩阵 | ## 编译实现注意 - `binary-compile.mjs` 必须走 **`bun build --compile --outfile …`**,不要用 `Bun.build({ compile })`(CI 钉 `1.2.19` 时 API 会假成功)。 - 编译后校验 outfile 存在再算 SHA256。 - 每个产物在哈希前 `chmod 0755`(规避 Bun 1.2.19 windows cross-compile 无权限,见 oven-sh/bun#21308)。 +- channel 同日同 commit 共用一个 `v0.0.0-beta-…` Release;滚动 tag `channel-` 只挂 `.json`。 diff --git a/docs/agents/publish.md b/docs/agents/publish.md index ab8bba6b..f6b85d7a 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -16,10 +16,13 @@ ```text publish-stable.mjs / publish-channel.mjs ← 唯一发版入口 ├─ npm(pnpm publish) - └─ binary(lib/binary-release → lib/binary-build + gh release) + └─ binary(lib/binary-release + → binary-build + → gh-release + → oss-sync-webhook) ``` -`tools/release/lib/binary-release.mjs` / `binary-build.mjs` 是实现,一般不要单独当发版入口(调试可用)。详细约定见 [binary-distribution 方案](../proposals/binary-distribution.md)。 +`tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。详细约定见 [binary-distribution](binary-distribution.md)。 两种模式: diff --git a/tools/release/lib/binary-build.mjs b/tools/release/lib/binary-build.mjs index 030b1180..e5dee421 100644 --- a/tools/release/lib/binary-build.mjs +++ b/tools/release/lib/binary-build.mjs @@ -16,12 +16,11 @@ 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"; +import { manifestFileName, normalizeModeChannel } from "./binary-options.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 ] [--host] [--target ] [--outdir ]\n"; @@ -33,6 +32,16 @@ export const BINARY_TARGETS = [ { bunTarget: "bun-windows-x64", os: "windows", arch: "x64", exe: true }, ]; +/** Asset basename for a matrix row: `bl---[.exe]`. */ +export function binaryAssetName(version, { os, arch, exe }) { + return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`; +} + +/** Full matrix 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`); } @@ -41,10 +50,6 @@ 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, @@ -78,22 +83,12 @@ function normalizeBuildOptions({ 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 "); - assertChannel(channel); - if (channel === "stable") { - throw new Error(`--channel cannot be "stable"; use --mode stable`); - } - } + const modeChannel = normalizeModeChannel(mode, channel); return { outdir: outdir ?? DEFAULT_OUTDIR, onlyTarget, hostOnly: Boolean(hostOnly), - mode, - channel: mode === "channel" ? channel : null, + ...modeChannel, }; } @@ -133,16 +128,12 @@ 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 fileName = binaryAssetName(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. + // binary-compile.mjs shells out to `bun build --compile` (CLI); this file stays Node. const result = spawnSync( "bun", [BINARY_COMPILE, "--entry", entry, "--outfile", outfile, "--target", bunTarget], @@ -165,15 +156,19 @@ function writeChecksums(outdir, artifacts) { writeFileSync(join(outdir, "SHA256SUMS"), `${lines.join("\n")}\n`); } -function writeChannelManifest(outdir, version, artifacts, mode, channel) { - const base = cdnBase(); +/** + * Write latest.json (stable) or .json (channel). + * Asset entries carry file + sha256 only — no baked download URL. + * Consumers (bl update / install scripts) resolve via BAILIAN_CLI_CDN + + * `{base}/releases/{version}/{file}` (see packages/core releaseAssetUrl). + */ +function writeManifest(outdir, version, artifacts, mode, channel) { const assets = Object.fromEntries( artifacts.map((item) => [ `${item.os}-${item.arch}`, { file: item.fileName, sha256: item.sha256, - url: `${base}/download/v${version}/${item.fileName}`, }, ]), ); @@ -184,9 +179,9 @@ function writeChannelManifest(outdir, version, artifacts, mode, channel) { 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; + const name = manifestFileName(mode, channel); + writeJson(join(outdir, name), manifest); + return [name]; } function cliVersion() { @@ -207,7 +202,7 @@ function smokeTestHostBinary(artifacts, outdir) { } } -/** Compile binaries into `outdir` and write checksums + channel manifest. */ +/** Compile binaries into `outdir` and write checksums + manifest. */ export function buildBinaryArtifacts(rawOptions = {}) { const options = normalizeBuildOptions(rawOptions); const { outdir, mode, channel } = options; @@ -223,7 +218,7 @@ export function buildBinaryArtifacts(rawOptions = {}) { const artifacts = targets.map((target) => compileOne(target, version, outdir, CLI_ENTRY)); writeChecksums(outdir, artifacts); - const manifests = writeChannelManifest(outdir, version, artifacts, mode, channel); + const manifests = writeManifest(outdir, version, artifacts, mode, channel); smokeTestHostBinary(artifacts, outdir); log(`\nBuilt ${artifacts.length} binary(ies):`); diff --git a/tools/release/lib/binary-compile.mjs b/tools/release/lib/binary-compile.mjs index 8cd288ca..778f4a1d 100644 --- a/tools/release/lib/binary-compile.mjs +++ b/tools/release/lib/binary-compile.mjs @@ -2,9 +2,8 @@ * Single-target Bun compile helper. Must be run with Bun on PATH: * bun tools/release/lib/binary-compile.mjs --entry --outfile --target * - * Uses `bun build --compile` (CLI), not `Bun.build({ compile })`. - * Bun ≤1.2.19's Build API can report success without writing `compile.outfile` - * (API support landed properly around 1.2.21). CLI works on the pinned CI version. + * 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). */ @@ -61,9 +60,6 @@ if (result.status !== 0) { } if (!existsSync(outfile)) { - console.error( - `bun build --compile exited 0 but outfile missing: ${outfile}\n` + - `(Bun Build API compile.outfile is unreliable on some versions; this helper uses the CLI.)`, - ); + console.error(`bun build --compile exited 0 but outfile missing: ${outfile}`); process.exit(1); } diff --git a/tools/release/lib/binary-options.mjs b/tools/release/lib/binary-options.mjs new file mode 100644 index 00000000..59457e0f --- /dev/null +++ b/tools/release/lib/binary-options.mjs @@ -0,0 +1,29 @@ +/** + * Shared mode / channel / manifest naming for binary-build and binary-release. + */ +import { assertChannel } from "./validate.mjs"; + +/** + * @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 "); + assertChannel(channel); + if (channel === "stable") { + throw new Error(`--channel cannot be "stable"; use --mode stable`); + } + return { mode, channel }; + } + return { mode: "stable", channel: null }; +} + +/** Manifest basename written to dist-bin / uploaded to Releases. */ +export function manifestFileName(mode, channel) { + return mode === "stable" ? "latest.json" : `${channel}.json`; +} diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index 098fcc5e..624523a8 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -1,13 +1,16 @@ /** - * Publish bailian-cli binary assets to GitHub Releases via the `gh` CLI. + * Publish bailian-cli binary assets to GitHub Releases. * * stable: release `v` (tag must already be on origin; --verify-tag) * assets: bl-*, SHA256SUMS, latest.json * channel: versioned prerelease `v` (assets: bl-*, SHA256SUMS) * + rolling prerelease tag `channel-` holding only `.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. + * Same commit/day channel publishes share one `v` Release (identical + * binaries); only the rolling `channel-` manifest differs per channel. + * + * Re-runs are idempotent via `gh release upload --clobber` (see gh-release.mjs). + * Optionally notifies BAILIAN_OSS_SYNC_WEBHOOK (see oss-sync-webhook.mjs). * * Called by publish-stable.mjs / publish-channel.mjs. * Debug: @@ -15,90 +18,16 @@ * 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 { 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 { 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 ] [--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 "); - 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; -} +import { buildBinaryArtifacts, matrixAssetNames } from "./binary-build.mjs"; +import { manifestFileName, normalizeModeChannel } from "./binary-options.mjs"; +import { ensureGh, GITHUB_REPOSITORY, upsertRelease } from "./gh-release.mjs"; +import { notifyOssSyncWebhook } from "./oss-sync-webhook.mjs"; -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(", ")}`); - } -} +const DEFAULT_DIR = join(ROOT, "dist-bin"); /** Extract the `## []` section from CHANGELOG.md, or null when absent. */ function extractChangelogSection(version) { @@ -111,62 +40,34 @@ function extractChangelogSection(version) { 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 \n`, - ); - process.stdout.write( - `[dry-run] missing → gh release create ${tag} --repo ${REPO} ${extraArgs.join(" ")} \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; +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).`, + ); } +} - 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 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 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 assets = [ + ...versionBinaryAssets(dir, version, files), + join(dir, manifestFileName("stable", null)), + ]; const section = extractChangelogSection(version); upsertRelease({ - tag, - title: tag, + tag: `v${version}`, + title: `v${version}`, verifyTag: true, notes: section || undefined, assets, @@ -175,20 +76,13 @@ function uploadStable({ dir, version, files, 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)); + // Versioned tag is shared across channels built from the same beta version string. upsertRelease({ tag: `v${version}`, title: `v${version}`, prerelease: true, notes: `Beta build for the \`${channel}\` channel.`, - assets: binaries, + assets: versionBinaryAssets(dir, version, files), dryRun, }); @@ -202,67 +96,76 @@ function uploadChannel({ dir, version, channel, files, 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`, - ); +/** Dry-run path when dist-bin is absent: plan tags/assets without compiling. */ +function planDryRunWithoutArtifacts({ version, mode, channel }) { + const matrix = matrixAssetNames(version); + const manifestName = manifestFileName(mode, channel); + if (mode === "stable") { + upsertRelease({ + tag: `v${version}`, + title: `v${version}`, + verifyTag: true, + notes: extractChangelogSection(version) || undefined, + assets: [...matrix, "SHA256SUMS", manifestName], + dryRun: true, + }); return; } - if (result.stdout) process.stdout.write(`${result.stdout}\n`); + upsertRelease({ + tag: `v${version}`, + title: `v${version}`, + prerelease: true, + notes: `Beta build for the \`${channel}\` channel.`, + assets: [...matrix, "SHA256SUMS"], + dryRun: true, + }); + upsertRelease({ + tag: `channel-${channel}`, + title: `channel: ${channel}`, + prerelease: true, + notes: `Rolling manifest for the \`${channel}\` channel. Latest beta: ${version}.`, + assets: [manifestName], + dryRun: true, + }); } /** - * Build (unless skipped) and upload binary artifacts to GitHub Releases. + * 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 function releaseBinaryArtifacts(rawOptions) { - const { dir, dryRun, mode, channel, skipBuild } = normalizeOptions(rawOptions); +export 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 (!skipBuild) { + 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 }); + notifyOssSyncWebhook({ version, mode, channel, dryRun }); + 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}` : ""}).`, @@ -270,7 +173,7 @@ export function releaseBinaryArtifacts(rawOptions) { } const files = readdirSync(dir).filter((name) => !name.startsWith(".")); - const manifestName = requiredManifestName(mode, channel); + const manifestName = manifestFileName(mode, channel); if (!files.includes(manifestName)) { throw new Error( `Missing ${manifestName} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`, @@ -280,12 +183,12 @@ export function releaseBinaryArtifacts(rawOptions) { 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`); + // 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 { @@ -304,8 +207,32 @@ export function releaseBinaryArtifacts(rawOptions) { } if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { + const USAGE = + "Usage: node tools/release/lib/binary-release.mjs --mode stable|channel [--channel ] [--dir dist-bin] [--skip-build] [--dry-run]\n"; try { - releaseBinaryArtifacts(parseArgs(process.argv.slice(2))); + 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); + } + 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); diff --git a/tools/release/lib/gh-release.mjs b/tools/release/lib/gh-release.mjs new file mode 100644 index 00000000..bd655535 --- /dev/null +++ b/tools/release/lib/gh-release.mjs @@ -0,0 +1,95 @@ +/** + * 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 \n`, + ); + process.stdout.write( + `[dry-run] missing → gh release create ${tag} --repo ${repo} ${extraArgs.join(" ")} \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", "--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, 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); +} diff --git a/tools/release/lib/oss-sync-webhook.mjs b/tools/release/lib/oss-sync-webhook.mjs new file mode 100644 index 00000000..ba9f8149 --- /dev/null +++ b/tools/release/lib/oss-sync-webhook.mjs @@ -0,0 +1,63 @@ +/** + * Optional hook for an external FC that mirrors GitHub Releases → OSS. + * Set BAILIAN_OSS_SYNC_WEBHOOK to an HTTP endpoint; unset → no-op. + * Failure is warn-only — Release publish already succeeded. + */ +import { tryRun } from "./proc.mjs"; +import { GITHUB_REPOSITORY } from "./gh-release.mjs"; + +/** + * @param {{ + * version: string, + * mode: "stable" | "channel", + * channel: string | null, + * dryRun?: boolean, + * repo?: string, + * }} options + */ +export function notifyOssSyncWebhook({ + version, + mode, + channel, + dryRun = false, + repo = GITHUB_REPOSITORY, +}) { + 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 = `v${version}`; + const body = { + 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`); +} diff --git a/tools/release/publish-channel.mjs b/tools/release/publish-channel.mjs index 4f7d498a..420271ef 100644 --- a/tools/release/publish-channel.mjs +++ b/tools/release/publish-channel.mjs @@ -91,7 +91,7 @@ try { // 2) binary GitHub Release — must run before finally restores package.json versions if (skipBinary) { - log("\n[skip-binary] skipping Bun binary build/upload"); + log("\n[skip-binary] skipping binary GitHub Release"); } else { step( `publish binary GitHub Release (mode=channel, channel=${channel}, version=${betaVersion})`, @@ -99,9 +99,12 @@ try { releaseBinaryArtifacts({ mode: "channel", channel, dryRun }); } - log(`\nchannel release complete: ${channel}@${betaVersion} (npm + binary)`); + const parts = ["npm"]; + if (!skipBinary) parts.push("binary"); + 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(); diff --git a/tools/release/publish-stable.mjs b/tools/release/publish-stable.mjs index 986ecefe..40fa2cb6 100644 --- a/tools/release/publish-stable.mjs +++ b/tools/release/publish-stable.mjs @@ -83,13 +83,15 @@ try { // 3) binary GitHub Release (same version; orchestrated here, not a separate release entry) if (skipBinary) { - log("\n[skip-binary] skipping Bun binary build/upload"); + log("\n[skip-binary] skipping binary GitHub Release"); } else { step(`publish binary GitHub Release (mode=stable, version=${version})`); releaseBinaryArtifacts({ mode: "stable", dryRun }); } - log("\nstable release complete (npm + binary)."); + 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); From a1a448c5d2a046983498c3835275e9dc406ac03b Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Fri, 24 Jul 2026 10:35:46 +0800 Subject: [PATCH 07/21] fix(release): fix binary CI publish and clarify release modules Stabilize Bun compile on 1.2.19, align manifests with OSS consumers, and split gh / webhook / mode helpers out of binary-release. --- tools/release/lib/binary-release.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index 624523a8..84304ef7 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -91,7 +91,7 @@ function uploadChannel({ dir, version, channel, files, dryRun }) { title: `channel: ${channel}`, prerelease: true, notes: `Rolling manifest for the \`${channel}\` channel. Latest beta: ${version}.`, - assets: [join(dir, `${channel}.json`)], + assets: [join(dir, manifestFileName("channel", channel))], dryRun, }); } From d30fb2ae6866a0acc63f424748a1eea8f886b1ae Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Fri, 24 Jul 2026 15:33:15 +0800 Subject: [PATCH 08/21] feat(release): distribute binaries as per-platform zips --- .github/workflows/publish.yml | 14 ++- docs/agents/binary-distribution.md | 84 --------------- docs/agents/publish.md | 2 +- packages/core/src/install/cdn.ts | 14 ++- packages/core/src/install/index.ts | 2 + packages/core/src/install/unzip-asset.ts | 109 ++++++++++++++++++++ packages/core/tests/install-method.test.ts | 12 ++- packages/runtime/src/utils/binary-update.ts | 32 +++--- tools/release/lib/binary-build.mjs | 100 ++++++++++-------- tools/release/lib/binary-options.mjs | 11 +- tools/release/lib/binary-release.mjs | 33 +++--- tools/release/lib/binary-zip.mjs | 55 ++++++++++ 12 files changed, 302 insertions(+), 166 deletions(-) delete mode 100644 docs/agents/binary-distribution.md create mode 100644 packages/core/src/install/unzip-asset.ts create mode 100644 tools/release/lib/binary-zip.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0346059d..875a35e3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,7 +18,7 @@ on: - channel - stable channel: - description: "dist-tag (channel mode only, e.g. mcp/plugin/advisor)" + description: "Required when mode=channel. dist-tag name, e.g. mcp / plugin / release-test (lowercase, digits, dashes)" required: false type: string @@ -55,6 +55,9 @@ 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). @@ -78,6 +81,12 @@ jobs: 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\" (e.g. mcp, plugin, release-test). Leave mode=stable if you do not need a dist-tag." + exit 1 + - uses: actions/checkout@v6 - uses: pnpm/action-setup@v6 @@ -97,6 +106,9 @@ 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). diff --git a/docs/agents/binary-distribution.md b/docs/agents/binary-distribution.md deleted file mode 100644 index 27159fb6..00000000 --- a/docs/agents/binary-distribution.md +++ /dev/null @@ -1,84 +0,0 @@ -# 二进制分发(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 为 `.json`) -- [ ] manifest asset 只有 `file` + `sha256`(无硬编码 `url`;客户端按 OSS `{base}/releases/{version}/{file}` 拼) -- [ ] dry-run:`node tools/release/lib/binary-release.mjs --mode stable --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 -``` - -实现分层(均在 `tools/release/lib/`): - -- `binary-build.mjs` / `binary-compile.mjs` — 编译 + manifest -- `binary-options.mjs` — 共享 `--mode` / `--channel` 校验 -- `binary-release.mjs` — 编排(stable/channel 上传哪些资产) -- `gh-release.mjs` — `gh release` create / clobber / verify -- `oss-sync-webhook.mjs` — 可选 FC 通知 - -## 常见漏点 - -| 漏点 | 后果 | -| ------------------------------------ | ----------------------------------------------------------------- | -| 只发 npm、未建 Release | FC 无源可同步 | -| FC 未跑完用户就 curl | OSS 404 / 半包 | -| 矩阵变更未通知脚本方 | 装错 arch / 永久失败 | -| webhook 配错当发版失败 | 不应;webhook 失败只 warn | -| 用 `Bun.build({ compile })` 代替 CLI | Bun ≤1.2.19 可能 exit 0 但不写 outfile → `sha256` ENOENT | -| 编译后未 `chmod` windows `.exe` | Bun 1.2.19 在 Unix 上写出 mode `000` → `sha256` / upload `EACCES` | -| manifest 写死 GitHub `url` | FC 同步后 `bl update` 仍打 GitHub,绕开 OSS | -| `--dry-run` 仍全量 compile | 本地验证极慢;dry-run 应只规划 gh / webhook | -| 用 `--host` 产物去 upload | 半包上架;release 路径会校验完整矩阵 | - -## 编译实现注意 - -- `binary-compile.mjs` 必须走 **`bun build --compile --outfile …`**,不要用 `Bun.build({ compile })`(CI 钉 `1.2.19` 时 API 会假成功)。 -- 编译后校验 outfile 存在再算 SHA256。 -- 每个产物在哈希前 `chmod 0755`(规避 Bun 1.2.19 windows cross-compile 无权限,见 oven-sh/bun#21308)。 -- channel 同日同 commit 共用一个 `v0.0.0-beta-…` Release;滚动 tag `channel-` 只挂 `.json`。 diff --git a/docs/agents/publish.md b/docs/agents/publish.md index f6b85d7a..6532b9dd 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -108,7 +108,7 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run - [ ] 验证 npm 上能装:`npm view bailian-cli@ version`;如发布 `knowledge-studio-cli`,同时 `npm view knowledge-studio-cli@ version` - [ ] 试装一次:`npm i -g bailian-cli@ && bl --version`;如发布 `knowledge-studio-cli`,同时 `npm i -g knowledge-studio-cli@ && kscli --version` -- [ ] (若本次含二进制)GitHub Release 页可见资产,`install.sh` / `latest.json` 经 FC 同步到 OSS 后可访问;清单见 [binary-distribution.md](binary-distribution.md) +- [ ] (若本次含二进制)GitHub Release 可见 `bl-*.zip` + `SHA256SUMS`;OSS 侧 `channels/latest.json` 与 install 脚本由 FC/仓外维护;清单见 [binary-distribution.md](binary-distribution.md) ## 常见漏点(基于历史踩坑) diff --git a/packages/core/src/install/cdn.ts b/packages/core/src/install/cdn.ts index 4ec9585b..44d23d1e 100644 --- a/packages/core/src/install/cdn.ts +++ b/packages/core/src/install/cdn.ts @@ -21,7 +21,8 @@ export function getCliCdnBase(): string { /** * Channel manifest on OSS (after FC sync): `{base}/channels/{channel}.json` - * Formal releases use `latest.json` (default). + * Formal installs still read `channels/latest.json` on OSS; this repo no longer + * attaches `latest.json` to GitHub Releases (FC / ops maintain OSS latest). */ export function channelManifestUrl(channel = "latest"): string { return `${getCliCdnBase()}/channels/${channel}.json`; @@ -66,7 +67,18 @@ export function detectBinaryPlatform(): { os: string; arch: string; fileSuffix: return { os, arch: normalizedArch, fileSuffix }; } +/** Release download asset: `bl---.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, diff --git a/packages/core/src/install/index.ts b/packages/core/src/install/index.ts index 87659025..78e0535a 100644 --- a/packages/core/src/install/index.ts +++ b/packages/core/src/install/index.ts @@ -11,8 +11,10 @@ export { DEFAULT_INSTALL_SCRIPT_URL, GITHUB_RELEASES_BASE, binaryAssetFileName, + binaryInnerFileName, channelManifestUrl, detectBinaryPlatform, getCliCdnBase, releaseAssetUrl, } from "./cdn.ts"; +export { extractZipEntryToFile } from "./unzip-asset.ts"; diff --git a/packages/core/src/install/unzip-asset.ts b/packages/core/src/install/unzip-asset.ts new file mode 100644 index 00000000..3ccea4fa --- /dev/null +++ b/packages/core/src/install/unzip-asset.ts @@ -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 { + 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 { + 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(); + }); +} diff --git a/packages/core/tests/install-method.test.ts b/packages/core/tests/install-method.test.ts index 57f866b1..e4aa5577 100644 --- a/packages/core/tests/install-method.test.ts +++ b/packages/core/tests/install-method.test.ts @@ -3,6 +3,7 @@ import { detectInstallMethod, isCompiledBinary, binaryAssetFileName, + binaryInnerFileName, } from "../src/install/index.ts"; test("isCompiledBinary respects BAILIAN_COMPILED", () => { @@ -23,7 +24,12 @@ test("detectInstallMethod respects 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"); +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"); }); diff --git a/packages/runtime/src/utils/binary-update.ts b/packages/runtime/src/utils/binary-update.ts index e107e4d6..e7f61b8b 100644 --- a/packages/runtime/src/utils/binary-update.ts +++ b/packages/runtime/src/utils/binary-update.ts @@ -1,11 +1,13 @@ -import { mkdir, rename, unlink, writeFile, chmod } from "node:fs/promises"; +import { mkdir, rename, unlink, writeFile, chmod, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; import { createHash } from "node:crypto"; import { binaryAssetFileName, + binaryInnerFileName, channelManifestUrl, detectBinaryPlatform, + extractZipEntryToFile, getConfigDir, releaseAssetUrl, writeInstallMethodSync, @@ -13,7 +15,7 @@ import { export interface ChannelManifest { version: string; - assets?: Record; + assets?: Record; } export async function fetchBinaryChannelVersion( @@ -81,31 +83,36 @@ function sha256(buffer: Buffer): string { /** * Download and install a newer standalone binary in place of the current install. + * Assets are per-platform `.zip` files; checksum applies to the zip. * Returns the installed version string. */ export async function performBinaryUpdate(targetVersion: string): Promise { const { os, arch, fileSuffix } = detectBinaryPlatform(); + const exe = fileSuffix === ".exe"; 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 zipName = assetMeta?.file ?? binaryAssetFileName(targetVersion, os, arch, exe); + const innerName = assetMeta?.inner ?? binaryInnerFileName(targetVersion, os, arch, exe); const expectedSha = assetMeta?.sha256; - const url = assetMeta?.url ?? releaseAssetUrl(targetVersion, fileName); + const url = assetMeta?.url ?? releaseAssetUrl(targetVersion, zipName); - const tmpPath = join(shareRoot(), ".tmp", fileName); - const buffer = await downloadToFile(url, tmpPath); + const tmpZip = join(shareRoot(), ".tmp", zipName); + const buffer = await downloadToFile(url, tmpZip); const actualSha = sha256(buffer); if (expectedSha && expectedSha !== actualSha) { - await unlink(tmpPath).catch(() => {}); - throw new Error(`Checksum mismatch for ${fileName}`); + await unlink(tmpZip).catch(() => {}); + throw new Error(`Checksum mismatch for ${zipName}`); } 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); + const tmpBinary = join(shareRoot(), ".tmp", binaryName); + await extractZipEntryToFile(tmpZip, tmpBinary, innerName); + await unlink(tmpZip).catch(() => {}); + await rename(tmpBinary, finalPath); if (process.platform !== "win32") { await chmod(finalPath, 0o755); } @@ -113,8 +120,9 @@ export async function performBinaryUpdate(targetVersion: string): Promise.json only (does not touch latest.json) + * --mode stable → no release manifest (latest.json removed) + * --mode channel → writes .json only */ -import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +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 { manifestFileName, normalizeModeChannel } from "./binary-options.mjs"; +import { channelManifestFileName, normalizeModeChannel } 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"); @@ -32,12 +33,17 @@ export const BINARY_TARGETS = [ { bunTarget: "bun-windows-x64", os: "windows", arch: "x64", exe: true }, ]; -/** Asset basename for a matrix row: `bl---[.exe]`. */ -export function binaryAssetName(version, { os, arch, exe }) { +/** Uncompressed binary basename inside the zip: `bl---[.exe]`. */ +export function binaryInnerName(version, { os, arch, exe }) { return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`; } -/** Full matrix basenames for a version (order matches BINARY_TARGETS). */ +/** Release asset basename: `bl---.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)); } @@ -124,19 +130,14 @@ function ensureBun() { return result.stdout.trim(); } -function sha256File(path) { - return createHash("sha256").update(readFileSync(path)).digest("hex"); -} - function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) { - const fileName = binaryAssetName(version, { os, arch, exe }); - const outfile = join(outdir, fileName); - log(`compile ${bunTarget} → ${fileName}`); + const innerName = binaryInnerName(version, { os, arch, exe }); + const innerPath = join(outdir, innerName); + log(`compile ${bunTarget} → ${innerName}`); - // binary-compile.mjs shells out to `bun build --compile` (CLI); this file stays Node. const result = spawnSync( "bun", - [BINARY_COMPILE, "--entry", entry, "--outfile", outfile, "--target", bunTarget], + [BINARY_COMPILE, "--entry", entry, "--outfile", innerPath, "--target", bunTarget], { cwd: ROOT, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, ); if (result.status !== 0) { @@ -146,9 +147,8 @@ function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) { 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). - // chmod so sha256 / gh upload can open the file; harmless for other targets. - chmodSync(outfile, 0o755); - return { fileName, outfile, os, arch, sha256: sha256File(outfile) }; + chmodSync(innerPath, 0o755); + return { innerName, innerPath, os, arch, exe }; } function writeChecksums(outdir, artifacts) { @@ -157,56 +157,56 @@ function writeChecksums(outdir, artifacts) { } /** - * Write latest.json (stable) or .json (channel). - * Asset entries carry file + sha256 only — no baked download URL. - * Consumers (bl update / install scripts) resolve via BAILIAN_CLI_CDN + - * `{base}/releases/{version}/{file}` (see packages/core releaseAssetUrl). + * Channel-only: write `.json` with per-platform zip file + sha256. + * Stable no longer emits latest.json. */ -function writeManifest(outdir, version, artifacts, mode, channel) { +function writeChannelManifest(outdir, version, artifacts, channel) { 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: mode === "stable" ? "latest" : channel, + channel, version, releasedAt: new Date().toISOString(), assets, }; - const name = manifestFileName(mode, channel); + const name = channelManifestFileName(channel); writeJson(join(outdir, name), manifest); - return [name]; + return name; } 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) { +/** 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 = artifacts.find((item) => item.os === hostOs && item.arch === process.arch); + const host = compiled.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 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.fileName} --version`); + throw new Error(`smoke test failed: ${host.innerName} --version`); } } -/** Compile binaries into `outdir` and write checksums + manifest. */ +/** 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); @@ -216,17 +216,31 @@ export function buildBinaryArtifacts(rawOptions = {}) { log(`mode ${mode}${channel ? ` channel=${channel}` : ""}`); log(`outdir ${outdir}`); - const artifacts = targets.map((target) => compileOne(target, version, outdir, CLI_ENTRY)); + 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 manifests = writeManifest(outdir, version, artifacts, mode, channel); - smokeTestHostBinary(artifacts, outdir); - log(`\nBuilt ${artifacts.length} binary(ies):`); + const extras = ["SHA256SUMS"]; + if (mode === "channel") { + extras.push(writeChannelManifest(outdir, version, artifacts, channel)); + } + + log(`\nBuilt ${artifacts.length} zip(s):`); for (const item of artifacts) { - log(` ${item.fileName} ${item.sha256.slice(0, 12)}…`); + log(` ${item.fileName} ${item.sha256.slice(0, 12)}… (inner ${item.innerName})`); } - log(`Also wrote SHA256SUMS, ${manifests.join(", ")}`); - return { version, mode, channel, outdir, artifacts, manifests }; + 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)) { diff --git a/tools/release/lib/binary-options.mjs b/tools/release/lib/binary-options.mjs index 59457e0f..f91b4f2e 100644 --- a/tools/release/lib/binary-options.mjs +++ b/tools/release/lib/binary-options.mjs @@ -1,5 +1,9 @@ /** * Shared mode / channel / manifest naming for binary-build and binary-release. + * + * Stable releases no longer write `latest.json` (OSS `channels/latest.json` is + * maintained outside this repo / by FC). Channel mode still writes `.json` + * onto the rolling `channel-` GitHub Release. */ import { assertChannel } from "./validate.mjs"; @@ -23,7 +27,8 @@ export function normalizeModeChannel(mode = "stable", channel = null) { return { mode: "stable", channel: null }; } -/** Manifest basename written to dist-bin / uploaded to Releases. */ -export function manifestFileName(mode, channel) { - return mode === "stable" ? "latest.json" : `${channel}.json`; +/** Channel rolling-manifest basename (`mcp.json`, …). Stable has none. */ +export function channelManifestFileName(channel) { + if (!channel) throw new Error("channelManifestFileName requires a channel name"); + return `${channel}.json`; } diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index 84304ef7..de646a1e 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -2,8 +2,8 @@ * Publish bailian-cli binary assets to GitHub Releases. * * stable: release `v` (tag must already be on origin; --verify-tag) - * assets: bl-*, SHA256SUMS, latest.json - * channel: versioned prerelease `v` (assets: bl-*, SHA256SUMS) + * assets: bl-*.zip, SHA256SUMS (no latest.json) + * channel: versioned prerelease `v` (assets: bl-*.zip, SHA256SUMS) * + rolling prerelease tag `channel-` holding only `.json` * * Same commit/day channel publishes share one `v` Release (identical @@ -23,7 +23,7 @@ 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 { manifestFileName, normalizeModeChannel } from "./binary-options.mjs"; +import { channelManifestFileName, normalizeModeChannel } from "./binary-options.mjs"; import { ensureGh, GITHUB_REPOSITORY, upsertRelease } from "./gh-release.mjs"; import { notifyOssSyncWebhook } from "./oss-sync-webhook.mjs"; @@ -59,10 +59,6 @@ function versionBinaryAssets(dir, version, files) { } function uploadStable({ dir, version, files, dryRun }) { - const assets = [ - ...versionBinaryAssets(dir, version, files), - join(dir, manifestFileName("stable", null)), - ]; const section = extractChangelogSection(version); upsertRelease({ @@ -70,7 +66,7 @@ function uploadStable({ dir, version, files, dryRun }) { title: `v${version}`, verifyTag: true, notes: section || undefined, - assets, + assets: versionBinaryAssets(dir, version, files), dryRun, }); } @@ -91,7 +87,7 @@ function uploadChannel({ dir, version, channel, files, dryRun }) { title: `channel: ${channel}`, prerelease: true, notes: `Rolling manifest for the \`${channel}\` channel. Latest beta: ${version}.`, - assets: [join(dir, manifestFileName("channel", channel))], + assets: [join(dir, channelManifestFileName(channel))], dryRun, }); } @@ -99,14 +95,13 @@ function uploadChannel({ dir, version, channel, files, dryRun }) { /** Dry-run path when dist-bin is absent: plan tags/assets without compiling. */ function planDryRunWithoutArtifacts({ version, mode, channel }) { const matrix = matrixAssetNames(version); - const manifestName = manifestFileName(mode, channel); if (mode === "stable") { upsertRelease({ tag: `v${version}`, title: `v${version}`, verifyTag: true, notes: extractChangelogSection(version) || undefined, - assets: [...matrix, "SHA256SUMS", manifestName], + assets: [...matrix, "SHA256SUMS"], dryRun: true, }); return; @@ -124,7 +119,7 @@ function planDryRunWithoutArtifacts({ version, mode, channel }) { title: `channel: ${channel}`, prerelease: true, notes: `Rolling manifest for the \`${channel}\` channel. Latest beta: ${version}.`, - assets: [manifestName], + assets: [channelManifestFileName(channel)], dryRun: true, }); } @@ -173,15 +168,17 @@ export function releaseBinaryArtifacts(rawOptions = {}) { } const files = readdirSync(dir).filter((name) => !name.startsWith(".")); - const manifestName = manifestFileName(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}`); } + if (mode === "channel") { + const manifestName = channelManifestFileName(channel); + if (!files.includes(manifestName)) { + throw new Error( + `Missing ${manifestName} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`, + ); + } + } process.stdout.write(`artifacts in ${dir}:\n`); for (const name of files) process.stdout.write(` ${name}\n`); diff --git a/tools/release/lib/binary-zip.mjs b/tools/release/lib/binary-zip.mjs new file mode 100644 index 00000000..d054ba84 --- /dev/null +++ b/tools/release/lib/binary-zip.mjs @@ -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---.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), + }; +} From e736bab9c11071023476919918bbf5b99fa7c6b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Sat, 25 Jul 2026 00:33:11 +0800 Subject: [PATCH 09/21] feat: add installer sync --- .github/workflows/publish.yml | 19 +- tools/release/lib/binary-release.mjs | 52 +++- tools/release/lib/oss-direct-upload.mjs | 304 ++++++++++++++++++++++++ tools/release/lib/oss-sync-webhook.mjs | 63 ----- tools/release/publish-channel.mjs | 2 +- tools/release/publish-stable.mjs | 2 +- 6 files changed, 367 insertions(+), 75 deletions(-) create mode 100644 tools/release/lib/oss-direct-upload.mjs delete mode 100644 tools/release/lib/oss-sync-webhook.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 875a35e3..567c79c8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -69,8 +69,15 @@ jobs: - 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 }} + # OSS release channel runs fully in CI: upload + reconcile + manifest.json. + # All values come from repo Settings: credentials via Secrets, the rest + # via Variables. Leave the secrets unset to skip the OSS channel entirely. + BAILIAN_OSS_AK: ${{ secrets.BAILIAN_OSS_AK }} + BAILIAN_OSS_SK: ${{ secrets.BAILIAN_OSS_SK }} + BAILIAN_OSS_BUCKET: ${{ vars.BAILIAN_OSS_BUCKET }} + BAILIAN_OSS_REGION: ${{ vars.BAILIAN_OSS_REGION }} + BAILIAN_OSS_ENDPOINT: ${{ vars.BAILIAN_OSS_ENDPOINT }} + BAILIAN_RELEASE_PREFIX: ${{ vars.BAILIAN_RELEASE_PREFIX }} run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} publish-channel: @@ -120,5 +127,11 @@ jobs: - name: publish-channel env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BAILIAN_OSS_SYNC_WEBHOOK: ${{ secrets.BAILIAN_OSS_SYNC_WEBHOOK }} + # 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: ${{ vars.BAILIAN_OSS_BUCKET }} + BAILIAN_OSS_REGION: ${{ vars.BAILIAN_OSS_REGION }} + BAILIAN_OSS_ENDPOINT: ${{ vars.BAILIAN_OSS_ENDPOINT }} + BAILIAN_RELEASE_PREFIX: ${{ vars.BAILIAN_RELEASE_PREFIX }} run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}" diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index de646a1e..a906a518 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -10,7 +10,9 @@ * binaries); only the rolling `channel-` manifest differs per channel. * * Re-runs are idempotent via `gh release upload --clobber` (see gh-release.mjs). - * Optionally notifies BAILIAN_OSS_SYNC_WEBHOOK (see oss-sync-webhook.mjs). + * After the GitHub upload the same assets are pushed straight to OSS from the + * runner, HEAD-reconciled, and (stable only) release/manifest.json is updated — + * all in-process, no external FC (see oss-direct-upload.mjs). * * Called by publish-stable.mjs / publish-channel.mjs. * Debug: @@ -18,14 +20,14 @@ * 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 { basename, 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 { channelManifestFileName, normalizeModeChannel } from "./binary-options.mjs"; import { ensureGh, GITHUB_REPOSITORY, upsertRelease } from "./gh-release.mjs"; -import { notifyOssSyncWebhook } from "./oss-sync-webhook.mjs"; +import { maintainReleaseManifest, mirrorReleaseAssetsToOss } from "./oss-direct-upload.mjs"; const DEFAULT_DIR = join(ROOT, "dist-bin"); @@ -124,6 +126,23 @@ function planDryRunWithoutArtifacts({ version, mode, channel }) { }); } +/** + * 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, channel, files }) { + const paths = files + ? versionBinaryAssets(dir, version, files) + : [...matrixAssetNames(version), "SHA256SUMS"]; + const plans = [{ tag: `v${version}`, paths }]; + if (mode === "channel") { + const manifest = channelManifestFileName(channel); + plans.push({ tag: `channel-${channel}`, 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. @@ -131,7 +150,7 @@ function planDryRunWithoutArtifacts({ version, mode, channel }) { * `--dry-run` never compiles; it plans gh release steps. Prebuilt `dist-bin` is * optional (used only to list real paths when present). */ -export function releaseBinaryArtifacts(rawOptions = {}) { +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); @@ -157,7 +176,15 @@ export function releaseBinaryArtifacts(rawOptions = {}) { if (dryRun && !existsSync(dir)) { process.stdout.write(`[dry-run] ${dir} missing; planning expected assets\n`); planDryRunWithoutArtifacts({ version, mode, channel }); - notifyOssSyncWebhook({ version, mode, channel, dryRun }); + const plans = ossMirrorPlans({ dir, version, mode, channel, files: null }); + await mirrorReleaseAssetsToOss({ plans, dryRun: true }); + if (mode === "stable") { + await maintainReleaseManifest({ + tag: `v${version}`, + assetNames: plans[0].paths.map((path) => basename(path)), + dryRun: true, + }); + } return { version, mode, channel, dryRun }; } @@ -198,7 +225,18 @@ export function releaseBinaryArtifacts(rawOptions = {}) { uploadChannel({ dir, version, channel, files, dryRun }); } - notifyOssSyncWebhook({ version, mode, channel, 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, channel, files }); + const mirror = await mirrorReleaseAssetsToOss({ plans, dryRun }); + if (mode === "stable" && !mirror.skipped) { + await maintainReleaseManifest({ + tag: `v${version}`, + assetNames: plans[0].paths.map((path) => basename(path)), + dryRun, + }); + } return { version, mode, channel, dryRun }; } @@ -223,7 +261,7 @@ if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { process.stdout.write(USAGE); process.exit(0); } - releaseBinaryArtifacts({ + await releaseBinaryArtifacts({ dir: values.dir ? resolve(values.dir) : undefined, dryRun: values["dry-run"], mode: values.mode, diff --git a/tools/release/lib/oss-direct-upload.mjs b/tools/release/lib/oss-direct-upload.mjs new file mode 100644 index 00000000..69f3b6b7 --- /dev/null +++ b/tools/release/lib/oss-direct-upload.mjs @@ -0,0 +1,304 @@ +/** + * 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 `//` (channel + * builds include the rolling `channel-/.json`). + * - 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 manifest.latest + * (compareVersions), rewrite `/manifest.json`. Channel/prerelease + * never touches it — same semantics the FC sync-release used to enforce. + * + * 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 for + * credentials, Variables for the rest; never hardcode values in the workflow): + * BAILIAN_OSS_AK / BAILIAN_OSS_SK —— RAM AccessKey; needs oss:PutObject and + * oss:GetObject on `/*` + * BAILIAN_OSS_BUCKET —— target bucket, default bailian-wiki + * BAILIAN_OSS_REGION —— region, default oss-cn-hangzhou + * BAILIAN_OSS_ENDPOINT —— optional request endpoint override, + * e.g. oss-accelerate.aliyuncs.com; public + * manifest URLs always use the region endpoint + * BAILIAN_RELEASE_PREFIX —— object prefix, default release + */ +import { createHash, createHmac } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { basename } from "node:path"; + +const OSS_CONFIG = { + bucket: process.env.BAILIAN_OSS_BUCKET || "bailian-wiki", + region: process.env.BAILIAN_OSS_REGION || "oss-cn-hangzhou", + endpoint: process.env.BAILIAN_OSS_ENDPOINT || "", + prefix: process.env.BAILIAN_RELEASE_PREFIX || "release", +}; + +function ossCredentials() { + const ak = process.env.BAILIAN_OSS_AK?.trim(); + const sk = process.env.BAILIAN_OSS_SK?.trim(); + return ak && sk ? { ak, sk } : null; +} + +/** Virtual-hosted-style request host: .. */ +function ossHost(cfg) { + return `${cfg.bucket}.${cfg.endpoint || `${cfg.region}.aliyuncs.com`}`; +} + +function contentTypeFor(name) { + 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 + 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; +} + +/** Format now (or a given time) as an Asia/Shanghai +08:00 string. */ +function toBeijing(input) { + const d = input ? new Date(input) : new Date(); + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).formatToParts(d); + const g = (t) => parts.find((p) => p.type === t)?.value ?? "00"; + return `${g("year")}-${g("month")}-${g("day")}T${g("hour")}:${g("minute")}:${g("second")}+08:00`; +} + +/** + * Build the manifest.json contents. Public URLs always use the durable region + * endpoint (never the acceleration endpoint used for uploads). + */ +function buildManifest(tag, releasedAt, assetNames, cfg) { + const base = `https://${cfg.bucket}.${cfg.region}.aliyuncs.com/${cfg.prefix}/${tag}`; + const assets = {}; + for (const name of [...assetNames].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) { + assets[name] = `${base}/${encodeURIComponent(name)}`; + } + return { latest: tag, releasedAt, assets }; +} + +/** + * 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 = "" }) { + 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}` }; + 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. */ +async function headObjectSize(key, creds, cfg) { + const res = await ossRequest("HEAD", key, { creds, cfg }); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`OSS HEAD ${key} failed: HTTP ${res.status}`); + return Number(res.headers.get("content-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; + } +} + +/** + * Upload release assets to OSS under `//`, 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 creds = ossCredentials(); + const cfg = OSS_CONFIG; + + const jobs = plans.flatMap(({ tag, paths }) => + paths.map((path) => ({ path, key: `${cfg.prefix}/${tag}/${basename(path)}` })), + ); + if (jobs.length === 0) return { uploaded: 0, skipped: true }; + + if (!creds) { + process.stdout.write( + "\n[warn] BAILIAN_OSS_AK/SK unset; skip the OSS release channel entirely\n", + ); + 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 }; + } + + // Bounded worker pool; collect failures, then throw once at the end. + const failed = []; + let cursor = 0; + const worker = async () => { + while (true) { + const index = cursor++; + if (index >= jobs.length) return; + const { path, key } = jobs[index]; + const startedAt = Date.now(); + try { + const body = readFileSync(path); + await putWithRetry({ creds, cfg, key, body, contentType: contentTypeFor(key) }); + process.stdout.write( + ` [oss] ok ${key} (${(body.length / 1024 / 1024).toFixed(1)}MB, ${Date.now() - startedAt}ms)\n`, + ); + } catch (err) { + failed.push({ key, error: err.message }); + process.stdout.write(` [oss] FAIL ${key}: ${err.message}\n`); + } + } + }; + await Promise.all(Array.from({ length: Math.min(4, jobs.length) }, () => worker())); + + if (failed.length > 0) { + throw new Error( + `OSS upload failed for ${failed.length}/${jobs.length} object(s): ${failed + .map((f) => f.key) + .join(", ")}`, + ); + } + + // Reconcile: every uploaded object must exist remotely with the local byte size. + for (const { path, key } of jobs) { + const remote = await headObjectSize(key, creds, cfg); + const local = statSync(path).size; + if (remote !== local) { + throw new Error( + `OSS reconcile mismatch for ${key}: local ${local}B vs remote ${remote ?? "missing"}`, + ); + } + } + process.stdout.write(`reconcile ok: ${jobs.length}/${jobs.length} object(s) verified on OSS\n`); + return { uploaded: jobs.length, skipped: false }; +} + +/** + * Maintain `/manifest.json` for STABLE releases only: rewrite it when + * `tag` is a newer version than the current `latest` (first write included). + * Same update rule the FC sync-release used to apply. + * + * @param {{ tag: string, assetNames: string[], dryRun?: boolean }} options + * @returns {Promise<{ updated: boolean, latest: string | null }>} + */ +export async function maintainReleaseManifest({ tag, assetNames, dryRun = false }) { + const creds = ossCredentials(); + const cfg = OSS_CONFIG; + const key = `${cfg.prefix}/manifest.json`; + + if (!creds) { + process.stdout.write("[info] BAILIAN_OSS_AK/SK unset; skip manifest.json maintenance\n"); + return { updated: false, latest: null }; + } + + if (dryRun) { + process.stdout.write( + `[dry-run] manifest: GET oss://${cfg.bucket}/${key} → rewrite when ${tag} > latest (assets: ${assetNames.length})\n`, + ); + return { updated: false, latest: null }; + } + + const current = await getObjectJson(key, creds, cfg); + const currentLatest = 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 manifest = buildManifest(tag, toBeijing(), assetNames, cfg); + await putObject({ + creds, + cfg, + key, + body: Buffer.from(JSON.stringify(manifest, null, 2)), + contentType: "application/json", + }); + process.stdout.write(`manifest.json → latest=${tag} (was ${currentLatest ?? "none"})\n`); + return { updated: true, latest: tag }; +} diff --git a/tools/release/lib/oss-sync-webhook.mjs b/tools/release/lib/oss-sync-webhook.mjs deleted file mode 100644 index ba9f8149..00000000 --- a/tools/release/lib/oss-sync-webhook.mjs +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Optional hook for an external FC that mirrors GitHub Releases → OSS. - * Set BAILIAN_OSS_SYNC_WEBHOOK to an HTTP endpoint; unset → no-op. - * Failure is warn-only — Release publish already succeeded. - */ -import { tryRun } from "./proc.mjs"; -import { GITHUB_REPOSITORY } from "./gh-release.mjs"; - -/** - * @param {{ - * version: string, - * mode: "stable" | "channel", - * channel: string | null, - * dryRun?: boolean, - * repo?: string, - * }} options - */ -export function notifyOssSyncWebhook({ - version, - mode, - channel, - dryRun = false, - repo = GITHUB_REPOSITORY, -}) { - 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 = `v${version}`; - const body = { - 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`); -} diff --git a/tools/release/publish-channel.mjs b/tools/release/publish-channel.mjs index 420271ef..a5e62c60 100644 --- a/tools/release/publish-channel.mjs +++ b/tools/release/publish-channel.mjs @@ -96,7 +96,7 @@ try { step( `publish binary GitHub Release (mode=channel, channel=${channel}, version=${betaVersion})`, ); - releaseBinaryArtifacts({ mode: "channel", channel, dryRun }); + await releaseBinaryArtifacts({ mode: "channel", channel, dryRun }); } const parts = ["npm"]; diff --git a/tools/release/publish-stable.mjs b/tools/release/publish-stable.mjs index 40fa2cb6..66e85445 100644 --- a/tools/release/publish-stable.mjs +++ b/tools/release/publish-stable.mjs @@ -86,7 +86,7 @@ try { log("\n[skip-binary] skipping binary GitHub Release"); } else { step(`publish binary GitHub Release (mode=stable, version=${version})`); - releaseBinaryArtifacts({ mode: "stable", dryRun }); + await releaseBinaryArtifacts({ mode: "stable", dryRun }); } const parts = ["npm"]; From 6bdc16597bf2db834bd5c9cce5f78e968aa7b260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Sat, 25 Jul 2026 08:07:29 +0800 Subject: [PATCH 10/21] feat: add secret --- .github/workflows/publish.yml | 21 +++---- tools/release/lib/oss-direct-upload.mjs | 77 +++++++++++++++---------- 2 files changed, 56 insertions(+), 42 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 567c79c8..6907d97e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -70,14 +70,15 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # OSS release channel runs fully in CI: upload + reconcile + manifest.json. - # All values come from repo Settings: credentials via Secrets, the rest - # via Variables. Leave the secrets unset to skip the OSS channel entirely. + # 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: ${{ vars.BAILIAN_OSS_BUCKET }} - BAILIAN_OSS_REGION: ${{ vars.BAILIAN_OSS_REGION }} - BAILIAN_OSS_ENDPOINT: ${{ vars.BAILIAN_OSS_ENDPOINT }} - BAILIAN_RELEASE_PREFIX: ${{ vars.BAILIAN_RELEASE_PREFIX }} + 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 }} run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} publish-channel: @@ -130,8 +131,8 @@ jobs: # 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: ${{ vars.BAILIAN_OSS_BUCKET }} - BAILIAN_OSS_REGION: ${{ vars.BAILIAN_OSS_REGION }} - BAILIAN_OSS_ENDPOINT: ${{ vars.BAILIAN_OSS_ENDPOINT }} - BAILIAN_RELEASE_PREFIX: ${{ vars.BAILIAN_RELEASE_PREFIX }} + 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 }} run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}" diff --git a/tools/release/lib/oss-direct-upload.mjs b/tools/release/lib/oss-direct-upload.mjs index 69f3b6b7..132de8fb 100644 --- a/tools/release/lib/oss-direct-upload.mjs +++ b/tools/release/lib/oss-direct-upload.mjs @@ -20,32 +20,47 @@ * - 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 for - * credentials, Variables for the rest; never hardcode values in the workflow): + * 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 `/*` - * BAILIAN_OSS_BUCKET —— target bucket, default bailian-wiki - * BAILIAN_OSS_REGION —— region, default oss-cn-hangzhou - * BAILIAN_OSS_ENDPOINT —— optional request endpoint override, - * e.g. oss-accelerate.aliyuncs.com; public - * manifest URLs always use the region endpoint - * BAILIAN_RELEASE_PREFIX —— object prefix, default release + * oss:GetObject on the release prefix + * BAILIAN_OSS_BUCKET / BAILIAN_OSS_REGION / BAILIAN_RELEASE_PREFIX + * —— required once the channel is enabled + * 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"; -const OSS_CONFIG = { - bucket: process.env.BAILIAN_OSS_BUCKET || "bailian-wiki", - region: process.env.BAILIAN_OSS_REGION || "oss-cn-hangzhou", - endpoint: process.env.BAILIAN_OSS_ENDPOINT || "", - prefix: process.env.BAILIAN_RELEASE_PREFIX || "release", -}; - -function ossCredentials() { +/** + * 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(); - return ak && sk ? { ak, sk } : null; + 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: .. */ @@ -186,20 +201,19 @@ async function getObjectJson(key, creds, cfg) { * @returns {Promise<{ uploaded: number, skipped: boolean }>} */ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) { - const creds = ossCredentials(); - const cfg = OSS_CONFIG; - - const jobs = plans.flatMap(({ tag, paths }) => - paths.map((path) => ({ path, key: `${cfg.prefix}/${tag}/${basename(path)}` })), - ); - if (jobs.length === 0) return { uploaded: 0, skipped: true }; - - if (!creds) { + 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; + + const jobs = plans.flatMap(({ tag, paths }) => + paths.map((path) => ({ path, key: `${cfg.prefix}/${tag}/${basename(path)}` })), + ); + 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`, @@ -267,14 +281,13 @@ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) { * @returns {Promise<{ updated: boolean, latest: string | null }>} */ export async function maintainReleaseManifest({ tag, assetNames, dryRun = false }) { - const creds = ossCredentials(); - const cfg = OSS_CONFIG; - const key = `${cfg.prefix}/manifest.json`; - - if (!creds) { + 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( From ebbd173b7908bd3baa658bbe52ed25c8466e8d05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Sat, 25 Jul 2026 08:43:38 +0800 Subject: [PATCH 11/21] feat: update manifest.json path --- package.json | 1 + packages/commands/src/commands/update.ts | 5 +- packages/core/src/install/cdn.ts | 26 ++++--- packages/runtime/src/utils/update-checker.ts | 9 ++- tools/release/lib/binary-build.mjs | 16 ++--- tools/release/lib/binary-options.mjs | 9 +-- tools/release/lib/binary-release.mjs | 18 ++--- tools/release/lib/oss-direct-upload.mjs | 71 +++++++++++++++----- tools/release/lib/validate.mjs | 11 ++- 9 files changed, 114 insertions(+), 52 deletions(-) diff --git a/package.json b/package.json index dc59209d..c631a0b9 100644 --- a/package.json +++ b/package.json @@ -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:" diff --git a/packages/commands/src/commands/update.ts b/packages/commands/src/commands/update.ts index 53f2d591..a20117d7 100644 --- a/packages/commands/src/commands/update.ts +++ b/packages/commands/src/commands/update.ts @@ -2,6 +2,7 @@ import { execSync } from "child_process"; import { writeFileSync } from "fs"; import { join } from "path"; import { + DEFAULT_INSTALL_SCRIPT_URL, defineCommand, getConfigDir, getInstallMethod, @@ -101,9 +102,7 @@ export default defineCommand({ 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", - ); + process.stderr.write(` curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash\n\n`); } return; } diff --git a/packages/core/src/install/cdn.ts b/packages/core/src/install/cdn.ts index 44d23d1e..d9996f41 100644 --- a/packages/core/src/install/cdn.ts +++ b/packages/core/src/install/cdn.ts @@ -1,11 +1,17 @@ /** - * 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. + * 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/.zip —— immutable per-version binaries + SHA256SUMS + * manifest.json —— stable pointer { latest, releasedAt, assets } + * latest.json —— stable rolling manifest (os-arch keyed, sha256) + * .json —— per-channel rolling manifests (beta versions) * * Override with `BAILIAN_CLI_CDN`. */ -export const DEFAULT_CLI_CDN_BASE = "https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli"; +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"; @@ -20,16 +26,18 @@ export function getCliCdnBase(): string { } /** - * Channel manifest on OSS (after FC sync): `{base}/channels/{channel}.json` - * Formal installs still read `channels/latest.json` on OSS; this repo no longer - * attaches `latest.json` to GitHub Releases (FC / ops maintain OSS latest). + * Rolling channel manifest at the CDN base root: `{base}/{channel}.json`. + * `latest.json` (stable) and `.json` (betas) share the same shape; + * both are written by CI on publish. */ export function channelManifestUrl(channel = "latest"): string { - return `${getCliCdnBase()}/channels/${channel}.json`; + return `${getCliCdnBase()}/${channel}.json`; } +/** Immutable per-version asset: `{base}/v{version}/{fileName}`. */ export function releaseAssetUrl(version: string, fileName: string): string { - return `${getCliCdnBase()}/releases/${version}/${fileName}`; + const tag = version.startsWith("v") ? version : `v${version}`; + return `${getCliCdnBase()}/${tag}/${fileName}`; } /** Platform triple used in asset names: `bl---[.exe]`. */ diff --git a/packages/runtime/src/utils/update-checker.ts b/packages/runtime/src/utils/update-checker.ts index 956d769a..67a1907f 100644 --- a/packages/runtime/src/utils/update-checker.ts +++ b/packages/runtime/src/utils/update-checker.ts @@ -1,6 +1,11 @@ import { join } from "path"; import { readFileSync, writeFileSync } from "fs"; -import { getConfigDir, trackingHeaders, getInstallMethod } from "bailian-cli-core"; +import { + DEFAULT_INSTALL_SCRIPT_URL, + 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. */ @@ -257,7 +262,7 @@ export async function performAutoUpdate( } 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`, + ` ${yellow} Re-run:${reset} ${cyan}curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash${reset}\n\n`, ); return false; } diff --git a/tools/release/lib/binary-build.mjs b/tools/release/lib/binary-build.mjs index 25ee40bc..d92b5452 100644 --- a/tools/release/lib/binary-build.mjs +++ b/tools/release/lib/binary-build.mjs @@ -7,8 +7,9 @@ * node tools/release/lib/binary-build.mjs --mode stable --host * * Manifests: - * --mode stable → no release manifest (latest.json removed) - * --mode channel → writes .json only + * --mode stable → writes latest.json (rolling manifest of the implicit + * "latest" channel; OSS prefix root only, not a GH asset) + * --mode channel → writes .json */ import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; @@ -156,10 +157,7 @@ function writeChecksums(outdir, artifacts) { writeFileSync(join(outdir, "SHA256SUMS"), `${lines.join("\n")}\n`); } -/** - * Channel-only: write `.json` with per-platform zip file + sha256. - * Stable no longer emits latest.json. - */ +/** Write the rolling channel manifest (`latest.json` / `.json`) with per-platform zip file + sha256. */ function writeChannelManifest(outdir, version, artifacts, channel) { const assets = Object.fromEntries( artifacts.map((item) => [ @@ -224,9 +222,9 @@ export function buildBinaryArtifacts(rawOptions = {}) { writeChecksums(outdir, artifacts); const extras = ["SHA256SUMS"]; - if (mode === "channel") { - extras.push(writeChannelManifest(outdir, version, artifacts, channel)); - } + extras.push( + writeChannelManifest(outdir, version, artifacts, mode === "channel" ? channel : "latest"), + ); log(`\nBuilt ${artifacts.length} zip(s):`); for (const item of artifacts) { diff --git a/tools/release/lib/binary-options.mjs b/tools/release/lib/binary-options.mjs index f91b4f2e..184a554f 100644 --- a/tools/release/lib/binary-options.mjs +++ b/tools/release/lib/binary-options.mjs @@ -1,9 +1,10 @@ /** * Shared mode / channel / manifest naming for binary-build and binary-release. * - * Stable releases no longer write `latest.json` (OSS `channels/latest.json` is - * maintained outside this repo / by FC). Channel mode still writes `.json` - * onto the rolling `channel-` GitHub Release. + * Every mode writes a rolling channel manifest: stable writes `latest.json` + * (uploaded to the OSS prefix root only, never attached to the GitHub Release), + * channel writes `.json` (attached to the rolling `channel-` + * GitHub Release and uploaded to the OSS prefix root). */ import { assertChannel } from "./validate.mjs"; @@ -27,7 +28,7 @@ export function normalizeModeChannel(mode = "stable", channel = null) { return { mode: "stable", channel: null }; } -/** Channel rolling-manifest basename (`mcp.json`, …). Stable has none. */ +/** Rolling-manifest basename: `latest.json` for stable, `.json` otherwise. */ export function channelManifestFileName(channel) { if (!channel) throw new Error("channelManifestFileName requires a channel name"); return `${channel}.json`; diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index a906a518..0b1d37ad 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -138,7 +138,9 @@ function ossMirrorPlans({ dir, version, mode, channel, files }) { const plans = [{ tag: `v${version}`, paths }]; if (mode === "channel") { const manifest = channelManifestFileName(channel); - plans.push({ tag: `channel-${channel}`, paths: [files ? join(dir, manifest) : manifest] }); + // Rolling channel manifest lives at the OSS prefix root, next to + // manifest.json / latest.json — one flat json per channel. + plans.push({ tag: "", paths: [files ? join(dir, manifest) : manifest] }); } return plans; } @@ -182,6 +184,7 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { await maintainReleaseManifest({ tag: `v${version}`, assetNames: plans[0].paths.map((path) => basename(path)), + channelJsonPath: null, dryRun: true, }); } @@ -198,13 +201,11 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { if (!files.includes("SHA256SUMS")) { throw new Error(`Missing SHA256SUMS in ${dir}`); } - if (mode === "channel") { - const manifestName = channelManifestFileName(channel); - if (!files.includes(manifestName)) { - throw new Error( - `Missing ${manifestName} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`, - ); - } + const rollingManifest = channelManifestFileName(mode === "channel" ? channel : "latest"); + if (!files.includes(rollingManifest)) { + throw new Error( + `Missing ${rollingManifest} in ${dir}. Rebuild with matching --mode/--channel (found: ${files.join(", ") || "(empty)"}).`, + ); } process.stdout.write(`artifacts in ${dir}:\n`); @@ -234,6 +235,7 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { await maintainReleaseManifest({ tag: `v${version}`, assetNames: plans[0].paths.map((path) => basename(path)), + channelJsonPath: join(dir, rollingManifest), dryRun, }); } diff --git a/tools/release/lib/oss-direct-upload.mjs b/tools/release/lib/oss-direct-upload.mjs index 132de8fb..a821a86d 100644 --- a/tools/release/lib/oss-direct-upload.mjs +++ b/tools/release/lib/oss-direct-upload.mjs @@ -4,13 +4,13 @@ * No external FC is involved anymore; CI is the single writer. * * Flow: - * - Every mode uploads its assets to `//` (channel - * builds include the rolling `channel-/.json`). + * - Every mode uploads its assets to `//`; rolling + * channel manifests (`.json`) go 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 manifest.latest - * (compareVersions), rewrite `/manifest.json`. Channel/prerelease - * never touches it — same semantics the FC sync-release used to enforce. + * (compareVersions), rewrite `/manifest.json` and the rolling + * `/latest.json`. Channel/prerelease never touches either. * * Zero-dependency: OSS V1 header signature (HMAC-SHA1) over plain fetch. * @@ -132,12 +132,16 @@ function buildManifest(tag, releasedAt, assetNames, cfg) { * 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 = "" }) { +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}` }; + 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 }; @@ -169,12 +173,23 @@ async function putWithRetry(params, attempts = 3) { } } -/** Remote object byte size; null when the object does not exist. */ +/** + * 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 }); + 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}`); - return Number(res.headers.get("content-length")); + 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. */ @@ -210,8 +225,12 @@ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) { } 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)}` })), + paths.map((path) => ({ + path, + key: [cfg.prefix, tag, basename(path)].filter(Boolean).join("/"), + })), ); if (jobs.length === 0) return { uploaded: 0, skipped: true }; @@ -273,14 +292,24 @@ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) { } /** - * Maintain `/manifest.json` for STABLE releases only: rewrite it when - * `tag` is a newer version than the current `latest` (first write included). - * Same update rule the FC sync-release used to apply. + * Maintain the STABLE pointers at the prefix root: rewrite `manifest.json` + * (and, when `channelJsonPath` is given, the rolling `latest.json`) when `tag` + * is a newer version than the current `latest` (first write included). * - * @param {{ tag: string, assetNames: string[], dryRun?: boolean }} options + * @param {{ + * tag: string, + * assetNames: string[], + * channelJsonPath?: string | null, + * dryRun?: boolean, + * }} options * @returns {Promise<{ updated: boolean, latest: string | null }>} */ -export async function maintainReleaseManifest({ tag, assetNames, dryRun = false }) { +export async function maintainReleaseManifest({ + tag, + assetNames, + channelJsonPath = null, + dryRun = false, +}) { const ctx = ossContext(); if (!ctx) { process.stdout.write("[info] BAILIAN_OSS_AK/SK unset; skip manifest.json maintenance\n"); @@ -291,7 +320,7 @@ export async function maintainReleaseManifest({ tag, assetNames, dryRun = false if (dryRun) { process.stdout.write( - `[dry-run] manifest: GET oss://${cfg.bucket}/${key} → rewrite when ${tag} > latest (assets: ${assetNames.length})\n`, + `[dry-run] manifest: GET oss://${cfg.bucket}/${key} → rewrite manifest.json + latest.json when ${tag} > latest (assets: ${assetNames.length})\n`, ); return { updated: false, latest: null }; } @@ -313,5 +342,15 @@ export async function maintainReleaseManifest({ tag, assetNames, dryRun = false contentType: "application/json", }); process.stdout.write(`manifest.json → latest=${tag} (was ${currentLatest ?? "none"})\n`); + if (channelJsonPath) { + await putObject({ + creds, + cfg, + key: `${cfg.prefix}/latest.json`, + body: readFileSync(channelJsonPath), + contentType: "application/json", + }); + process.stdout.write(`latest.json → ${tag}\n`); + } return { updated: true, latest: tag }; } diff --git a/tools/release/lib/validate.mjs b/tools/release/lib/validate.mjs index 8c6f094f..4b06314d 100644 --- a/tools/release/lib/validate.mjs +++ b/tools/release/lib/validate.mjs @@ -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) { From 7250de9228e6b2e78d486f9e97c614cfe2b2ce43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Mon, 27 Jul 2026 16:56:46 +0800 Subject: [PATCH 12/21] feat: add changelog sync to oss --- .github/workflows/publish.yml | 2 + tools/release/lib/binary-release.mjs | 15 +- tools/release/lib/oss-direct-upload.mjs | 216 ++++++++++++++++++++---- 3 files changed, 203 insertions(+), 30 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6907d97e..59136872 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -79,6 +79,7 @@ jobs: 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: @@ -135,4 +136,5 @@ jobs: 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 }}" diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index 0b1d37ad..13f4da39 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -27,7 +27,11 @@ import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs"; import { buildBinaryArtifacts, matrixAssetNames } from "./binary-build.mjs"; import { channelManifestFileName, normalizeModeChannel } from "./binary-options.mjs"; import { ensureGh, GITHUB_REPOSITORY, upsertRelease } from "./gh-release.mjs"; -import { maintainReleaseManifest, mirrorReleaseAssetsToOss } from "./oss-direct-upload.mjs"; +import { + maintainReleaseManifest, + mirrorReleaseAssetsToOss, + syncStaticFilesToOss, +} from "./oss-direct-upload.mjs"; const DEFAULT_DIR = join(ROOT, "dist-bin"); @@ -179,6 +183,10 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { process.stdout.write(`[dry-run] ${dir} missing; planning expected assets\n`); planDryRunWithoutArtifacts({ version, mode, channel }); const plans = ossMirrorPlans({ dir, version, mode, channel, 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({ @@ -226,6 +234,11 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { 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. diff --git a/tools/release/lib/oss-direct-upload.mjs b/tools/release/lib/oss-direct-upload.mjs index a821a86d..c7d024ed 100644 --- a/tools/release/lib/oss-direct-upload.mjs +++ b/tools/release/lib/oss-direct-upload.mjs @@ -26,6 +26,8 @@ * 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 @@ -69,6 +71,7 @@ function ossHost(cfg) { } 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"; @@ -204,6 +207,64 @@ async function getObjectJson(key, creds, cfg) { } } +/** + * 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>} tasks + * @param {number} limit + * @returns {Promise} + */ +async function runWithConcurrency(tasks, limit) { + const results = new Array(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 `//`, then * HEAD-reconcile every object against the local byte size. @@ -246,48 +307,40 @@ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) { return { uploaded: 0, skipped: false }; } - // Bounded worker pool; collect failures, then throw once at the end. - const failed = []; - let cursor = 0; - const worker = async () => { - while (true) { - const index = cursor++; - if (index >= jobs.length) return; - const { path, key } = jobs[index]; + const results = await runWithConcurrency( + jobs.map((job) => async () => { const startedAt = Date.now(); try { - const body = readFileSync(path); - await putWithRetry({ creds, cfg, key, body, contentType: contentTypeFor(key) }); + const body = readFileSync(job.path); + await putWithRetry({ + creds, + cfg, + key: job.key, + body, + contentType: contentTypeFor(job.key), + }); process.stdout.write( - ` [oss] ok ${key} (${(body.length / 1024 / 1024).toFixed(1)}MB, ${Date.now() - startedAt}ms)\n`, + ` [oss] ok ${job.key} (${(body.length / 1024 / 1024).toFixed(1)}MB, ${Date.now() - startedAt}ms)\n`, ); - } catch (err) { - failed.push({ key, error: err.message }); - process.stdout.write(` [oss] FAIL ${key}: ${err.message}\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 }; } - } - }; - await Promise.all(Array.from({ length: Math.min(4, jobs.length) }, () => worker())); + }), + 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((f) => f.key) + .map((item) => item.key) .join(", ")}`, ); } - // Reconcile: every uploaded object must exist remotely with the local byte size. - for (const { path, key } of jobs) { - const remote = await headObjectSize(key, creds, cfg); - const local = statSync(path).size; - if (remote !== local) { - throw new Error( - `OSS reconcile mismatch for ${key}: local ${local}B vs remote ${remote ?? "missing"}`, - ); - } - } - process.stdout.write(`reconcile ok: ${jobs.length}/${jobs.length} object(s) verified on OSS\n`); + await reconcileUploads(jobs, creds, cfg, "release"); return { uploaded: jobs.length, skipped: false }; } @@ -354,3 +407,108 @@ export async function maintainReleaseManifest({ } 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 `/`. + * 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 }; +} From 467756b31952a8c224916e0869385552917bd551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Tue, 28 Jul 2026 10:17:16 +0800 Subject: [PATCH 13/21] feat: update manifest.json --- packages/core/src/install/cdn.ts | 2 +- tools/release/lib/binary-release.mjs | 7 +- tools/release/lib/oss-direct-upload.mjs | 89 +++++++++---------------- 3 files changed, 34 insertions(+), 64 deletions(-) diff --git a/packages/core/src/install/cdn.ts b/packages/core/src/install/cdn.ts index d9996f41..8460a1ee 100644 --- a/packages/core/src/install/cdn.ts +++ b/packages/core/src/install/cdn.ts @@ -5,7 +5,7 @@ * * Layout under the base: * v/.zip —— immutable per-version binaries + SHA256SUMS - * manifest.json —— stable pointer { latest, releasedAt, assets } + * manifest.json —— stable pointer; same rolling-manifest shape as latest.json * latest.json —— stable rolling manifest (os-arch keyed, sha256) * .json —— per-channel rolling manifests (beta versions) * diff --git a/tools/release/lib/binary-release.mjs b/tools/release/lib/binary-release.mjs index 13f4da39..7143db86 100644 --- a/tools/release/lib/binary-release.mjs +++ b/tools/release/lib/binary-release.mjs @@ -11,7 +11,8 @@ * * 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, HEAD-reconciled, and (stable only) release/manifest.json is updated — + * runner, HEAD-reconciled, and (stable only) release/manifest.json + latest.json + * are rewritten with the SAME rolling-manifest body as channel `.json` — * all in-process, no external FC (see oss-direct-upload.mjs). * * Called by publish-stable.mjs / publish-channel.mjs. @@ -20,7 +21,7 @@ * 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 { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs as parseCliArgs } from "node:util"; import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs"; @@ -191,7 +192,6 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { if (mode === "stable") { await maintainReleaseManifest({ tag: `v${version}`, - assetNames: plans[0].paths.map((path) => basename(path)), channelJsonPath: null, dryRun: true, }); @@ -247,7 +247,6 @@ export async function releaseBinaryArtifacts(rawOptions = {}) { if (mode === "stable" && !mirror.skipped) { await maintainReleaseManifest({ tag: `v${version}`, - assetNames: plans[0].paths.map((path) => basename(path)), channelJsonPath: join(dir, rollingManifest), dryRun, }); diff --git a/tools/release/lib/oss-direct-upload.mjs b/tools/release/lib/oss-direct-upload.mjs index c7d024ed..ae142d0d 100644 --- a/tools/release/lib/oss-direct-upload.mjs +++ b/tools/release/lib/oss-direct-upload.mjs @@ -8,9 +8,11 @@ * channel manifests (`.json`) go 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 manifest.latest + * - Stable only: when the tag is a NEWER version than the current manifest * (compareVersions), rewrite `/manifest.json` and the rolling - * `/latest.json`. Channel/prerelease never touches either. + * `/latest.json` — both carry the SAME rolling-manifest body + * written by binary-build.mjs, so manifest.json shares the channel + * `.json` shape. Channel/prerelease never touches either. * * Zero-dependency: OSS V1 header signature (HMAC-SHA1) over plain fetch. * @@ -101,36 +103,6 @@ export function compareVersions(a, b) { return 0; } -/** Format now (or a given time) as an Asia/Shanghai +08:00 string. */ -function toBeijing(input) { - const d = input ? new Date(input) : new Date(); - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: "Asia/Shanghai", - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }).formatToParts(d); - const g = (t) => parts.find((p) => p.type === t)?.value ?? "00"; - return `${g("year")}-${g("month")}-${g("day")}T${g("hour")}:${g("minute")}:${g("second")}+08:00`; -} - -/** - * Build the manifest.json contents. Public URLs always use the durable region - * endpoint (never the acceleration endpoint used for uploads). - */ -function buildManifest(tag, releasedAt, assetNames, cfg) { - const base = `https://${cfg.bucket}.${cfg.region}.aliyuncs.com/${cfg.prefix}/${tag}`; - const assets = {}; - for (const name of [...assetNames].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))) { - assets[name] = `${base}/${encodeURIComponent(name)}`; - } - return { latest: tag, releasedAt, assets }; -} - /** * 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. @@ -218,7 +190,7 @@ async function getObjectJson(key, creds, cfg) { * @returns {Promise} */ async function runWithConcurrency(tasks, limit) { - const results = new Array(tasks.length); + const results = Array.from({ length: tasks.length }); let nextIndex = 0; async function worker() { @@ -346,23 +318,20 @@ export async function mirrorReleaseAssetsToOss({ plans, dryRun = false }) { /** * Maintain the STABLE pointers at the prefix root: rewrite `manifest.json` - * (and, when `channelJsonPath` is given, the rolling `latest.json`) when `tag` - * is a newer version than the current `latest` (first write included). + * 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: { "-": { file, sha256, inner } } }` + * — identical in shape to the channel `.json` manifests. * * @param {{ * tag: string, - * assetNames: string[], * channelJsonPath?: string | null, * dryRun?: boolean, - * }} options + * }} options `channelJsonPath` is required outside dry-run. * @returns {Promise<{ updated: boolean, latest: string | null }>} */ -export async function maintainReleaseManifest({ - tag, - assetNames, - channelJsonPath = null, - dryRun = false, -}) { +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"); @@ -373,38 +342,40 @@ export async function maintainReleaseManifest({ if (dryRun) { process.stdout.write( - `[dry-run] manifest: GET oss://${cfg.bucket}/${key} → rewrite manifest.json + latest.json when ${tag} > latest (assets: ${assetNames.length})\n`, + `[dry-run] manifest: GET oss://${cfg.bucket}/${key} → rewrite manifest.json + latest.json from ${channelJsonPath ?? ""} 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); - const currentLatest = typeof current?.latest === "string" ? current.latest : null; + // 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 manifest = buildManifest(tag, toBeijing(), assetNames, cfg); + 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, - body: Buffer.from(JSON.stringify(manifest, null, 2)), + key: `${cfg.prefix}/latest.json`, + body, contentType: "application/json", }); - process.stdout.write(`manifest.json → latest=${tag} (was ${currentLatest ?? "none"})\n`); - if (channelJsonPath) { - await putObject({ - creds, - cfg, - key: `${cfg.prefix}/latest.json`, - body: readFileSync(channelJsonPath), - contentType: "application/json", - }); - process.stdout.write(`latest.json → ${tag}\n`); - } + process.stdout.write(`latest.json → ${tag}\n`); return { updated: true, latest: tag }; } From 6465c4a78a11d677ac225d1180ecc860ab33c93e Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Tue, 28 Jul 2026 10:19:54 +0800 Subject: [PATCH 14/21] feat: install shell test --- INSTALL.md | 6 +- README.md | 4 +- README.zh.md | 4 +- docs/agents/publish.md | 6 +- packages/cli/README.md | 4 +- packages/cli/README.zh.md | 4 +- packaging/install.ps1 | 134 +++++++++++++++++++ packaging/install.sh | 272 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 421 insertions(+), 13 deletions(-) create mode 100644 packaging/install.ps1 create mode 100755 packaging/install.sh diff --git a/INSTALL.md b/INSTALL.md index 6136efd2..96f4d9da 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -5,13 +5,15 @@ ## 1. 推荐:二进制安装(无需 Node) ```bash -curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash +curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash ``` +可选:`BAILIAN_CHANNEL`(默认 `latest`)、`BAILIAN_VERSION`(钉版本)、`BAILIAN_CLI_CDN`(覆盖 CDN 根)。 + Windows PowerShell: ```powershell -irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex +irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex ``` 校验: diff --git a/README.md b/README.md index 0bdd1b66..5d7fcd1e 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide ```bash # Recommended — no Node required -curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash +curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash # Windows (PowerShell) -irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex +irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex # Node users / developers (Node.js >= 18.17) npm install -g bailian-cli diff --git a/README.zh.md b/README.zh.md index b726a9e0..5ec63261 100644 --- a/README.zh.md +++ b/README.zh.md @@ -76,10 +76,10 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ ```bash # 推荐 — 无需本机 Node.js -curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash +curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash # Windows(PowerShell) -irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex +irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex # Node 用户 / 开发者(需要 Node.js >= 18.17) npm install -g bailian-cli diff --git a/docs/agents/publish.md b/docs/agents/publish.md index 6532b9dd..95c77deb 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -19,10 +19,10 @@ publish-stable.mjs / publish-channel.mjs ← 唯一发版入口 └─ binary(lib/binary-release → binary-build → gh-release - → oss-sync-webhook) + → oss-direct-upload) ``` -`tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。详细约定见 [binary-distribution](binary-distribution.md)。 +`tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。 两种模式: @@ -108,7 +108,7 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run - [ ] 验证 npm 上能装:`npm view bailian-cli@ version`;如发布 `knowledge-studio-cli`,同时 `npm view knowledge-studio-cli@ version` - [ ] 试装一次:`npm i -g bailian-cli@ && bl --version`;如发布 `knowledge-studio-cli`,同时 `npm i -g knowledge-studio-cli@ && kscli --version` -- [ ] (若本次含二进制)GitHub Release 可见 `bl-*.zip` + `SHA256SUMS`;OSS 侧 `channels/latest.json` 与 install 脚本由 FC/仓外维护;清单见 [binary-distribution.md](binary-distribution.md) +- [ ] (若本次含二进制)GitHub Release 可见 `bl-*.zip` + `SHA256SUMS`;OSS 侧 `latest.json` / `.json`、`v/*` 与 `install.sh`/`install.ps1` 已由 CI 直传 ## 常见漏点(基于历史踩坑) diff --git a/packages/cli/README.md b/packages/cli/README.md index 0bdd1b66..5d7fcd1e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -78,10 +78,10 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide ```bash # Recommended — no Node required -curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash +curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash # Windows (PowerShell) -irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex +irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex # Node users / developers (Node.js >= 18.17) npm install -g bailian-cli diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index b726a9e0..5ec63261 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -76,10 +76,10 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ ```bash # 推荐 — 无需本机 Node.js -curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash +curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash # Windows(PowerShell) -irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex +irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex # Node 用户 / 开发者(需要 Node.js >= 18.17) npm install -g bailian-cli diff --git a/packaging/install.ps1 b/packaging/install.ps1 new file mode 100644 index 00000000..e18c4d4f --- /dev/null +++ b/packaging/install.ps1 @@ -0,0 +1,134 @@ +# Binary install script for the OSS release layout (see packages/core/src/install/cdn.ts). +# +# irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex +# $env:BAILIAN_CHANNEL = "sync-release"; irm ... | iex +# $env:BAILIAN_VERSION = "1.10.1"; irm ... | iex +# +# Layout: +# {CDN}/{channel}.json — rolling manifest (version + assets) +# {CDN}/v{version}/{file}.zip — per-platform zip +param() + +$ErrorActionPreference = "Stop" + +$CdnBase = if ($env:BAILIAN_CLI_CDN) { $env:BAILIAN_CLI_CDN.TrimEnd("/") } else { "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release" } +$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) +} + +function Get-VersionTag([string]$Ver) { + if ($Ver.StartsWith("v")) { return $Ver } + return "v$Ver" +} + +$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" +} +$PlatformKey = "windows-$Arch" + +$ManifestUrl = "$CdnBase/$Channel.json" +Write-Log "Fetching $ManifestUrl" +$Manifest = Invoke-RestMethod -Uri $ManifestUrl + +$PinnedVersion = $Version +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = $Manifest.version +} +if ([string]::IsNullOrWhiteSpace($Version)) { + throw "Could not parse version from $Channel channel manifest" +} + +$ZipFile = $null +$ExpectedSha = $null +$InnerName = $null +$AssetMeta = $null +if ($Manifest.assets -and $Manifest.assets.$PlatformKey) { + $AssetMeta = $Manifest.assets.$PlatformKey +} + +$UseManifestAsset = $AssetMeta -and ( + [string]::IsNullOrWhiteSpace($PinnedVersion) -or ($PinnedVersion -eq $Manifest.version) +) + +if ($UseManifestAsset) { + $ZipFile = $AssetMeta.file + $ExpectedSha = $AssetMeta.sha256 + $InnerName = $AssetMeta.inner +} else { + $ZipFile = "bl-$Version-$PlatformKey.zip" + $InnerName = "bl-$Version-$PlatformKey.exe" +} + +if ([string]::IsNullOrWhiteSpace($ZipFile) -or [string]::IsNullOrWhiteSpace($InnerName)) { + throw "Manifest missing asset metadata for $PlatformKey" +} + +$Tag = Get-VersionTag $Version +$Url = "$CdnBase/$Tag/$ZipFile" +$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("bailian-cli-install-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $TempDir | Out-Null + +try { + $ZipPath = Join-Path $TempDir $ZipFile + Write-Log "Downloading $ZipFile…" + Invoke-WebRequest -Uri $Url -OutFile $ZipPath + + $Actual = (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash.ToLowerInvariant() + if (-not [string]::IsNullOrWhiteSpace($ExpectedSha)) { + if ($ExpectedSha.ToLowerInvariant() -ne $Actual) { + throw "Checksum mismatch for $ZipFile" + } + } else { + $SumsUrl = "$CdnBase/$Tag/SHA256SUMS" + $SumsPath = Join-Path $TempDir "SHA256SUMS" + Invoke-WebRequest -Uri $SumsUrl -OutFile $SumsPath + $Expected = $null + Get-Content $SumsPath | ForEach-Object { + if ($_ -match "^([a-fA-F0-9]+)\s+$([regex]::Escape($ZipFile))\s*$") { + $Expected = $Matches[1].ToLowerInvariant() + } + } + if (-not $Expected) { throw "Checksum for $ZipFile not found in SHA256SUMS" } + if ($Expected -ne $Actual) { throw "Checksum mismatch for $ZipFile" } + } + + Write-Log "Extracting $InnerName…" + Expand-Archive -Path $ZipPath -DestinationPath $TempDir -Force + $ExtractPath = Join-Path $TempDir $InnerName + if (-not (Test-Path -LiteralPath $ExtractPath)) { + # Some Expand-Archive versions nest a single top-level folder. + $Found = Get-ChildItem -Path $TempDir -Recurse -File | Where-Object { $_.Name -eq $InnerName } | Select-Object -First 1 + if (-not $Found) { throw "Extracted binary missing: $InnerName" } + $ExtractPath = $Found.FullName + } + + $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 $ExtractPath $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) [$Channel]" + Write-Log "Done. Run: bl --help" +} +finally { + Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue +} diff --git a/packaging/install.sh b/packaging/install.sh new file mode 100755 index 00000000..f0e98ff3 --- /dev/null +++ b/packaging/install.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# Binary install script for the OSS release layout (see packages/core/src/install/cdn.ts). +# +# curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash +# BAILIAN_CHANNEL=latest bash install.sh +# BAILIAN_CHANNEL=sync-release bash install.sh +# BAILIAN_VERSION=1.10.1 bash install.sh +# +# Layout: +# {CDN}/{channel}.json —— rolling manifest (version + assets) +# {CDN}/v{version}/{file}.zip —— per-platform zip +set -euo pipefail + +CDN_BASE="${BAILIAN_CLI_CDN:-https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release}" +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}" + +ZIP_FILE="" +ZIP_SHA256="" +INNER_NAME="" + +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" +} + +version_tag() { + local ver="$1" + if [[ "${ver}" == v* ]]; then + printf '%s\n' "${ver}" + else + printf 'v%s\n' "${ver}" + fi +} + +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 + + # Rosetta 2: report the native arm64 binary, not the translated x64 view. + 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 + + PLATFORM_KEY="${OS}-${ARCH}" +} + +# First top-level JSON string field: "key": "value" +json_string_field() { + local key="$1" + local file="$2" + sed -n "s/^[[:space:]]*\"${key}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "${file}" | head -n1 +} + +# String field inside the `"": { ... }` asset object. +json_asset_field() { + local platform_key="$1" + local field="$2" + local file="$3" + awk -v platform="${platform_key}" -v field="${field}" ' + BEGIN { in_block = 0 } + $0 ~ "\"" platform "\"[[:space:]]*:" { in_block = 1; next } + in_block && /\{/ { next } + in_block { + if ($0 ~ "\"" field "\"[[:space:]]*:") { + if (match($0, /:[[:space:]]*"[^"]*"/)) { + value = substr($0, RSTART, RLENGTH) + sub(/^:[[:space:]]*"/, "", value) + sub(/"$/, "", value) + print value + exit + } + } + if ($0 ~ /\}/) exit + } + ' "${file}" +} + +# Prints: versionfilesha256inner +# Pure POSIX-ish shell + awk/sed — no python/jq/node. +parse_manifest_asset() { + local manifest_path="$1" + local platform_key="$2" + local pinned_version="${3:-}" + local channel_version file_name sha256_value inner_name version + + channel_version="$(json_string_field version "${manifest_path}")" + [[ -n "${channel_version}" ]] || die "manifest missing version" + + if [[ -n "${pinned_version}" ]]; then + version="${pinned_version}" + else + version="${channel_version}" + fi + + if [[ -z "${pinned_version}" || "${pinned_version}" == "${channel_version}" ]]; then + file_name="$(json_asset_field "${platform_key}" file "${manifest_path}")" + sha256_value="$(json_asset_field "${platform_key}" sha256 "${manifest_path}")" + inner_name="$(json_asset_field "${platform_key}" inner "${manifest_path}")" + else + # Pinned version differs from channel tip — naming convention + SHA256SUMS. + file_name="bl-${version}-${platform_key}.zip" + sha256_value="" + if [[ "${platform_key}" == windows-* ]]; then + inner_name="bl-${version}-${platform_key}.exe" + else + inner_name="bl-${version}-${platform_key}" + fi + fi + + [[ -n "${file_name}" && -n "${inner_name}" ]] \ + || die "manifest missing asset metadata for ${platform_key}" + + printf '%s\t%s\t%s\t%s\n' "${version}" "${file_name}" "${sha256_value}" "${inner_name}" +} + +resolve_asset() { + need_cmd curl + local manifest_path parsed manifest_url + manifest_path="$(mktemp)" + manifest_url="${CDN_BASE}/${CHANNEL}.json" + log "Fetching ${manifest_url}" + if ! curl -fsSL "${manifest_url}" -o "${manifest_path}"; then + rm -f "${manifest_path}" + die "failed to download channel manifest: ${manifest_url}" + fi + + if ! parsed="$(parse_manifest_asset "${manifest_path}" "${PLATFORM_KEY}" "${VERSION}")"; then + rm -f "${manifest_path}" + die "failed to parse asset metadata from ${CHANNEL}.json for ${PLATFORM_KEY}" + fi + rm -f "${manifest_path}" + + VERSION="$(printf '%s\n' "${parsed}" | cut -f1)" + ZIP_FILE="$(printf '%s\n' "${parsed}" | cut -f2)" + ZIP_SHA256="$(printf '%s\n' "${parsed}" | cut -f3)" + INNER_NAME="$(printf '%s\n' "${parsed}" | cut -f4)" + + [[ -n "${VERSION}" && -n "${ZIP_FILE}" && -n "${INNER_NAME}" ]] \ + || die "incomplete asset metadata from ${CHANNEL}.json" +} + +sha256_file() { + local path="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "${path}" | awk '{ print $1 }' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "${path}" | awk '{ print $1 }' + else + die "required command not found: shasum or sha256sum" + fi +} + +verify_checksum() { + local zip_path="$1" + local expected="$2" + local tag sums_url sums_path actual + + if [[ -n "${expected}" ]]; then + actual="$(sha256_file "${zip_path}")" + [[ "${expected}" == "${actual}" ]] || die "checksum mismatch for ${ZIP_FILE}" + return + fi + + # Pinned / convention fallback: verify against per-version SHA256SUMS. + need_cmd curl + tag="$(version_tag "${VERSION}")" + sums_url="${CDN_BASE}/${tag}/SHA256SUMS" + sums_path="$(mktemp)" + curl -fsSL "${sums_url}" -o "${sums_path}" || die "download failed: ${sums_url}" + expected="$(awk -v file="${ZIP_FILE}" '$2 == file { print $1; exit }' "${sums_path}")" + rm -f "${sums_path}" + [[ -n "${expected}" ]] || die "checksum for ${ZIP_FILE} not found in SHA256SUMS" + actual="$(sha256_file "${zip_path}")" + [[ "${expected}" == "${actual}" ]] || die "checksum mismatch for ${ZIP_FILE}" +} + +download_and_install() { + need_cmd curl + need_cmd unzip + + local tag tmp_dir version_dir zip_path extract_path + tag="$(version_tag "${VERSION}")" + tmp_dir="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '${tmp_dir}'" EXIT + + zip_path="${tmp_dir}/${ZIP_FILE}" + log "Downloading ${ZIP_FILE}…" + curl -fsSL "${CDN_BASE}/${tag}/${ZIP_FILE}" -o "${zip_path}" \ + || die "download failed: ${CDN_BASE}/${tag}/${ZIP_FILE}" + + verify_checksum "${zip_path}" "${ZIP_SHA256}" + + log "Extracting ${INNER_NAME}…" + unzip -j -o "${zip_path}" "${INNER_NAME}" -d "${tmp_dir}" >/dev/null \ + || die "failed to extract ${INNER_NAME} from ${ZIP_FILE}" + extract_path="${tmp_dir}/${INNER_NAME}" + [[ -f "${extract_path}" ]] || die "extracted binary missing: ${INNER_NAME}" + + version_dir="${SHARE_DIR}/versions/${VERSION}" + mkdir -p "${version_dir}" "${BIN_DIR}" "${CONFIG_DIR}" + install -m 755 "${extract_path}" "${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_asset + log "Installing bailian-cli ${VERSION} (${PLATFORM_KEY}) from ${CDN_BASE} [${CHANNEL}]" + download_and_install + 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 "$@" From 871c667e977f48c4d73f589db2f24bea58ad7f5e Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Tue, 28 Jul 2026 13:49:51 +0800 Subject: [PATCH 15/21] docs: install shell md --- INSTALL.md | 6 +- README.md | 4 +- README.zh.md | 4 +- packages/cli/README.md | 4 +- packages/cli/README.zh.md | 4 +- packages/core/src/install/cdn.ts | 24 ++++--- packages/core/tests/install-method.test.ts | 17 +++++ packaging/install.ps1 | 33 +++++---- packaging/install.sh | 81 ++++++++++++++++++---- 9 files changed, 130 insertions(+), 47 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 96f4d9da..608c4b8b 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -5,15 +5,15 @@ ## 1. 推荐:二进制安装(无需 Node) ```bash -curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash +curl -fsSL https://bailian.aliyun.com/install.sh | bash ``` -可选:`BAILIAN_CHANNEL`(默认 `latest`)、`BAILIAN_VERSION`(钉版本)、`BAILIAN_CLI_CDN`(覆盖 CDN 根)。 +可选:默认读 `manifest.json`(正式版);`bash install.sh --channel sync-release` 读 channel 清单做验证;`--version` 钉版本;`--cdn` / `BAILIAN_CLI_CDN` 覆盖资源 CDN 根。 Windows PowerShell: ```powershell -irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex +irm https://bailian.aliyun.com/install.ps1 | iex ``` 校验: diff --git a/README.md b/README.md index 5d7fcd1e..e1343feb 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide ```bash # Recommended — no Node required -curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash +curl -fsSL https://bailian.aliyun.com/install.sh | bash # Windows (PowerShell) -irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex +irm https://bailian.aliyun.com/install.ps1 | iex # Node users / developers (Node.js >= 18.17) npm install -g bailian-cli diff --git a/README.zh.md b/README.zh.md index 5ec63261..69eda4bf 100644 --- a/README.zh.md +++ b/README.zh.md @@ -76,10 +76,10 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ ```bash # 推荐 — 无需本机 Node.js -curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash +curl -fsSL https://bailian.aliyun.com/install.sh | bash # Windows(PowerShell) -irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex +irm https://bailian.aliyun.com/install.ps1 | iex # Node 用户 / 开发者(需要 Node.js >= 18.17) npm install -g bailian-cli diff --git a/packages/cli/README.md b/packages/cli/README.md index 5d7fcd1e..e1343feb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -78,10 +78,10 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide ```bash # Recommended — no Node required -curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash +curl -fsSL https://bailian.aliyun.com/install.sh | bash # Windows (PowerShell) -irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex +irm https://bailian.aliyun.com/install.ps1 | iex # Node users / developers (Node.js >= 18.17) npm install -g bailian-cli diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index 5ec63261..69eda4bf 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -76,10 +76,10 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ ```bash # 推荐 — 无需本机 Node.js -curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash +curl -fsSL https://bailian.aliyun.com/install.sh | bash # Windows(PowerShell) -irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex +irm https://bailian.aliyun.com/install.ps1 | iex # Node 用户 / 开发者(需要 Node.js >= 18.17) npm install -g bailian-cli diff --git a/packages/core/src/install/cdn.ts b/packages/core/src/install/cdn.ts index 8460a1ee..a25dd1b2 100644 --- a/packages/core/src/install/cdn.ts +++ b/packages/core/src/install/cdn.ts @@ -5,9 +5,9 @@ * * Layout under the base: * v/.zip —— immutable per-version binaries + SHA256SUMS - * manifest.json —— stable pointer; same rolling-manifest shape as latest.json - * latest.json —— stable rolling manifest (os-arch keyed, sha256) - * .json —— per-channel rolling manifests (beta versions) + * manifest.json —— stable install/update pointer (rolling-manifest shape) + * latest.json —— stable alias; same body as manifest.json + * .json —— per-channel rolling manifests (betas / local verify) * * Override with `BAILIAN_CLI_CDN`. */ @@ -16,8 +16,9 @@ export const DEFAULT_CLI_CDN_BASE = "https://bailian-wiki.oss-cn-hangzhou.aliyun /** 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`; +/** User-facing install entry (docs / update hints); asset downloads still use getCliCdnBase(). */ +export const DEFAULT_INSTALL_SCRIPT_URL = "https://bailian.aliyun.com/install.sh"; +export const DEFAULT_INSTALL_PS1_URL = "https://bailian.aliyun.com/install.ps1"; export function getCliCdnBase(): string { const fromEnv = process.env.BAILIAN_CLI_CDN?.trim(); @@ -26,12 +27,17 @@ export function getCliCdnBase(): string { } /** - * Rolling channel manifest at the CDN base root: `{base}/{channel}.json`. - * `latest.json` (stable) and `.json` (betas) share the same shape; - * both are written by CI on publish. + * Rolling manifest URL at the CDN base root. + * Stable (`latest` / `stable` / empty) → `manifest.json`. + * Named channel → `{channel}.json` (e.g. sync-release for local verify). + * All share the same rolling-manifest shape from binary-build. */ export function channelManifestUrl(channel = "latest"): string { - return `${getCliCdnBase()}/${channel}.json`; + 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}`. */ diff --git a/packages/core/tests/install-method.test.ts b/packages/core/tests/install-method.test.ts index e4aa5577..2c1f0859 100644 --- a/packages/core/tests/install-method.test.ts +++ b/packages/core/tests/install-method.test.ts @@ -33,3 +33,20 @@ 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; +}); diff --git a/packaging/install.ps1 b/packaging/install.ps1 index e18c4d4f..2dcd2703 100644 --- a/packaging/install.ps1 +++ b/packaging/install.ps1 @@ -1,19 +1,18 @@ -# Binary install script for the OSS release layout (see packages/core/src/install/cdn.ts). +# Binary install script for the OSS release # -# irm https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.ps1 | iex -# $env:BAILIAN_CHANNEL = "sync-release"; irm ... | iex -# $env:BAILIAN_VERSION = "1.10.1"; irm ... | iex +# irm https://bailian.aliyun.com/install.ps1 | iex +# .\install.ps1 -Channel sync-release # local / channel verify +# .\install.ps1 -Version 1.10.1 # -# Layout: -# {CDN}/{channel}.json — rolling manifest (version + assets) -# {CDN}/v{version}/{file}.zip — per-platform zip -param() +param( + [string]$Channel = $env:BAILIAN_CHANNEL, + [string]$Version = $env:BAILIAN_VERSION, + [string]$Cdn = $env:BAILIAN_CLI_CDN +) $ErrorActionPreference = "Stop" -$CdnBase = if ($env:BAILIAN_CLI_CDN) { $env:BAILIAN_CLI_CDN.TrimEnd("/") } else { "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release" } -$Channel = if ($env:BAILIAN_CHANNEL) { $env:BAILIAN_CHANNEL } else { "latest" } -$Version = $env:BAILIAN_VERSION +$CdnBase = if ($Cdn) { $Cdn.TrimEnd("/") } else { "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release" } $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" } @@ -32,7 +31,13 @@ if ($Arch -eq "arm64") { } $PlatformKey = "windows-$Arch" -$ManifestUrl = "$CdnBase/$Channel.json" +if (-not [string]::IsNullOrWhiteSpace($Channel) -and $Channel -ne "latest" -and $Channel -ne "stable") { + $ManifestLabel = "$Channel.json" + $ManifestUrl = "$CdnBase/$Channel.json" +} else { + $ManifestLabel = "manifest.json" + $ManifestUrl = "$CdnBase/manifest.json" +} Write-Log "Fetching $ManifestUrl" $Manifest = Invoke-RestMethod -Uri $ManifestUrl @@ -41,7 +46,7 @@ if ([string]::IsNullOrWhiteSpace($Version)) { $Version = $Manifest.version } if ([string]::IsNullOrWhiteSpace($Version)) { - throw "Could not parse version from $Channel channel manifest" + throw "Could not parse version from $ManifestLabel" } $ZipFile = $null @@ -126,7 +131,7 @@ try { Write-Log "Added $ShimDir to your user PATH. Open a new terminal to use bl." } - Write-Log "Installed: $Target (bailian-cli $Version) [$Channel]" + Write-Log "Installed: $Target (bailian-cli $Version) [$ManifestLabel]" Write-Log "Done. Run: bl --help" } finally { diff --git a/packaging/install.sh b/packaging/install.sh index f0e98ff3..5f47a457 100755 --- a/packaging/install.sh +++ b/packaging/install.sh @@ -1,19 +1,16 @@ #!/usr/bin/env bash -# Binary install script for the OSS release layout (see packages/core/src/install/cdn.ts). +# Binary install script for the OSS release # -# curl -fsSL https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/install.sh | bash -# BAILIAN_CHANNEL=latest bash install.sh -# BAILIAN_CHANNEL=sync-release bash install.sh -# BAILIAN_VERSION=1.10.1 bash install.sh +# curl -fsSL https://bailian.aliyun.com/install.sh | bash +# bash install.sh --channel sync-release # local / channel verify +# bash install.sh --version 1.10.1 +# curl -fsSL …/install.sh | bash -s -- --channel sync-release # -# Layout: -# {CDN}/{channel}.json —— rolling manifest (version + assets) -# {CDN}/v{version}/{file}.zip —— per-platform zip set -euo pipefail CDN_BASE="${BAILIAN_CLI_CDN:-https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release}" CDN_BASE="${CDN_BASE%/}" -CHANNEL="${BAILIAN_CHANNEL:-latest}" +CHANNEL="${BAILIAN_CHANNEL:-}" VERSION="${BAILIAN_VERSION:-}" BIN_DIR="${BAILIAN_BIN_DIR:-${HOME}/.local/bin}" SHARE_DIR="${BAILIAN_SHARE_DIR:-${HOME}/.local/share/bailian-cli}" @@ -22,10 +19,61 @@ CONFIG_DIR="${BAILIAN_CONFIG_DIR:-${HOME}/.bailian}" ZIP_FILE="" ZIP_SHA256="" INNER_NAME="" +MANIFEST_LABEL="manifest.json" log() { printf '%s\n' "$*" >&2; } die() { log "error: $*"; exit 1; } +usage() { + cat >&2 <<'EOF' +Usage: install.sh [--channel ] [--version ] [--cdn ] [--help] + + --channel Install from {CDN}/{name}.json (default: manifest.json) + --version Pin a release version (checksum via SHA256SUMS if needed) + --cdn Override CDN base (default / BAILIAN_CLI_CDN) + --help Show this help + +Env fallbacks: BAILIAN_CHANNEL, BAILIAN_VERSION, BAILIAN_CLI_CDN, + BAILIAN_BIN_DIR, BAILIAN_SHARE_DIR, BAILIAN_CONFIG_DIR +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --channel|-c) + [[ $# -ge 2 ]] || die "$1 requires a value" + CHANNEL="$2" + shift 2 + ;; + --version) + [[ $# -ge 2 ]] || die "$1 requires a value" + VERSION="$2" + shift 2 + ;; + --cdn) + [[ $# -ge 2 ]] || die "$1 requires a value" + CDN_BASE="${2%/}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) + die "unknown option: $1 (try --help)" + ;; + *) + die "unexpected argument: $1 (try --help)" + ;; + esac + done +} + need_cmd() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" } @@ -141,7 +189,13 @@ resolve_asset() { need_cmd curl local manifest_path parsed manifest_url manifest_path="$(mktemp)" - manifest_url="${CDN_BASE}/${CHANNEL}.json" + if [[ -n "${CHANNEL}" && "${CHANNEL}" != "latest" && "${CHANNEL}" != "stable" ]]; then + MANIFEST_LABEL="${CHANNEL}.json" + manifest_url="${CDN_BASE}/${CHANNEL}.json" + else + MANIFEST_LABEL="manifest.json" + manifest_url="${CDN_BASE}/manifest.json" + fi log "Fetching ${manifest_url}" if ! curl -fsSL "${manifest_url}" -o "${manifest_path}"; then rm -f "${manifest_path}" @@ -150,7 +204,7 @@ resolve_asset() { if ! parsed="$(parse_manifest_asset "${manifest_path}" "${PLATFORM_KEY}" "${VERSION}")"; then rm -f "${manifest_path}" - die "failed to parse asset metadata from ${CHANNEL}.json for ${PLATFORM_KEY}" + die "failed to parse asset metadata from ${MANIFEST_LABEL} for ${PLATFORM_KEY}" fi rm -f "${manifest_path}" @@ -160,7 +214,7 @@ resolve_asset() { INNER_NAME="$(printf '%s\n' "${parsed}" | cut -f4)" [[ -n "${VERSION}" && -n "${ZIP_FILE}" && -n "${INNER_NAME}" ]] \ - || die "incomplete asset metadata from ${CHANNEL}.json" + || die "incomplete asset metadata from ${MANIFEST_LABEL}" } sha256_file() { @@ -253,11 +307,12 @@ warn_npm_conflict() { } main() { + parse_args "$@" need_cmd curl need_cmd uname detect_os_arch resolve_asset - log "Installing bailian-cli ${VERSION} (${PLATFORM_KEY}) from ${CDN_BASE} [${CHANNEL}]" + log "Installing bailian-cli ${VERSION} (${PLATFORM_KEY}) from ${CDN_BASE} [${MANIFEST_LABEL}]" download_and_install warn_npm_conflict warn_path From 952f2277a4627f8030f1bbb493d0076dcbe17e63 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Tue, 28 Jul 2026 13:51:22 +0800 Subject: [PATCH 16/21] docs: install shell md --- packaging/install.ps1 | 139 ------------------ packaging/install.sh | 327 ------------------------------------------ 2 files changed, 466 deletions(-) delete mode 100644 packaging/install.ps1 delete mode 100755 packaging/install.sh diff --git a/packaging/install.ps1 b/packaging/install.ps1 deleted file mode 100644 index 2dcd2703..00000000 --- a/packaging/install.ps1 +++ /dev/null @@ -1,139 +0,0 @@ -# Binary install script for the OSS release -# -# irm https://bailian.aliyun.com/install.ps1 | iex -# .\install.ps1 -Channel sync-release # local / channel verify -# .\install.ps1 -Version 1.10.1 -# -param( - [string]$Channel = $env:BAILIAN_CHANNEL, - [string]$Version = $env:BAILIAN_VERSION, - [string]$Cdn = $env:BAILIAN_CLI_CDN -) - -$ErrorActionPreference = "Stop" - -$CdnBase = if ($Cdn) { $Cdn.TrimEnd("/") } else { "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release" } -$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) -} - -function Get-VersionTag([string]$Ver) { - if ($Ver.StartsWith("v")) { return $Ver } - return "v$Ver" -} - -$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" -} -$PlatformKey = "windows-$Arch" - -if (-not [string]::IsNullOrWhiteSpace($Channel) -and $Channel -ne "latest" -and $Channel -ne "stable") { - $ManifestLabel = "$Channel.json" - $ManifestUrl = "$CdnBase/$Channel.json" -} else { - $ManifestLabel = "manifest.json" - $ManifestUrl = "$CdnBase/manifest.json" -} -Write-Log "Fetching $ManifestUrl" -$Manifest = Invoke-RestMethod -Uri $ManifestUrl - -$PinnedVersion = $Version -if ([string]::IsNullOrWhiteSpace($Version)) { - $Version = $Manifest.version -} -if ([string]::IsNullOrWhiteSpace($Version)) { - throw "Could not parse version from $ManifestLabel" -} - -$ZipFile = $null -$ExpectedSha = $null -$InnerName = $null -$AssetMeta = $null -if ($Manifest.assets -and $Manifest.assets.$PlatformKey) { - $AssetMeta = $Manifest.assets.$PlatformKey -} - -$UseManifestAsset = $AssetMeta -and ( - [string]::IsNullOrWhiteSpace($PinnedVersion) -or ($PinnedVersion -eq $Manifest.version) -) - -if ($UseManifestAsset) { - $ZipFile = $AssetMeta.file - $ExpectedSha = $AssetMeta.sha256 - $InnerName = $AssetMeta.inner -} else { - $ZipFile = "bl-$Version-$PlatformKey.zip" - $InnerName = "bl-$Version-$PlatformKey.exe" -} - -if ([string]::IsNullOrWhiteSpace($ZipFile) -or [string]::IsNullOrWhiteSpace($InnerName)) { - throw "Manifest missing asset metadata for $PlatformKey" -} - -$Tag = Get-VersionTag $Version -$Url = "$CdnBase/$Tag/$ZipFile" -$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("bailian-cli-install-" + [guid]::NewGuid().ToString("N")) -New-Item -ItemType Directory -Path $TempDir | Out-Null - -try { - $ZipPath = Join-Path $TempDir $ZipFile - Write-Log "Downloading $ZipFile…" - Invoke-WebRequest -Uri $Url -OutFile $ZipPath - - $Actual = (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash.ToLowerInvariant() - if (-not [string]::IsNullOrWhiteSpace($ExpectedSha)) { - if ($ExpectedSha.ToLowerInvariant() -ne $Actual) { - throw "Checksum mismatch for $ZipFile" - } - } else { - $SumsUrl = "$CdnBase/$Tag/SHA256SUMS" - $SumsPath = Join-Path $TempDir "SHA256SUMS" - Invoke-WebRequest -Uri $SumsUrl -OutFile $SumsPath - $Expected = $null - Get-Content $SumsPath | ForEach-Object { - if ($_ -match "^([a-fA-F0-9]+)\s+$([regex]::Escape($ZipFile))\s*$") { - $Expected = $Matches[1].ToLowerInvariant() - } - } - if (-not $Expected) { throw "Checksum for $ZipFile not found in SHA256SUMS" } - if ($Expected -ne $Actual) { throw "Checksum mismatch for $ZipFile" } - } - - Write-Log "Extracting $InnerName…" - Expand-Archive -Path $ZipPath -DestinationPath $TempDir -Force - $ExtractPath = Join-Path $TempDir $InnerName - if (-not (Test-Path -LiteralPath $ExtractPath)) { - # Some Expand-Archive versions nest a single top-level folder. - $Found = Get-ChildItem -Path $TempDir -Recurse -File | Where-Object { $_.Name -eq $InnerName } | Select-Object -First 1 - if (-not $Found) { throw "Extracted binary missing: $InnerName" } - $ExtractPath = $Found.FullName - } - - $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 $ExtractPath $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) [$ManifestLabel]" - Write-Log "Done. Run: bl --help" -} -finally { - Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue -} diff --git a/packaging/install.sh b/packaging/install.sh deleted file mode 100755 index 5f47a457..00000000 --- a/packaging/install.sh +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env bash -# Binary install script for the OSS release -# -# curl -fsSL https://bailian.aliyun.com/install.sh | bash -# bash install.sh --channel sync-release # local / channel verify -# bash install.sh --version 1.10.1 -# curl -fsSL …/install.sh | bash -s -- --channel sync-release -# -set -euo pipefail - -CDN_BASE="${BAILIAN_CLI_CDN:-https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release}" -CDN_BASE="${CDN_BASE%/}" -CHANNEL="${BAILIAN_CHANNEL:-}" -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}" - -ZIP_FILE="" -ZIP_SHA256="" -INNER_NAME="" -MANIFEST_LABEL="manifest.json" - -log() { printf '%s\n' "$*" >&2; } -die() { log "error: $*"; exit 1; } - -usage() { - cat >&2 <<'EOF' -Usage: install.sh [--channel ] [--version ] [--cdn ] [--help] - - --channel Install from {CDN}/{name}.json (default: manifest.json) - --version Pin a release version (checksum via SHA256SUMS if needed) - --cdn Override CDN base (default / BAILIAN_CLI_CDN) - --help Show this help - -Env fallbacks: BAILIAN_CHANNEL, BAILIAN_VERSION, BAILIAN_CLI_CDN, - BAILIAN_BIN_DIR, BAILIAN_SHARE_DIR, BAILIAN_CONFIG_DIR -EOF -} - -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - --channel|-c) - [[ $# -ge 2 ]] || die "$1 requires a value" - CHANNEL="$2" - shift 2 - ;; - --version) - [[ $# -ge 2 ]] || die "$1 requires a value" - VERSION="$2" - shift 2 - ;; - --cdn) - [[ $# -ge 2 ]] || die "$1 requires a value" - CDN_BASE="${2%/}" - shift 2 - ;; - --help|-h) - usage - exit 0 - ;; - --) - shift - break - ;; - -*) - die "unknown option: $1 (try --help)" - ;; - *) - die "unexpected argument: $1 (try --help)" - ;; - esac - done -} - -need_cmd() { - command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" -} - -version_tag() { - local ver="$1" - if [[ "${ver}" == v* ]]; then - printf '%s\n' "${ver}" - else - printf 'v%s\n' "${ver}" - fi -} - -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 - - # Rosetta 2: report the native arm64 binary, not the translated x64 view. - 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 - - PLATFORM_KEY="${OS}-${ARCH}" -} - -# First top-level JSON string field: "key": "value" -json_string_field() { - local key="$1" - local file="$2" - sed -n "s/^[[:space:]]*\"${key}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" "${file}" | head -n1 -} - -# String field inside the `"": { ... }` asset object. -json_asset_field() { - local platform_key="$1" - local field="$2" - local file="$3" - awk -v platform="${platform_key}" -v field="${field}" ' - BEGIN { in_block = 0 } - $0 ~ "\"" platform "\"[[:space:]]*:" { in_block = 1; next } - in_block && /\{/ { next } - in_block { - if ($0 ~ "\"" field "\"[[:space:]]*:") { - if (match($0, /:[[:space:]]*"[^"]*"/)) { - value = substr($0, RSTART, RLENGTH) - sub(/^:[[:space:]]*"/, "", value) - sub(/"$/, "", value) - print value - exit - } - } - if ($0 ~ /\}/) exit - } - ' "${file}" -} - -# Prints: versionfilesha256inner -# Pure POSIX-ish shell + awk/sed — no python/jq/node. -parse_manifest_asset() { - local manifest_path="$1" - local platform_key="$2" - local pinned_version="${3:-}" - local channel_version file_name sha256_value inner_name version - - channel_version="$(json_string_field version "${manifest_path}")" - [[ -n "${channel_version}" ]] || die "manifest missing version" - - if [[ -n "${pinned_version}" ]]; then - version="${pinned_version}" - else - version="${channel_version}" - fi - - if [[ -z "${pinned_version}" || "${pinned_version}" == "${channel_version}" ]]; then - file_name="$(json_asset_field "${platform_key}" file "${manifest_path}")" - sha256_value="$(json_asset_field "${platform_key}" sha256 "${manifest_path}")" - inner_name="$(json_asset_field "${platform_key}" inner "${manifest_path}")" - else - # Pinned version differs from channel tip — naming convention + SHA256SUMS. - file_name="bl-${version}-${platform_key}.zip" - sha256_value="" - if [[ "${platform_key}" == windows-* ]]; then - inner_name="bl-${version}-${platform_key}.exe" - else - inner_name="bl-${version}-${platform_key}" - fi - fi - - [[ -n "${file_name}" && -n "${inner_name}" ]] \ - || die "manifest missing asset metadata for ${platform_key}" - - printf '%s\t%s\t%s\t%s\n' "${version}" "${file_name}" "${sha256_value}" "${inner_name}" -} - -resolve_asset() { - need_cmd curl - local manifest_path parsed manifest_url - manifest_path="$(mktemp)" - if [[ -n "${CHANNEL}" && "${CHANNEL}" != "latest" && "${CHANNEL}" != "stable" ]]; then - MANIFEST_LABEL="${CHANNEL}.json" - manifest_url="${CDN_BASE}/${CHANNEL}.json" - else - MANIFEST_LABEL="manifest.json" - manifest_url="${CDN_BASE}/manifest.json" - fi - log "Fetching ${manifest_url}" - if ! curl -fsSL "${manifest_url}" -o "${manifest_path}"; then - rm -f "${manifest_path}" - die "failed to download channel manifest: ${manifest_url}" - fi - - if ! parsed="$(parse_manifest_asset "${manifest_path}" "${PLATFORM_KEY}" "${VERSION}")"; then - rm -f "${manifest_path}" - die "failed to parse asset metadata from ${MANIFEST_LABEL} for ${PLATFORM_KEY}" - fi - rm -f "${manifest_path}" - - VERSION="$(printf '%s\n' "${parsed}" | cut -f1)" - ZIP_FILE="$(printf '%s\n' "${parsed}" | cut -f2)" - ZIP_SHA256="$(printf '%s\n' "${parsed}" | cut -f3)" - INNER_NAME="$(printf '%s\n' "${parsed}" | cut -f4)" - - [[ -n "${VERSION}" && -n "${ZIP_FILE}" && -n "${INNER_NAME}" ]] \ - || die "incomplete asset metadata from ${MANIFEST_LABEL}" -} - -sha256_file() { - local path="$1" - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "${path}" | awk '{ print $1 }' - elif command -v sha256sum >/dev/null 2>&1; then - sha256sum "${path}" | awk '{ print $1 }' - else - die "required command not found: shasum or sha256sum" - fi -} - -verify_checksum() { - local zip_path="$1" - local expected="$2" - local tag sums_url sums_path actual - - if [[ -n "${expected}" ]]; then - actual="$(sha256_file "${zip_path}")" - [[ "${expected}" == "${actual}" ]] || die "checksum mismatch for ${ZIP_FILE}" - return - fi - - # Pinned / convention fallback: verify against per-version SHA256SUMS. - need_cmd curl - tag="$(version_tag "${VERSION}")" - sums_url="${CDN_BASE}/${tag}/SHA256SUMS" - sums_path="$(mktemp)" - curl -fsSL "${sums_url}" -o "${sums_path}" || die "download failed: ${sums_url}" - expected="$(awk -v file="${ZIP_FILE}" '$2 == file { print $1; exit }' "${sums_path}")" - rm -f "${sums_path}" - [[ -n "${expected}" ]] || die "checksum for ${ZIP_FILE} not found in SHA256SUMS" - actual="$(sha256_file "${zip_path}")" - [[ "${expected}" == "${actual}" ]] || die "checksum mismatch for ${ZIP_FILE}" -} - -download_and_install() { - need_cmd curl - need_cmd unzip - - local tag tmp_dir version_dir zip_path extract_path - tag="$(version_tag "${VERSION}")" - tmp_dir="$(mktemp -d)" - # shellcheck disable=SC2064 - trap "rm -rf '${tmp_dir}'" EXIT - - zip_path="${tmp_dir}/${ZIP_FILE}" - log "Downloading ${ZIP_FILE}…" - curl -fsSL "${CDN_BASE}/${tag}/${ZIP_FILE}" -o "${zip_path}" \ - || die "download failed: ${CDN_BASE}/${tag}/${ZIP_FILE}" - - verify_checksum "${zip_path}" "${ZIP_SHA256}" - - log "Extracting ${INNER_NAME}…" - unzip -j -o "${zip_path}" "${INNER_NAME}" -d "${tmp_dir}" >/dev/null \ - || die "failed to extract ${INNER_NAME} from ${ZIP_FILE}" - extract_path="${tmp_dir}/${INNER_NAME}" - [[ -f "${extract_path}" ]] || die "extracted binary missing: ${INNER_NAME}" - - version_dir="${SHARE_DIR}/versions/${VERSION}" - mkdir -p "${version_dir}" "${BIN_DIR}" "${CONFIG_DIR}" - install -m 755 "${extract_path}" "${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() { - parse_args "$@" - need_cmd curl - need_cmd uname - detect_os_arch - resolve_asset - log "Installing bailian-cli ${VERSION} (${PLATFORM_KEY}) from ${CDN_BASE} [${MANIFEST_LABEL}]" - download_and_install - 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 "$@" From fb0c4b81be047bebcffd82a3291c3e7c5e12a6f7 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Tue, 28 Jul 2026 14:08:32 +0800 Subject: [PATCH 17/21] docs: install shell md --- AGENTS.md | 3 +-- INSTALL.md | 10 +++++++++- docs/agents/publish.md | 2 -- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7612e4de..213f4eb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,8 +68,7 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/ | 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | | 配置项扩展 | 新 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) | +| 发布 | channel / stable 发 npm + 二进制(Bun / GitHub Release / OSS);安装脚本仓外维护 | [docs/agents/publish.md](docs/agents/publish.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) | diff --git a/INSTALL.md b/INSTALL.md index 608c4b8b..475dc3b6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -8,7 +8,13 @@ curl -fsSL https://bailian.aliyun.com/install.sh | bash ``` -可选:默认读 `manifest.json`(正式版);`bash install.sh --channel sync-release` 读 channel 清单做验证;`--version` 钉版本;`--cdn` / `BAILIAN_CLI_CDN` 覆盖资源 CDN 根。 +可选:正式安装读 CDN 上的 `manifest.json`。预发 / channel 验证示例: + +```bash +curl -fsSL https://bailian.aliyun.com/install.sh | bash -s -- --channel sync-release +``` + +也可用 `--version` / `--cdn`(或环境变量 `BAILIAN_CLI_CDN`)覆盖资源根。安装脚本本身不在本仓库维护。 Windows PowerShell: @@ -16,6 +22,8 @@ Windows PowerShell: irm https://bailian.aliyun.com/install.ps1 | iex ``` +带参时先落盘再执行(`irm | iex` 不便传参),或使用仓外静态资源文档中的预发入口。 + 校验: ```bash diff --git a/docs/agents/publish.md b/docs/agents/publish.md index 95c77deb..2cdc71dd 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -47,7 +47,6 @@ publish-stable.mjs / publish-channel.mjs ← 唯一发版入口 4. CI 自动:自检 → **npm 发到 latest** → **推送 git tag `v`** → **Bun 编二进制并创建/更新 GitHub Release** → 完成 5. 如果所选发布集合的当前版本已全部存在于 npm,stable 发布会失败并提示先升级版本号;如果只有部分包已发布,CI 会继续补发缺失包 6. 对应脚本:`tools/release/publish-stable.mjs` -7. 二进制上传使用 `GITHUB_TOKEN`(`gh release`),**不需要** OSS AccessKey ## 自检(`tools/release/check.mjs`) @@ -108,7 +107,6 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run - [ ] 验证 npm 上能装:`npm view bailian-cli@ version`;如发布 `knowledge-studio-cli`,同时 `npm view knowledge-studio-cli@ version` - [ ] 试装一次:`npm i -g bailian-cli@ && bl --version`;如发布 `knowledge-studio-cli`,同时 `npm i -g knowledge-studio-cli@ && kscli --version` -- [ ] (若本次含二进制)GitHub Release 可见 `bl-*.zip` + `SHA256SUMS`;OSS 侧 `latest.json` / `.json`、`v/*` 与 `install.sh`/`install.ps1` 已由 CI 直传 ## 常见漏点(基于历史踩坑) From 434aac5b081a9ef7363414ecbe77b1aef17e1241 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Thu, 30 Jul 2026 10:05:31 +0800 Subject: [PATCH 18/21] docs: install shell md --- INSTALL.md | 6 +++--- README.md | 4 ++-- README.zh.md | 4 ++-- packages/cli/README.md | 4 ++-- packages/cli/README.zh.md | 4 ++-- packages/core/src/install/cdn.ts | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 475dc3b6..41096255 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -5,13 +5,13 @@ ## 1. 推荐:二进制安装(无需 Node) ```bash -curl -fsSL https://bailian.aliyun.com/install.sh | bash +curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash ``` 可选:正式安装读 CDN 上的 `manifest.json`。预发 / channel 验证示例: ```bash -curl -fsSL https://bailian.aliyun.com/install.sh | bash -s -- --channel sync-release +curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash -s -- --channel sync-release ``` 也可用 `--version` / `--cdn`(或环境变量 `BAILIAN_CLI_CDN`)覆盖资源根。安装脚本本身不在本仓库维护。 @@ -19,7 +19,7 @@ curl -fsSL https://bailian.aliyun.com/install.sh | bash -s -- --channel sync-rel Windows PowerShell: ```powershell -irm https://bailian.aliyun.com/install.ps1 | iex +irm https://bailian.aliyun.com/cli/install.ps1 | iex ``` 带参时先落盘再执行(`irm | iex` 不便传参),或使用仓外静态资源文档中的预发入口。 diff --git a/README.md b/README.md index e1343feb..2acb3558 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide ```bash # Recommended — no Node required -curl -fsSL https://bailian.aliyun.com/install.sh | bash +curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash # Windows (PowerShell) -irm https://bailian.aliyun.com/install.ps1 | iex +irm https://bailian.aliyun.com/cli/install.ps1 | iex # Node users / developers (Node.js >= 18.17) npm install -g bailian-cli diff --git a/README.zh.md b/README.zh.md index 69eda4bf..a1321f0d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -76,10 +76,10 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ ```bash # 推荐 — 无需本机 Node.js -curl -fsSL https://bailian.aliyun.com/install.sh | bash +curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash # Windows(PowerShell) -irm https://bailian.aliyun.com/install.ps1 | iex +irm https://bailian.aliyun.com/cli/install.ps1 | iex # Node 用户 / 开发者(需要 Node.js >= 18.17) npm install -g bailian-cli diff --git a/packages/cli/README.md b/packages/cli/README.md index e1343feb..2acb3558 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -78,10 +78,10 @@ No timeline scrubbing. No frame-by-frame editing. Just one sentence → one vide ```bash # Recommended — no Node required -curl -fsSL https://bailian.aliyun.com/install.sh | bash +curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash # Windows (PowerShell) -irm https://bailian.aliyun.com/install.ps1 | iex +irm https://bailian.aliyun.com/cli/install.ps1 | iex # Node users / developers (Node.js >= 18.17) npm install -g bailian-cli diff --git a/packages/cli/README.zh.md b/packages/cli/README.zh.md index 69eda4bf..a1321f0d 100644 --- a/packages/cli/README.zh.md +++ b/packages/cli/README.zh.md @@ -76,10 +76,10 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_ ```bash # 推荐 — 无需本机 Node.js -curl -fsSL https://bailian.aliyun.com/install.sh | bash +curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash # Windows(PowerShell) -irm https://bailian.aliyun.com/install.ps1 | iex +irm https://bailian.aliyun.com/cli/install.ps1 | iex # Node 用户 / 开发者(需要 Node.js >= 18.17) npm install -g bailian-cli diff --git a/packages/core/src/install/cdn.ts b/packages/core/src/install/cdn.ts index a25dd1b2..7cfc4c67 100644 --- a/packages/core/src/install/cdn.ts +++ b/packages/core/src/install/cdn.ts @@ -17,8 +17,8 @@ export const DEFAULT_CLI_CDN_BASE = "https://bailian-wiki.oss-cn-hangzhou.aliyun 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/install.sh"; -export const DEFAULT_INSTALL_PS1_URL = "https://bailian.aliyun.com/install.ps1"; +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(); From 54b95ed12201716606cfa4c0ceb94b086ee9ec44 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Thu, 30 Jul 2026 10:20:53 +0800 Subject: [PATCH 19/21] Merge branch main of github.com:modelstudioai/cli into feat/multi-channel-install --- INSTALL.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index f5ceb754..89987e8e 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -31,8 +31,6 @@ bl --version which bl # Windows: where.exe bl ``` -若 `command not found`:把 `~/.local/bin`(Windows 为 `%LOCALAPPDATA%\bailian-cli\bin`)加入 PATH 后开新终端。 - > CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装。 ## 2. 备选:npm 安装(要求 **≥ 18.17.0**) From 6870dc50a6aec1a0f7699edc7b8d1e99049b2067 Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Thu, 30 Jul 2026 10:32:39 +0800 Subject: [PATCH 20/21] style: fix AGENTS.md table formatting for vp check Co-authored-by: Cursor --- AGENTS.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b13a7129..5ad555ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,23 +56,23 @@ 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) | -| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | -| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | -| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | -| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | -| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | -| 配置项扩展 | 新 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) | -| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) | +| 场景 | 何时进入 | 详见 | +| -------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| 命令增删改 | 增加 / 删除 / 重命名 `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) | +| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | +| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | +| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | +| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | +| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | +| 配置项扩展 | 新 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) | +| 安装文档 | 改安装、鉴权、验证流程或线上 install 页面 | [docs/agents/install-doc-change.md](docs/agents/install-doc-change.md) | | 发布 | channel / stable 发 npm + 二进制(Bun / GitHub Release / OSS);安装脚本仓外维护 | [docs/agents/publish.md](docs/agents/publish.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) | +| 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) | 如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/.md`,把清单沉淀下来。 From 389c93239042f29444690df8619961a31a33764a Mon Sep 17 00:00:00 2001 From: rendianmeng Date: Thu, 30 Jul 2026 11:38:40 +0800 Subject: [PATCH 21/21] test(runtime): expect npm --version probe in command pack install Co-authored-by: Cursor --- packages/runtime/tests/command-packs.test.ts | 23 +++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/runtime/tests/command-packs.test.ts b/packages/runtime/tests/command-packs.test.ts index 19546d7e..6ea9dcab 100644 --- a/packages/runtime/tests/command-packs.test.ts +++ b/packages/runtime/tests/command-packs.test.ts @@ -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; }, ); - 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; }, ); - 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(