diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c45f8b4..23a33a3 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 f1c88cb..6b33a8e 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 d26a7a5..7612e4d 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 7e31ec6..6136efd 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` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。 +```bash +curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash +``` -1. **Node.js** - - 要求 **≥ 18.17.0**,执行 `node -v` 确认。 - - 未安装或版本不足:由 Agent 自行安装或升级 Node。 +Windows PowerShell: -2. **npm**(**唯一**允许的包管理器) - - 执行 `npm -v` 确认可用。 - - 未安装或不可用:由 Agent **安装或修复 npm**(不要改用 pnpm/yarn 装 `bailian-cli`),再执行 `npm install -g bailian-cli`。 - - 即使本机已安装 `pnpm`、`yarn` 等,**也仅用于其它项目**;装 `bailian-cli` 时仍必须用 `npm`。 +```powershell +irm https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.ps1 | iex +``` -3. **网络** - - 需能访问 npm registry(默认 **registry.npmjs.org**)。 - - 不可达:由 Agent 自行配置镜像或代理后重试。 +校验: ---- +```bash +bl --version +which bl # Windows: where.exe bl +``` -## 2. 安装 CLI 与 Skills +若 `command not found`:把 `~/.local/bin`(Windows 为 `%LOCALAPPDATA%\bailian-cli\bin`)加入 PATH 后开新终端。 -**仅允许以下命令**(不要用 `pnpm add -g`、`yarn global add` 等)。按顺序执行,上一步通过后再进行下一步。 +> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装。 -**2.1 安装 CLI** +## 2. 备选:npm 安装(需要 Node.js >= 18.17) + +1. `node -v` 确认版本。 +2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn)。 +3. 执行: ```bash npm install -g bailian-cli ``` -安装成功后,应能在 PATH 中找到: +4. 校验:`bl --version`。 -- `bl`(短别名) -- `bailian`(全名) - -**校验**(Agent 应执行并检查退出码与输出): - -```bash -bl --version -which bl # Windows 可用 where bl -``` - -若 `command not found`:检查全局 bin 是否在 PATH(`npm config get prefix`,其下 `bin` 目录应加入 PATH)。 - -**2.2 安装 Skills** - -CLI 校验通过后,在本机终端执行: +可选 skills(与 CLI 本体无关,按需): ```bash npx skills add modelstudioai/cli --all -g ``` -安装成功后,用中文简要说明已安装的 skills 及用户可做什么。 - ---- - ## 3. 鉴权(安装后必做才能调 API) ### 推荐:浏览器登录(控制台会话) -适用于本机交互式安装,无需用户手动复制 API Key: - 1. 执行 `bl auth status --output json`,判断是否已配置。 -2. 若未配置,在**用户本机终端**执行 `bl auth login --console`;命令会拉起浏览器完成阿里云控制台登录授权。 +2. 若未配置,在**用户本机终端**执行 `bl auth login --console`。 3. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整凭据。 -> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。 +### 备选:API Key / Token Plan -### 备选一:由 Agent 引导用户输入普通 API Key 后登录 - -适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等): - -- 获取入口:[百炼控制台 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key) - -1. 执行 `bl auth status --output json`,判断是否已配置。 -2. 若未配置或后续 API 校验失败,**请用户粘贴 API Key**(可说明从上述控制台复制;勿要求用户发到公开渠道)。 -3. 用户提供了 Key 之后,在**用户本机终端**执行(Agent 用终端工具跑,勿把 Key 写进回复正文):`bl auth login --api-key <用户提供的_Key>` -4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整 Key。 - -### 备选二:使用 Token Plan API Key - -- 获取入口:[Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) - -1. 请用户从订阅详情页获取或复制 Token Plan API Key,勿要求用户发到公开渠道。 -2. 在用户本机终端执行:`bl auth login --config token-plan --api-key <用户提供的_Key>`。 -3. `token-plan` Profile 已内置默认 Base URL;登录命令会先测试 Key,通过后才保存并激活该 Profile,无需另行配置或重复测试。 -4. 执行 `bl auth status --config token-plan --output json` 确认;汇报时只使用 masked 字段。 - -### 其他方式 - -- **环境变量**(不落盘到配置文件):在 shell 中配置 API Key 环境变量;变量名见 `bl auth status --help`,勿在对话中向用户解释底层命名。 -- **写入配置文件**(持久化,与 `auth login` 落盘相同):`bl config set --key api_key --value `(`--key 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. 常见问题 ---- - -## 5. 常见问题(Agent 排障清单) - -| 现象 | 可能原因 | 建议动作 | -| ----------------------- | -------------------- | --------------------------------------------------------------- | -| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH | -| 安装报错 engines | Node 版本过低 | 升级到 ≥ 18.17 | -| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 | -| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 | -| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm | +| 现象 | 可能原因 | 建议动作 | +| ----------------------- | ---------------------------- | -------------------------------------- | +| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin` 或 `npm prefix -g` | +| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` | +| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node,或改用 npm 版 CLI | +| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17 | diff --git a/README.md b/README.md index 5a8347b..0bdd1b6 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 ed4c1ac..b726a9e 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 0000000..066f7e9 --- /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 f51de97..ab8bba6 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 0000000..90861ed --- /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 5a8347b..0bdd1b6 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 ed4c1ac..b726a9e 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 a49554d..53f2d59 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; } - const { cmd, label } = detectInstallCommand(npmPackage); - process.stderr.write(`Updating ${npmPackage} via ${label}...\n\n`); + if (method === "binary") { + process.stderr.write(`Updating via binary channel...\n\n`); + try { + const newVer = await performBinaryUpdate(latest); + process.stderr.write( + `\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`, + ); + writeUpdateState(newVer); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`\nAutomatic binary update failed: ${message}\n`); + process.stderr.write("Re-run the install script:\n"); + process.stderr.write( + " curl -fsSL https://bailian-cli.oss-cn-hangzhou.aliyuncs.com/bailian-cli/install.sh | bash\n\n", + ); + } + return; + } + + const cmd = `npm install -g ${npmPackage}@latest`; + process.stderr.write(`Updating ${npmPackage} via npm...\n\n`); try { execSync(cmd, { stdio: "inherit" }); - // Verify the installed version after update try { const rawVer = execSync(`${binName} --version 2>/dev/null`, { encoding: "utf-8" }).trim(); - // ` --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 e49ee6c..5930cf6 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 0000000..4ec9585 --- /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 0000000..8765902 --- /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 0000000..55e22bf --- /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 c449e9d..d277251 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 0000000..57f866b --- /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 223279b..444f81a 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 4db579c..80caa0f 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 0000000..e107e4d --- /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 c774a9f..956d769 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 0000000..07bea41 --- /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 0000000..f3952b9 --- /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 0000000..737c797 --- /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 0000000..92b3e06 --- /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 0000000..098fcc5 --- /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 990c711..4f7d498 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 69aaace..986ecef 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);