mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 564e21d9f1 | |||
| 081d09863b | |||
| 1e1f5306b3 | |||
| cf2592c07d | |||
| 3766b6d7ca | |||
| 1962758b0c | |||
| 026e250cd3 | |||
| 658763af2c | |||
| da2ddb7a55 | |||
| be3033baf9 | |||
| 8ad3e7b947 | |||
| 525412f566 | |||
| 75b056ba64 | |||
| f5a36b1787 | |||
| 9fb388b75d | |||
| 45d468838f | |||
| 389c932390 | |||
| 6870dc50a6 | |||
| 54b95ed122 | |||
| 5e2833569a | |||
| 434aac5b08 | |||
| e46053b93e | |||
| 30fe8182f4 | |||
| 65c0fe9604 | |||
| fb0c4b81be | |||
| 952f2277a4 | |||
| 871c667e97 | |||
| af3286dd00 | |||
| 6465c4a78a | |||
| 467756b319 | |||
| 7250de9228 | |||
| 51ed69596e | |||
| 67b7fa30a7 | |||
| bd17c27023 | |||
| 87c37994f2 | |||
| ebbd173b79 | |||
| 6bdc16597b | |||
| e736bab9c1 | |||
| 8dd786287f | |||
| d30fb2ae68 | |||
| a1a448c5d2 | |||
| 7b949d3d3c | |||
| 168e2b5ccb | |||
| 9fbd2e4ec6 | |||
| 4bd84e934c | |||
| 08bdc3be97 | |||
| 66a797203c | |||
| 90a44d7140 | |||
| d08edf0cd8 |
@@ -18,7 +18,7 @@ on:
|
||||
- channel
|
||||
- stable
|
||||
channel:
|
||||
description: "dist-tag (channel mode only, e.g. mcp/plugin/advisor)"
|
||||
description: "Required when mode=channel. npm dist-tag only (lowercase, digits, dashes), e.g. mcp / plugin / sync-release. bailian-cli binary CDN always overwrites sync-release.json; knowledge-studio-cli is npm-only."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
@@ -29,11 +29,11 @@ concurrency:
|
||||
jobs:
|
||||
publish-stable:
|
||||
if: inputs.mode == 'stable'
|
||||
name: publish stable (${{ inputs.package }}) to npm + tag
|
||||
name: publish stable (${{ inputs.package }}) to npm + binary + tag
|
||||
runs-on: ubuntu-latest
|
||||
environment: production # Required Reviewers gate
|
||||
permissions:
|
||||
contents: write # push lightweight tag to origin
|
||||
contents: write # push tag + create GitHub Release with binary assets
|
||||
id-token: write # OIDC for npm Trusted Publishing + provenance
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -55,19 +55,47 @@ jobs:
|
||||
| sudo tar -xz -C /usr/local/bin gitleaks
|
||||
gitleaks version
|
||||
|
||||
- name: Ensure zip (per-platform binary archives)
|
||||
run: sudo apt-get update && sudo apt-get install -y zip
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Binary compile uses `bun build --compile` CLI (not Bun.build API).
|
||||
# Keep this pin in sync with any local smoke tests of binary-compile.mjs.
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.2.19"
|
||||
|
||||
- name: publish-stable
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# OSS release channel runs fully in CI: upload + reconcile + manifest.json.
|
||||
# All values come from repo Settings → Secrets — no OSS defaults live in
|
||||
# code. Leave AK/SK unset to skip the OSS channel; once enabled,
|
||||
# bucket/region/prefix are required.
|
||||
BAILIAN_OSS_AK: ${{ secrets.BAILIAN_OSS_AK }}
|
||||
BAILIAN_OSS_SK: ${{ secrets.BAILIAN_OSS_SK }}
|
||||
BAILIAN_OSS_BUCKET: ${{ secrets.BAILIAN_OSS_BUCKET }}
|
||||
BAILIAN_OSS_REGION: ${{ secrets.BAILIAN_OSS_REGION }}
|
||||
BAILIAN_OSS_ENDPOINT: ${{ secrets.BAILIAN_OSS_ENDPOINT }}
|
||||
BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }}
|
||||
BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }}
|
||||
run: node tools/release/publish-stable.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }}
|
||||
|
||||
publish-channel:
|
||||
if: inputs.mode == 'channel'
|
||||
name: publish channel (${{ inputs.package }}) to npm
|
||||
name: publish channel (${{ inputs.package }}) to npm + binary
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # no tag, no Release; just publish
|
||||
contents: write # create prerelease GitHub Release with binary assets
|
||||
id-token: write # OIDC for npm Trusted Publishing + provenance
|
||||
steps:
|
||||
- name: Require channel input
|
||||
if: ${{ inputs.channel == '' }}
|
||||
run: |
|
||||
echo "::error::mode=channel requires the workflow input \"channel\" (npm dist-tag, e.g. mcp / plugin / sync-release). Leave mode=stable if you do not need a dist-tag."
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
@@ -87,7 +115,26 @@ jobs:
|
||||
| sudo tar -xz -C /usr/local/bin gitleaks
|
||||
gitleaks version
|
||||
|
||||
- name: Ensure zip (per-platform binary archives)
|
||||
run: sudo apt-get update && sudo apt-get install -y zip
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Binary compile uses `bun build --compile` CLI (not Bun.build API).
|
||||
# Keep this pin in sync with any local smoke tests of binary-compile.mjs.
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: "1.2.19"
|
||||
|
||||
- name: publish-channel
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# OSS release channel — same Settings-injected values as stable.
|
||||
BAILIAN_OSS_AK: ${{ secrets.BAILIAN_OSS_AK }}
|
||||
BAILIAN_OSS_SK: ${{ secrets.BAILIAN_OSS_SK }}
|
||||
BAILIAN_OSS_BUCKET: ${{ secrets.BAILIAN_OSS_BUCKET }}
|
||||
BAILIAN_OSS_REGION: ${{ secrets.BAILIAN_OSS_REGION }}
|
||||
BAILIAN_OSS_ENDPOINT: ${{ secrets.BAILIAN_OSS_ENDPOINT }}
|
||||
BAILIAN_RELEASE_PREFIX: ${{ secrets.BAILIAN_RELEASE_PREFIX }}
|
||||
BAILIAN_STATIC_PREFIX: ${{ secrets.BAILIAN_STATIC_PREFIX }}
|
||||
run: node tools/release/publish-channel.mjs ${{ inputs.package == 'knowledge-studio-cli' && '--knowledge' || '' }} --channel "${{ inputs.channel }}"
|
||||
|
||||
@@ -10,6 +10,7 @@ lerna-debug.log*
|
||||
# Dependencies & build output
|
||||
node_modules
|
||||
dist
|
||||
dist-bin
|
||||
dist-ssr
|
||||
tools/generated
|
||||
.node-version
|
||||
@@ -46,3 +47,6 @@ packages/cli/scene/**/outputs/
|
||||
|
||||
# Environment variables (sensitive data)
|
||||
.env
|
||||
|
||||
# Local scratch / plan drafts (never commit)
|
||||
.scratch/
|
||||
|
||||
@@ -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) |
|
||||
| 发布 | 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) |
|
||||
| 安装文档 | 改安装、鉴权、验证流程或线上 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) |
|
||||
|
||||
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
|
||||
|
||||
|
||||
@@ -6,6 +6,17 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.14.0] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
- **Standalone installation without Node.js** — binary packages are available for macOS on Apple Silicon and Intel, Linux x64, and Windows x64; npm installation remains supported.
|
||||
- **Exact-version updates** — binary and npm installations can use `bl update --to <version>` to update or switch to a specified version.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Binary self-updates** — binary installations now check and download updates through a dedicated release channel. `bl update` no longer replaces the running executable, and the next invocation automatically uses the new version.
|
||||
|
||||
## [1.13.1] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.14.0] - 2026-08-04
|
||||
|
||||
### 新增
|
||||
|
||||
- **免 Node.js 的二进制安装** — 支持 macOS Apple Silicon / Intel、Linux x64 和 Windows x64;npm 安装方式继续保留。
|
||||
- **指定版本更新** — 二进制和 npm 安装均可通过 `bl update --to <version>` 更新或切换到指定版本。
|
||||
|
||||
### 变更
|
||||
|
||||
- **二进制自更新** — 二进制安装现在通过独立的发布通道检查和下载更新;执行 `bl update` 时不会覆盖正在运行的程序,下次运行自动使用新版本。
|
||||
|
||||
## [1.13.1] - 2026-08-03
|
||||
|
||||
### 变更
|
||||
|
||||
+49
-80
@@ -1,99 +1,74 @@
|
||||
# 阿里云百炼CLI 安装说明(供 AI Agent 阅读)
|
||||
|
||||
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(npm 包名 `bailian-cli`,命令 `bl` / `bailian`)。用户通常通过 **npm 全局安装** 使用,**无需**访问本仓库源码。不要臆造版本号或路径;以用户环境为准。
|
||||
本文档面向 **AI Agent**:按下列步骤为用户安装并校验 **阿里云百炼CLI**(命令 `bl` / `bailian`)。**优先**使用二进制一键安装(无需 Node);若环境已有 Node / 需要 Command Pack,再用 npm。不要臆造版本号或路径;以用户环境为准。
|
||||
|
||||
## 1. 安装前检查
|
||||
## 1. 推荐:二进制安装(无需 Node)
|
||||
|
||||
**原则**:先执行检查;若 **Node / 包管理器 / 网络** 任一不满足下文要求,Agent 应 **在本机终端内主动完成安装或配置**(例如升级 Node、切换镜像、配置 `npm` 代理等),再继续 `bailian-cli` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
```
|
||||
|
||||
1. **Node.js**
|
||||
- 要求 **≥ 18.17.0**,执行 `node -v` 确认。
|
||||
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
|
||||
可选:正式安装读 CDN 上的 `manifest.json`。预发 / channel 验证一律读 `sync-release.json`(bailian-cli 的 channel 发版都会覆盖它):
|
||||
|
||||
2. **npm**(**唯一**允许的包管理器)
|
||||
- 执行 `npm -v` 确认可用。
|
||||
- 未安装或不可用:由 Agent **安装或修复 npm**(不要改用 pnpm/yarn 装 `bailian-cli`),再执行 `npm install -g bailian-cli`。
|
||||
- 即使本机已安装 `pnpm`、`yarn` 等,**也仅用于其它项目**;装 `bailian-cli` 时仍必须用 `npm`。
|
||||
```bash
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash -s -- --channel sync-release
|
||||
```
|
||||
|
||||
3. **网络**
|
||||
- 需能访问 npm registry(默认 **registry.npmjs.org**)。
|
||||
- 不可达:由 Agent 自行配置镜像或代理后重试。
|
||||
也可用 `--version` / `--cdn`(或环境变量 `BAILIAN_CLI_CDN`)覆盖资源根。安装脚本本身不在本仓库维护。
|
||||
|
||||
---
|
||||
Windows PowerShell:
|
||||
|
||||
## 2. 安装 CLI 与 Skills
|
||||
```powershell
|
||||
# 正式(manifest.json)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
**仅允许以下命令**(不要用 `pnpm add -g`、`yarn global add` 等)。按顺序执行,上一步通过后再进行下一步。
|
||||
# channel / 预发验证(sync-release.json)
|
||||
$env:BAILIAN_CHANNEL = 'sync-release'; irm 'https://bailian.aliyun.com/cli/install.ps1' | iex
|
||||
```
|
||||
|
||||
**2.1 安装 CLI**
|
||||
带其它参时先落盘再执行(`irm | iex` 不便传参),或使用仓外静态资源文档中的预发入口。
|
||||
|
||||
二进制安装布局为 `versions/<ver>/` + `current` 指针;`bl update` 只切换指针并清理旧版本(保留当前与上一版)。更新进程退出后,下次执行 `bl` 即使用新版本(无需「重启应用」)。
|
||||
|
||||
校验:
|
||||
|
||||
```bash
|
||||
bl --version
|
||||
which bl # Windows: where.exe bl
|
||||
```
|
||||
|
||||
> CDN / GitHub Release 未就绪或下载失败时,回退到下方 npm 安装。
|
||||
|
||||
## 2. 备选:npm 安装(要求 **≥ 18.17.0**)
|
||||
|
||||
1. `node -v` 确认版本。
|
||||
2. `npm -v` 确认可用(**仅允许 npm** 全局安装,不要用 pnpm/yarn)。
|
||||
3. 执行:
|
||||
|
||||
```bash
|
||||
npm install -g bailian-cli
|
||||
```
|
||||
|
||||
安装成功后,应能在 PATH 中找到:
|
||||
4. 校验:`bl --version`。
|
||||
|
||||
- `bl`(短别名)
|
||||
- `bailian`(全名)
|
||||
|
||||
**校验**(Agent 应执行并检查退出码与输出):
|
||||
|
||||
```bash
|
||||
bl --version
|
||||
which bl # Windows 可用 where bl
|
||||
```
|
||||
|
||||
若 `command not found`:检查全局 bin 是否在 PATH(`npm config get prefix`,其下 `bin` 目录应加入 PATH)。
|
||||
|
||||
**2.2 安装 Skills**
|
||||
|
||||
CLI 校验通过后,在本机终端执行:
|
||||
可选 skills(与 CLI 本体无关,按需):
|
||||
|
||||
```bash
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
安装成功后,用中文简要说明已安装的 skills 及用户可做什么。
|
||||
|
||||
---
|
||||
|
||||
## 3. 鉴权(安装后必做才能调 API)
|
||||
|
||||
### 推荐:浏览器登录(控制台会话)
|
||||
|
||||
适用于本机交互式安装,无需用户手动复制 API Key:
|
||||
|
||||
1. 执行 `bl auth status --output json`,判断是否已配置。
|
||||
2. 若未配置,在**用户本机终端**执行 `bl auth login --console`;命令会拉起浏览器完成阿里云控制台登录授权。
|
||||
2. 若未配置,在**用户本机终端**执行 `bl auth login --console`。
|
||||
3. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整凭据。
|
||||
|
||||
> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。
|
||||
### 备选:API Key / Token Plan
|
||||
|
||||
### 备选一:由 Agent 引导用户输入普通 API Key 后登录
|
||||
|
||||
适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等):
|
||||
|
||||
- 获取入口:[百炼控制台 API Key](https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key)
|
||||
|
||||
1. 执行 `bl auth status --output json`,判断是否已配置。
|
||||
2. 若未配置或后续 API 校验失败,**请用户粘贴 API Key**(可说明从上述控制台复制;勿要求用户发到公开渠道)。
|
||||
3. 用户提供了 Key 之后,在**用户本机终端**执行(Agent 用终端工具跑,勿把 Key 写进回复正文):`bl auth login --api-key <用户提供的_Key>`
|
||||
4. 登录成功后执行 `bl auth status --output json` 确认;汇报时只使用 masked 字段,**禁止**回显完整 Key。
|
||||
|
||||
### 备选二:使用 Token Plan API Key
|
||||
|
||||
- 获取入口:[Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview)
|
||||
|
||||
1. 请用户从订阅详情页获取或复制 Token Plan API Key,勿要求用户发到公开渠道。
|
||||
2. 在用户本机终端执行:`bl auth login --config token-plan --api-key <用户提供的_Key>`。
|
||||
3. `token-plan` Profile 已内置默认 Base URL;登录命令会先测试 Key,通过后才保存并激活该 Profile,无需另行配置或重复测试。
|
||||
4. 执行 `bl auth status --config token-plan --output json` 确认;汇报时只使用 masked 字段。
|
||||
|
||||
### 其他方式
|
||||
|
||||
- **环境变量**(不落盘到配置文件):在 shell 中配置 API Key 环境变量;变量名见 `bl auth status --help`,勿在对话中向用户解释底层命名。
|
||||
- **写入配置文件**(持久化,与 `auth login` 落盘相同):`bl config set --key api_key --value <key>`(`--key api-key` 亦可)。**不会**像 `bl auth login --api-key` 那样先校验 Key 是否可用;Agent 引导安装时仍**优先**用 `auth login`。
|
||||
- **命令行临时传入**:需要 API Key 的 `bl` 子命令可在**当次**执行附加全局 `--api-key <key>`,仅本次生效、不落盘(例:`bl text chat --api-key sk-xxx --message "你好"`)。与上文持久化方式不是同一用途。
|
||||
- 普通 Key:`bl auth login --api-key <Key>`
|
||||
- Token Plan:`bl auth login --config token-plan --api-key <Key>`
|
||||
|
||||
### Agent 安全约束
|
||||
|
||||
@@ -104,22 +79,16 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
## 4. 配置验证
|
||||
|
||||
API Key 登录命令本身已经完成可用性测试,通过后只需确认配置状态:
|
||||
|
||||
```bash
|
||||
bl auth status --output json
|
||||
```
|
||||
|
||||
无需再执行重复的模型调用测试。若登录失败,根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、`base_url` 等)。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`。
|
||||
## 5. 常见问题
|
||||
|
||||
---
|
||||
|
||||
## 5. 常见问题(Agent 排障清单)
|
||||
|
||||
| 现象 | 可能原因 | 建议动作 |
|
||||
| ----------------------- | -------------------- | --------------------------------------------------------------- |
|
||||
| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH |
|
||||
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 18.17 |
|
||||
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 |
|
||||
| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 |
|
||||
| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm |
|
||||
| 现象 | 可能原因 | 建议动作 |
|
||||
| ------------------------ | ---------------------------- | ------------------------------------------------ |
|
||||
| `bl: command not found` | bin 不在 PATH | 检查 `~/.local/bin` 或 `npm prefix -g` |
|
||||
| curl 安装 404 | GitHub Release 资产未上传 | 改用 `npm install -g bailian-cli` |
|
||||
| Windows `bl update` 失败 | 旧布局 / 文件锁 / 网络 | 重跑 `irm .../install.ps1 \| iex` 迁移布局后重试 |
|
||||
| `plugin` 需要 npm | 二进制安装无本机 npm | 安装 Node,或改用 npm 版 CLI |
|
||||
| 安装报错 engines | Node 版本过低(仅 npm 路径) | 升级到 ≥ 18.17.0 |
|
||||
|
||||
@@ -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.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
# Node users / developers (Node.js >= 18.17)
|
||||
npm install -g bailian-cli
|
||||
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
|
||||
|
||||
@@ -210,8 +216,9 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
# Self-update to latest version
|
||||
# Self-update to latest or a specific version
|
||||
bl update
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
Config file location: `~/.bailian/config.json`
|
||||
|
||||
+11
-2
@@ -75,11 +75,17 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 推荐 — 无需本机 Node.js
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows(PowerShell)
|
||||
irm https://bailian.aliyun.com/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` 长期保留。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -210,6 +216,9 @@ bl config set --key timeout --value 600
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
|
||||
# 安装指定版本
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
配置文件位置:`~/.bailian/config.json`
|
||||
|
||||
+41
-11
@@ -1,27 +1,53 @@
|
||||
# 发布(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
|
||||
→ binary-build
|
||||
→ gh-release
|
||||
→ oss-direct-upload)
|
||||
```
|
||||
|
||||
`tools/release/lib/binary-release.mjs` 等是实现,一般不要单独当发版入口(调试可用)。
|
||||
|
||||
两种模式:
|
||||
|
||||
| 模式 | 用途 | 触发方式 |
|
||||
| ------- | ------------------------------ | -------------------------------------------------- |
|
||||
| channel | 发 channel 版本到指定 dist-tag | 选 mode=channel,填 dist-tag 名称(如 mcp/plugin) |
|
||||
| stable | 正式发版到 latest | 选 mode=stable,需 production environment 审批 |
|
||||
| 模式 | 用途 | 触发方式 |
|
||||
| ------- | --------------------------------------------------------------------------------------- | -------------------------------------------- |
|
||||
| channel | npm dist-tag +(仅 bailian-cli)二进制 + CDN **一律**覆盖 `sync-release.json` | mode=channel,channel 填 **npm dist-tag** 名 |
|
||||
| stable | npm latest + GitHub Release `v<ver>` + CDN **`manifest.json`**(及 `latest.json` 别名) | mode=stable,需 production environment 审批 |
|
||||
|
||||
可选 flag:`--skip-binary`(仅发 npm,紧急逃生)。
|
||||
|
||||
### CDN 滚动指针(bailian-cli)
|
||||
|
||||
| 发布模式 | CDN 指针 | 本机安装 / 更新 |
|
||||
| -------- | ---------------------------------- | ----------------------------------------------------------------- |
|
||||
| channel | 始终覆盖 `sync-release.json` | `BAILIAN_CHANNEL=sync-release` / `install --channel sync-release` |
|
||||
| stable | `manifest.json`(+ `latest.json`) | 默认安装 / `bl update`(无 channel) |
|
||||
|
||||
workflow 的 `channel` 输入**只决定 npm dist-tag**(如 `mcp` / `plugin` / `sync-release`),**不再**生成 `release-test.json` 这类旁路文件。
|
||||
|
||||
### channel 发布
|
||||
|
||||
1. 在 GitHub 触发 Publish workflow,package 选 `bailian-cli` 或 `knowledge-studio-cli`,mode 选 `channel`,channel 填 dist-tag 名(如 `mcp`)
|
||||
2. CI 自动:生成 `0.0.0-beta-<sha7>-<date>` 版本号 → 临时 bump 对应包集合 → 自检 → 构建 → 发布到指定 dist-tag
|
||||
1. 在 GitHub 触发 Publish workflow,mode 选 `channel`,channel 填 npm dist-tag 名:
|
||||
- **`bailian-cli`**:npm 发到该 tag;二进制同时刷新 CDN `sync-release.json`(与 tag 名无关)。本机验证:`BAILIAN_CHANNEL=sync-release`
|
||||
- **`knowledge-studio-cli`**:仅 npm(自动跳过 binary,不碰 `sync-release.json`)
|
||||
2. CI 自动:生成 `0.0.0-beta-<sha7>-<YYYYMMDDHHMM>`(UTC 到分钟;同 commit 同分钟重跑会覆盖同号)→ 临时 bump → 自检 → **npm 发到 dist-tag** →(bailian-cli)**Bun 编二进制 + GH prerelease + 覆盖 `sync-release.json`** → 还原 package.json
|
||||
3. 对应脚本:`tools/release/publish-channel.mjs`
|
||||
|
||||
### stable 发布
|
||||
@@ -29,7 +55,7 @@
|
||||
1. 确保当前 release tooling 覆盖的包(`tools/release/lib/packages.mjs`)已升到目标版本且一致;当前基础集合为 `packages/core` / `packages/runtime` / `packages/commands` / `packages/cli`,`knowledge-studio-cli` 发布会额外包含 `packages/kscli`
|
||||
2. 在 GitHub 触发 Publish workflow,package 选目标包集合,mode 选 `stable`
|
||||
3. 需要 production environment 审批人批准
|
||||
4. CI 自动:自检 → 构建 → 检查 npm 已发布版本 → 发布到 latest → 打 git tag
|
||||
4. CI 自动:自检 → **npm 发到 latest** → **推送 git tag `v<ver>`** → **Bun 编二进制并创建/更新 GitHub Release** →(bailian-cli)维护 CDN **`manifest.json`** → 完成
|
||||
5. 如果所选发布集合的当前版本已全部存在于 npm,stable 发布会失败并提示先升级版本号;如果只有部分包已发布,CI 会继续补发缺失包
|
||||
6. 对应脚本:`tools/release/publish-stable.mjs`
|
||||
|
||||
@@ -59,7 +85,9 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
|
||||
## CI 基础设施
|
||||
|
||||
- **认证**:npm OIDC Trusted Publishing(无 token),需要 `id-token: write` 权限
|
||||
- **GitHub Release**:`contents: write` + `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`(stable / channel 均需)
|
||||
- **Node 版本**: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
|
||||
|
||||
@@ -105,3 +133,5 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 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` 失败 |
|
||||
|
||||
@@ -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:"
|
||||
|
||||
+10
-3
@@ -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.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows (PowerShell)
|
||||
irm https://bailian.aliyun.com/cli/install.ps1 | iex
|
||||
|
||||
# Node users / developers (Node.js >= 18.17)
|
||||
npm install -g bailian-cli
|
||||
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
|
||||
|
||||
@@ -210,8 +216,9 @@ bl config set --key base_url --value https://dashscope-us.aliyuncs.com
|
||||
bl config set --key default_text_model --value qwen-turbo
|
||||
bl config set --key timeout --value 600
|
||||
|
||||
# Self-update to latest version
|
||||
# Self-update to latest or a specific version
|
||||
bl update
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
Config file location: `~/.bailian/config.json`
|
||||
|
||||
@@ -75,11 +75,17 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
# 推荐 — 无需本机 Node.js
|
||||
curl -fsSL https://bailian.aliyun.com/cli/install.sh | bash
|
||||
|
||||
# Windows(PowerShell)
|
||||
irm https://bailian.aliyun.com/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` 长期保留。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -210,6 +216,9 @@ bl config set --key timeout --value 600
|
||||
|
||||
# 自更新到最新版本
|
||||
bl update
|
||||
|
||||
# 安装指定版本
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
配置文件位置:`~/.bailian/config.json`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
@@ -25,7 +25,8 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.zh.md"
|
||||
"README.zh.md",
|
||||
"postinstall.js"
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -45,12 +46,14 @@
|
||||
"build": "vp pack",
|
||||
"dev": "tsx src/main.ts",
|
||||
"test": "vp test",
|
||||
"check": "vp check"
|
||||
"check": "vp check",
|
||||
"postinstall": "node postinstall.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bailian-cli-commands": "workspace:*",
|
||||
"bailian-cli-core": "workspace:*",
|
||||
"bailian-cli-runtime": "workspace:*"
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"tar-stream": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^0.7.0",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* postinstall.js — Wiki data sync (layer 1: triggered by npm install)
|
||||
*
|
||||
* Runs automatically after npm/pnpm installs bailian-cli: unconditionally downloads the full Wiki data
|
||||
* package and overwrites the local directory, ensuring data is in place the first time the user runs
|
||||
* `bl advisor recommend`.
|
||||
*
|
||||
* Flow (unified skill publishing protocol: skills/index.json + one content-addressed object per skill):
|
||||
* 1. Download skills/index.json from public-read OSS, get the bailian-docs-llm-wiki entry
|
||||
* 2. Download skills/bailian-docs-llm-wiki/<entry.object> (sha256-<hex>.tar.br, brotli q6, ~2.3MB);
|
||||
* legacy fallback to skill.tar.br when the entry has no valid object field
|
||||
* 3. Node built-in brotli decompress + tar-stream extract (per-entry path safety check) to same-volume temp dir
|
||||
* 4. renameSync atomic swap into ~/.bailian/skills/bailian-docs-llm-wiki/
|
||||
* 5. Write ~/.bailian/wiki-sync-state.json
|
||||
* 6. Write ~/.bailian/skills/skill-lock.json record (same ledger as bl skill)
|
||||
*
|
||||
* Design constraints:
|
||||
* - Unconditional overwrite: every install fully replaces, no version comparison
|
||||
* - Silent failure: any step failure → console.warn → process.exit(0), never blocks install
|
||||
* - Standalone implementation: does not import bailian-cli-core, avoiding ESM path issues after bundling
|
||||
* - Depends on Node built-in modules + tar-stream (consistent with sync.ts / publisher skills-publish.mjs)
|
||||
*/
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createBrotliDecompress } from "node:zlib";
|
||||
import tar from "tar-stream";
|
||||
|
||||
const REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
|
||||
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
|
||||
const CONFIG_DIR_NAME = ".bailian";
|
||||
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
|
||||
const STATE_FILE_NAME = "wiki-sync-state.json";
|
||||
const INDEX_KEY = "index.json";
|
||||
/** Legacy fixed asset key (entries without a valid content-addressed object field) */
|
||||
const LEGACY_ASSET_NAME = "skill.tar.br";
|
||||
/** Same strict shape check as core registry.ts: only a valid object name may enter the URL */
|
||||
const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
|
||||
|
||||
const INDEX_TIMEOUT_MS = 3000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 30000;
|
||||
|
||||
function getConfigDir() {
|
||||
if (process.env.BAILIAN_CONFIG_DIR) return process.env.BAILIAN_CONFIG_DIR;
|
||||
return join(homedir(), CONFIG_DIR_NAME);
|
||||
}
|
||||
|
||||
function getCatalogDir() {
|
||||
return join(getConfigDir(), SKILL_DIR_NAME);
|
||||
}
|
||||
|
||||
function getStatePath() {
|
||||
return join(getConfigDir(), STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
function getSkillLockPath() {
|
||||
return join(getConfigDir(), "skills", "skill-lock.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record this sync in skill-lock.json (same ledger as bl skill; list shows installed).
|
||||
* Semantics aligned with upsertSkillLockEntry in core/src/skills/lock.ts: shallow-merge with the existing
|
||||
* entry, preserving fields like links written by bl skill add; rebuild as empty table if lock is corrupted/unrecognized.
|
||||
* best-effort: failure does not affect data sync results.
|
||||
*/
|
||||
function upsertSkillLock(name, entry) {
|
||||
try {
|
||||
let lock = { version: 1, skills: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(getSkillLockPath(), "utf-8"));
|
||||
if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
|
||||
lock = parsed;
|
||||
}
|
||||
} catch {
|
||||
/* absent/corrupted → empty table */
|
||||
}
|
||||
lock.skills[name] = { ...lock.skills[name], ...entry };
|
||||
mkdirSync(dirname(getSkillLockPath()), { recursive: true });
|
||||
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
|
||||
} catch {
|
||||
/* Bookkeeping failure does not block install; advisor-side sync will backfill */
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, timeoutMs) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function downloadBuffer(url) {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
||||
function isSafeEntryName(name) {
|
||||
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
||||
return !name.split("/").includes("..");
|
||||
}
|
||||
|
||||
/** Brotli decompress + tar-stream extract into destDir (symmetric with publisher tar.pack()). */
|
||||
async function extractTarBr(tarBrBuffer, destDir) {
|
||||
const extract = tar.extract();
|
||||
|
||||
extract.on("entry", (header, stream, next) => {
|
||||
if (!isSafeEntryName(header.name)) {
|
||||
// Same semantics as core skills/extract.ts: destroy so the pipeline rejects with this
|
||||
// error; silence the entry stream to avoid its companion error becoming unhandled
|
||||
stream.on("error", () => {});
|
||||
stream.resume();
|
||||
extract.destroy(new Error(`unsafe tar entry: ${header.name}`));
|
||||
return;
|
||||
}
|
||||
const filePath = join(destDir, header.name);
|
||||
if (header.type === "directory") {
|
||||
mkdirSync(filePath, { recursive: true });
|
||||
stream.resume();
|
||||
stream.on("end", next);
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const ws = createWriteStream(filePath);
|
||||
stream.pipe(ws);
|
||||
ws.on("finish", next);
|
||||
ws.on("error", next);
|
||||
});
|
||||
|
||||
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
|
||||
}
|
||||
|
||||
/** Atomic swap: tmpDir (same volume) → catalogDir. */
|
||||
function atomicSwap(tmpDir, catalogDir) {
|
||||
mkdirSync(dirname(catalogDir), { recursive: true });
|
||||
const backup = `${catalogDir}.old-${Date.now()}`;
|
||||
if (existsSync(catalogDir)) renameSync(catalogDir, backup);
|
||||
try {
|
||||
renameSync(tmpDir, catalogDir);
|
||||
} catch (err) {
|
||||
if (existsSync(backup) && !existsSync(catalogDir)) renameSync(backup, catalogDir);
|
||||
throw err;
|
||||
}
|
||||
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 1. Download skills/index.json and get the wiki entry
|
||||
const index = await fetchJson(`${REGISTRY_BASE_URL}/${INDEX_KEY}`, INDEX_TIMEOUT_MS);
|
||||
const entry = index?.skills?.[WIKI_SKILL_NAME];
|
||||
if (!entry?.contentHash)
|
||||
throw new Error("no bailian-docs-llm-wiki entry (or contentHash) in index.json");
|
||||
|
||||
// 2. Download the skill archive: content-addressed object first, legacy fixed key as fallback
|
||||
const assetName =
|
||||
entry.object && OBJECT_FILE_RE.test(entry.object) ? entry.object : LEGACY_ASSET_NAME;
|
||||
const tarBuf = await downloadBuffer(`${REGISTRY_BASE_URL}/${WIKI_SKILL_NAME}/${assetName}`);
|
||||
|
||||
// 3. Extract to same-volume temp dir + atomic swap
|
||||
const catalogDir = getCatalogDir();
|
||||
const tmpDir = `${catalogDir}.tmp-${process.pid}-${Date.now()}`;
|
||||
try {
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
await extractTarBr(tarBuf, tmpDir);
|
||||
atomicSwap(tmpDir, catalogDir);
|
||||
} catch (err) {
|
||||
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 4. Write state
|
||||
try {
|
||||
writeFileSync(
|
||||
getStatePath(),
|
||||
JSON.stringify({ lastChecked: Date.now(), contentHash: entry.contentHash }),
|
||||
);
|
||||
} catch {
|
||||
/* state write failure has no impact: first recommend will re-check */
|
||||
}
|
||||
|
||||
// 5. skill-lock.json record: wiki shares the same ledger as bl skill
|
||||
upsertSkillLock(WIKI_SKILL_NAME, {
|
||||
contentHash: entry.contentHash,
|
||||
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
|
||||
installedAt: new Date().toISOString(),
|
||||
sourceType: "oss",
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
});
|
||||
|
||||
process.stdout.write(`bailian-cli: wiki data ready (${entry.publishedAt ?? "latest"})\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Unconditional pass-through: install-time network/permission issues should not block npm install;
|
||||
// sync.ts will fall back to syncing on the first `bl advisor recommend`.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`bailian-cli: wiki data pre-download skipped (${msg}); will sync automatically on first use.\n`,
|
||||
);
|
||||
// Force a success exit code so a download failure never fails `npm install`.
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -89,6 +89,10 @@ import {
|
||||
pluginLink,
|
||||
pluginList,
|
||||
pluginRemove,
|
||||
skillAdd,
|
||||
skillUpdate,
|
||||
skillRemove,
|
||||
skillList,
|
||||
managedAgentInit,
|
||||
managedAgentValidate,
|
||||
managedAgentPlan,
|
||||
@@ -203,6 +207,10 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"plugin link": pluginLink,
|
||||
"plugin list": pluginList,
|
||||
"plugin remove": pluginRemove,
|
||||
"skill add": skillAdd,
|
||||
"skill update": skillUpdate,
|
||||
"skill remove": skillRemove,
|
||||
"skill list": skillList,
|
||||
"managed-agent init": managedAgentInit,
|
||||
"managed-agent validate": managedAgentValidate,
|
||||
"managed-agent plan": managedAgentPlan,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"description": "Command library for bailian-cli products (knowledge, memory, media, …). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type GetModelsOptions,
|
||||
getModels,
|
||||
type IntentProfile,
|
||||
maybeSyncWikiData,
|
||||
type PipelineStep,
|
||||
type RecommendedModel,
|
||||
type RecommendResult,
|
||||
@@ -248,6 +249,12 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
const userInput = flags.message;
|
||||
const top = 3;
|
||||
|
||||
// Keep the local wiki catalog fresh: throttled (12h) version check against
|
||||
// the remote manifest, silently replaces data when a newer version exists.
|
||||
// Never throws — a sync failure must not block recommendation.
|
||||
await maybeSyncWikiData();
|
||||
|
||||
// Default to JSON for structured output; render boxen cards only when the
|
||||
// user explicitly asked for text output.
|
||||
const format = settings.outputExplicit ? detectOutputFormat(settings.output) : "json";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, deleteDataset, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const DELETE_FLAGS = {
|
||||
fileId: {
|
||||
@@ -30,6 +30,7 @@ export default defineCommand({
|
||||
|
||||
if (settings.quiet || format === "text") {
|
||||
emitBare(`Deleted ${fileId}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, getDataset, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const GET_FLAGS = {
|
||||
fileId: {
|
||||
@@ -46,7 +46,7 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
emitResult({ ...item, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,5 +58,6 @@ export default defineCommand({
|
||||
if (item.purpose) emitBare(`purpose: ${item.purpose}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
if (item.description) emitBare(`description: ${item.description}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, listDatasets, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -55,7 +55,7 @@ export default defineCommand({
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,5 +68,6 @@ export default defineCommand({
|
||||
const rows = items.map((i) => [i.file_id, i.name, i.size, i.purpose]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,10 +9,9 @@ import {
|
||||
MAX_MEDIA_ZIP_BYTES,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type DatasetFile,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const UPLOAD_FLAGS = {
|
||||
file: {
|
||||
@@ -135,17 +134,19 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaded: DatasetFile = await uploadDataset(ctx.client, {
|
||||
const uploaded = await uploadDataset(ctx.client, {
|
||||
filePath,
|
||||
purpose,
|
||||
});
|
||||
const { request_id, ...file } = uploaded;
|
||||
|
||||
if (settings.quiet) {
|
||||
emitBare(uploaded.file_id);
|
||||
emitBare(file.file_id);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Uploaded ${uploaded.name} → file_id=${uploaded.file_id}`);
|
||||
emitBare(`Uploaded ${file.name} → file_id=${file.file_id}`);
|
||||
emitRequestId(request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(uploaded, format);
|
||||
emitResult({ ...file, request_id }, format);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type CommandContext,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const CREATE_FLAGS = {
|
||||
model: {
|
||||
@@ -163,6 +163,7 @@ async function runCreate(
|
||||
emitBare(
|
||||
`\nNext: track readiness with: ${identity.binName} deploy get --deployed-model ${deployment?.deployed_model ?? "<id>"}`,
|
||||
);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const DELETE_FLAGS = {
|
||||
deployedModel: {
|
||||
@@ -71,6 +71,7 @@ export default defineCommand({
|
||||
emitBare(deployedModel);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Deleted ${deployedModel}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, getDeployment, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const GET_FLAGS = {
|
||||
deployedModel: {
|
||||
@@ -58,7 +58,7 @@ export default defineCommand({
|
||||
if (deployment.gmt_modified) item.updated_at = deployment.gmt_modified;
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
emitResult({ ...item, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,5 +69,6 @@ export default defineCommand({
|
||||
const display = typeof value === "string" ? value : JSON.stringify(value);
|
||||
emitBare(`${label(key)}${display}`);
|
||||
}
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
listDeployments,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -58,7 +58,7 @@ export default defineCommand({
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,5 +78,6 @@ export default defineCommand({
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
listDeployableModels,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const MODELS_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -122,7 +122,7 @@ export default defineCommand({
|
||||
}
|
||||
return out;
|
||||
});
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -168,5 +168,6 @@ export default defineCommand({
|
||||
]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
if (total !== undefined) emitBare(`\nTotal: ${total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
scaleDeployment,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const SCALE_FLAGS = {
|
||||
deployedModel: {
|
||||
@@ -72,6 +72,7 @@ export default defineCommand({
|
||||
} else if (format === "text") {
|
||||
const cap = deployment?.capacity !== undefined ? ` (capacity=${deployment.capacity})` : "";
|
||||
emitBare(`Scaled ${deployedModel}${cap}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
updateDeployment,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const UPDATE_FLAGS = {
|
||||
deployedModel: {
|
||||
@@ -70,6 +70,7 @@ export default defineCommand({
|
||||
if (deployment?.tpm_limit !== undefined) parts.push(`tpm_limit=${deployment.tpm_limit}`);
|
||||
const summary = parts.length ? ` (${parts.join(", ")})` : "";
|
||||
emitBare(`Updated ${deployedModel}${summary}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, cancelFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const CANCEL_FLAGS = {
|
||||
jobId: {
|
||||
@@ -38,6 +38,7 @@ export default defineCommand({
|
||||
} else if (format === "text") {
|
||||
const status = job?.status ? ` (status=${job.status})` : "";
|
||||
emitBare(`Cancelled ${jobId}${status}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
listCheckpoints,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const CHECKPOINTS_FLAGS = {
|
||||
jobId: {
|
||||
@@ -47,7 +47,7 @@ export default defineCommand({
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,5 +60,6 @@ export default defineCommand({
|
||||
const rows = items.map((i) => [i.checkpoint, i.step, i.status]);
|
||||
for (const line of formatTable(headers, rows)) emitBare(line);
|
||||
emitBare(`\nTotal: ${total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "bailian-cli-core";
|
||||
import { existsSync, statSync } from "fs";
|
||||
import { basename } from "path";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* A `--datasets` / `--validations` token is treated as a local file to upload
|
||||
@@ -631,6 +631,7 @@ async function runCreate<F extends FlagsDef>(
|
||||
if (job?.job_id) {
|
||||
emitBare(`Created fine-tune job: ${job.job_id}`);
|
||||
if (job.status) emitBare(`Status: ${job.status}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, deleteFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const DELETE_FLAGS = {
|
||||
jobId: {
|
||||
@@ -36,6 +36,7 @@ export default defineCommand({
|
||||
emitBare(jobId);
|
||||
} else if (format === "text") {
|
||||
emitBare(`Deleted ${jobId}.`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
exportCheckpoint,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const EXPORT_FLAGS = {
|
||||
jobId: {
|
||||
@@ -69,6 +69,7 @@ export default defineCommand({
|
||||
emitBare(
|
||||
`Next: ${identity.binName} deploy text create --model ${exported} --name <display-name>`,
|
||||
);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, getFineTune, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const GET_FLAGS = {
|
||||
jobId: {
|
||||
@@ -56,7 +56,7 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(item, format);
|
||||
emitResult({ ...item, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,5 +76,6 @@ export default defineCommand({
|
||||
if (item.model_name) emitBare(`model_name: ${item.model_name}`);
|
||||
if (item.created_at) emitBare(`created_at: ${item.created_at}`);
|
||||
if (item.updated_at) emitBare(`updated_at: ${item.updated_at}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineCommand, detectOutputFormat, listFineTunes, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare, formatTable } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const LIST_FLAGS = {
|
||||
page: { type: "number", valueHint: "<n>", description: "Page number (default: 1)" },
|
||||
@@ -48,7 +48,7 @@ export default defineCommand({
|
||||
}));
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ items, total }, format);
|
||||
emitResult({ items, total, request_id: response.request_id }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -78,5 +78,6 @@ export default defineCommand({
|
||||
emitBare(
|
||||
`Tip: OUTPUT_MODEL is the input for \`${identity.binName} deploy text create --model\``,
|
||||
);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type FineTuneLogEntry,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
/**
|
||||
* Render a single log entry as a single line (mirrors the flatten logic used
|
||||
@@ -187,6 +187,7 @@ export default defineCommand({
|
||||
emitBare(renderEntry(entry));
|
||||
}
|
||||
if (payload?.total !== undefined) emitBare(`\nTotal: ${payload.total}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
emitResult(response, format);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ExitCode,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { emitResult, emitBare, emitRequestId } from "bailian-cli-runtime";
|
||||
|
||||
const DEFAULT_INTERVAL_SEC = 10;
|
||||
const MIN_INTERVAL_SEC = 1;
|
||||
@@ -135,9 +135,13 @@ export default defineCommand({
|
||||
} else if (format === "text") {
|
||||
emitBare(`${nowStamp()} ${jobId} ${status || "UNKNOWN"}`);
|
||||
if (status === "SUCCEEDED") emitBare(`✓ ${jobId} ${status}`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
} else {
|
||||
// json: a compact, purpose-built status probe.
|
||||
emitResult({ job_id: jobId, status: status || "UNKNOWN", terminal }, format);
|
||||
emitResult(
|
||||
{ job_id: jobId, status: status || "UNKNOWN", terminal, request_id: response.request_id },
|
||||
format,
|
||||
);
|
||||
}
|
||||
|
||||
if (terminal && status !== "SUCCEEDED") {
|
||||
@@ -175,6 +179,7 @@ export default defineCommand({
|
||||
emitResult(response, format);
|
||||
} else if (status === "SUCCEEDED") {
|
||||
emitBare(`\n✓ ${jobId} ${status} (elapsed ${formatElapsed(elapsed)})`);
|
||||
emitRequestId(response.request_id, settings.quiet);
|
||||
}
|
||||
if (status !== "SUCCEEDED") {
|
||||
throw new BailianError(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
detectInstalledAgents,
|
||||
fetchSkillsIndex,
|
||||
getSkillRegistryBaseUrl,
|
||||
installSkillWithFanout,
|
||||
parseSkillNames,
|
||||
readSkillLock,
|
||||
runWithConcurrency,
|
||||
writeSkillLock,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
interface AddOutcome {
|
||||
name: string;
|
||||
status: "installed" | "failed";
|
||||
publishedAt?: string;
|
||||
agents?: string[];
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Max number of skills downloading/installing at the same time. */
|
||||
const INSTALL_CONCURRENCY = 3;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Install skills from the Bailian skill registry into local agents",
|
||||
auth: "none",
|
||||
usageArgs: "--name <all|name,...>",
|
||||
flags: {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<all|name,...>",
|
||||
description: "Skills to install: all or comma-separated skill names",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: ["--name all", "--name spark-video,bailian-model-recommend"],
|
||||
async run(ctx) {
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
const requested = parseSkillNames(ctx.flags.name, false);
|
||||
const index = await fetchSkillsIndex();
|
||||
const remoteNames = Object.keys(index.skills);
|
||||
const names = requested === "all" ? remoteNames : requested;
|
||||
|
||||
const lock = readSkillLock();
|
||||
const agents = detectInstalledAgents();
|
||||
|
||||
// collect-then-throw: a single skill failure only affects itself; successful ones are written to disk and lock as usual.
|
||||
// Skills install concurrently (bounded by INSTALL_CONCURRENCY) — each writes to a disjoint canonical dir, unique tmpDir, and distinct lock key.
|
||||
const tasks = names.map((name) => async (): Promise<AddOutcome> => {
|
||||
const entry = index.skills[name];
|
||||
if (!entry) {
|
||||
return { name, status: "failed", reason: "skill not found in registry" };
|
||||
}
|
||||
try {
|
||||
const record = await installSkillWithFanout(name, entry, agents);
|
||||
lock.skills[name] = record.lockEntry;
|
||||
return {
|
||||
name,
|
||||
status: "installed",
|
||||
publishedAt: entry.publishedAt,
|
||||
agents: record.linkedAgents,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
name,
|
||||
status: "failed",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
});
|
||||
const results = await runWithConcurrency(tasks, INSTALL_CONCURRENCY);
|
||||
writeSkillLock(lock);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{
|
||||
registry: getSkillRegistryBaseUrl(),
|
||||
agents: agents.map((agent) => agent.id),
|
||||
skills: results,
|
||||
},
|
||||
format,
|
||||
);
|
||||
} else if (results.length === 0) {
|
||||
emitBare("Skill registry is empty; no skills to install.");
|
||||
} else {
|
||||
const rows = results.map((result) => [
|
||||
result.name,
|
||||
result.status,
|
||||
result.publishedAt ? result.publishedAt.slice(0, 10) : "-",
|
||||
result.status === "installed" ? result.agents?.join(", ") || "-" : (result.reason ?? "-"),
|
||||
]);
|
||||
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED", "AGENTS / REASON"], rows)) {
|
||||
emitBare(line);
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => result.status === "failed");
|
||||
if (failed.length > 0) {
|
||||
throw new BailianError(
|
||||
`${failed.length}/${results.length} skill(s) failed to install`,
|
||||
ExitCode.GENERAL,
|
||||
"Check the reason for failed skills in the output; network failures can be retried with bl skill add",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
computeSkillStatuses,
|
||||
fetchSkillsIndex,
|
||||
getSkillRegistryBaseUrl,
|
||||
listSkillDirsOnDisk,
|
||||
readSkillLock,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
const DESCRIPTION_MAX = 60;
|
||||
|
||||
function truncate(text: string | undefined): string {
|
||||
if (!text) return "-";
|
||||
return text.length > DESCRIPTION_MAX ? `${text.slice(0, DESCRIPTION_MAX - 1)}…` : text;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "List registry skills and diff against local installs",
|
||||
auth: "none",
|
||||
exampleArgs: ["", "--output json"],
|
||||
notes: [
|
||||
"STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed)",
|
||||
],
|
||||
async run(ctx) {
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
// Three-way reconciliation: live remote index × skill-lock.json (installation facts) × disk
|
||||
const index = await fetchSkillsIndex();
|
||||
const lock = readSkillLock();
|
||||
const rows = computeSkillStatuses(index, lock, listSkillDirsOnDisk());
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{
|
||||
registry: getSkillRegistryBaseUrl(),
|
||||
...(index.updatedAt ? { updatedAt: index.updatedAt } : {}),
|
||||
skills: rows,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
emitBare("Skill registry is empty and no skills are installed locally.");
|
||||
return;
|
||||
}
|
||||
const table = rows.map((row) => [
|
||||
row.name,
|
||||
row.status,
|
||||
row.publishedAt ? row.publishedAt.slice(0, 19).replace("T", " ") : "-",
|
||||
truncate(row.description),
|
||||
]);
|
||||
for (const line of formatTable(["NAME", "STATUS", "UPDATEDAT", "DESCRIPTION"], table)) {
|
||||
emitBare(line);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
listSkillDirsOnDisk,
|
||||
parseSkillNames,
|
||||
readSkillLock,
|
||||
removeSkillDir,
|
||||
unlinkSkillFromAgents,
|
||||
writeSkillLock,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
interface RemoveOutcome {
|
||||
name: string;
|
||||
status: "removed" | "failed";
|
||||
removedLinks?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Remove locally installed skills (registry is untouched)",
|
||||
auth: "none",
|
||||
usageArgs: "--name <all|name,...>",
|
||||
flags: {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<all|name,...>",
|
||||
description: "Skills to remove: all or comma-separated skill names",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: ["--name spark-video", "--name all"],
|
||||
async run(ctx) {
|
||||
// Purely local operation: no remote access, works offline
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
const requested = parseSkillNames(ctx.flags.name, false);
|
||||
const lock = readSkillLock();
|
||||
const names = requested === "all" ? Object.keys(lock.skills) : requested;
|
||||
|
||||
if (names.length === 0) {
|
||||
emitResult({ skills: [] }, format);
|
||||
if (format === "text") emitBare("No skills installed locally; nothing to remove.");
|
||||
return;
|
||||
}
|
||||
|
||||
const diskDirs = new Set(listSkillDirsOnDisk());
|
||||
const results: RemoveOutcome[] = [];
|
||||
for (const name of names) {
|
||||
const locked = lock.skills[name];
|
||||
if (!locked) {
|
||||
results.push({
|
||||
name,
|
||||
status: "failed",
|
||||
reason: diskDirs.has(name)
|
||||
? "directory not managed by bl skill (untracked); remove manually if needed"
|
||||
: "not installed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Reclaim agent fan-out first, then delete canonical, finally clear the lock entry
|
||||
const removedLinks = unlinkSkillFromAgents(name, locked.links ?? []);
|
||||
removeSkillDir(name);
|
||||
delete lock.skills[name];
|
||||
results.push({ name, status: "removed", removedLinks: removedLinks.length });
|
||||
} catch (err) {
|
||||
results.push({
|
||||
name,
|
||||
status: "failed",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
writeSkillLock(lock);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ skills: results }, format);
|
||||
} else {
|
||||
const rows = results.map((r) => [
|
||||
r.name,
|
||||
r.status,
|
||||
r.status === "removed" ? `reclaimed ${r.removedLinks} agent link(s)` : (r.reason ?? "-"),
|
||||
]);
|
||||
for (const line of formatTable(["NAME", "STATUS", "DETAIL"], rows)) {
|
||||
emitBare(line);
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => r.status === "failed");
|
||||
if (failed.length > 0) {
|
||||
throw new BailianError(
|
||||
`${failed.length}/${results.length} skill(s) failed to remove`,
|
||||
ExitCode.GENERAL,
|
||||
"Check the reason for failed skills in the output; use bl skill list to verify local install status",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
detectInstalledAgents,
|
||||
fetchSkillsIndex,
|
||||
getSkillRegistryBaseUrl,
|
||||
installSkillWithFanout,
|
||||
listSkillDirsOnDisk,
|
||||
parseSkillNames,
|
||||
readSkillLock,
|
||||
runWithConcurrency,
|
||||
writeSkillLock,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare, emitResult, formatTable } from "bailian-cli-runtime";
|
||||
|
||||
interface UpdateOutcome {
|
||||
name: string;
|
||||
status: "updated" | "up-to-date" | "skipped" | "failed";
|
||||
publishedAt?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Max number of skills downloading/installing at the same time. */
|
||||
const UPDATE_CONCURRENCY = 3;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Update installed skills to the latest registry versions",
|
||||
auth: "none",
|
||||
usageArgs: "[--name <all|name,...>]",
|
||||
flags: {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<all|name,...>",
|
||||
description:
|
||||
"Skills to update: all (default, only changed ones) or comma-separated names (force update installed skills)",
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--name spark-video"],
|
||||
async run(ctx) {
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
const requested = parseSkillNames(ctx.flags.name, true);
|
||||
const index = await fetchSkillsIndex();
|
||||
const lock = readSkillLock();
|
||||
const disk = new Set(listSkillDirsOnDisk());
|
||||
|
||||
const results: UpdateOutcome[] = [];
|
||||
const targets: string[] = [];
|
||||
if (requested === "all") {
|
||||
// Default: only process skills already installed in lock; reinstall only if version changed or local dir is missing
|
||||
for (const [name, locked] of Object.entries(lock.skills)) {
|
||||
const entry = index.skills[name];
|
||||
if (!entry) {
|
||||
results.push({
|
||||
name,
|
||||
status: "skipped",
|
||||
reason: "delisted from remote; local copy retained",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.contentHash === locked.contentHash && disk.has(name)) {
|
||||
results.push({ name, status: "up-to-date", publishedAt: locked.publishedAt });
|
||||
continue;
|
||||
}
|
||||
targets.push(name);
|
||||
}
|
||||
} else {
|
||||
// Explicit names: only update skills that are already installed; reject uninstalled ones
|
||||
for (const name of requested) {
|
||||
if (!lock.skills[name]) {
|
||||
results.push({
|
||||
name,
|
||||
status: "failed",
|
||||
reason: "not installed; run bl skill add --name " + name + " first",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
targets.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
const agents = detectInstalledAgents();
|
||||
const tasks = targets.map((name) => async (): Promise<UpdateOutcome> => {
|
||||
const entry = index.skills[name];
|
||||
if (!entry) {
|
||||
return { name, status: "failed", reason: "skill not found in registry" };
|
||||
}
|
||||
try {
|
||||
const record = await installSkillWithFanout(name, entry, agents);
|
||||
lock.skills[name] = record.lockEntry;
|
||||
return { name, status: "updated", publishedAt: entry.publishedAt };
|
||||
} catch (err) {
|
||||
return {
|
||||
name,
|
||||
status: "failed",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
});
|
||||
const updateResults = await runWithConcurrency(tasks, UPDATE_CONCURRENCY);
|
||||
results.push(...updateResults);
|
||||
writeSkillLock(lock);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult({ registry: getSkillRegistryBaseUrl(), skills: results }, format);
|
||||
} else if (results.length === 0) {
|
||||
emitBare("No skills installed locally; run bl skill add first.");
|
||||
} else {
|
||||
const rows = results.map((result) => [
|
||||
result.name,
|
||||
result.status,
|
||||
result.publishedAt ? result.publishedAt.slice(0, 10) : "-",
|
||||
]);
|
||||
for (const line of formatTable(["NAME", "STATUS", "PUBLISHED"], rows)) {
|
||||
emitBare(line);
|
||||
}
|
||||
|
||||
// Footnotes for skipped / failed entries
|
||||
const annotated = results.filter(
|
||||
(result) => (result.status === "skipped" || result.status === "failed") && result.reason,
|
||||
);
|
||||
if (annotated.length > 0) {
|
||||
emitBare("");
|
||||
for (const result of annotated) {
|
||||
emitBare(` ${result.name}: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const failed = results.filter((result) => result.status === "failed");
|
||||
if (failed.length > 0) {
|
||||
throw new BailianError(
|
||||
`${failed.length} skill(s) failed to update`,
|
||||
ExitCode.GENERAL,
|
||||
"Check the reason for failed skills in the output; network failures can be retried with bl skill update",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,22 +1,31 @@
|
||||
import { execSync } from "child_process";
|
||||
import { writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { defineCommand, getConfigDir } from "bailian-cli-core";
|
||||
import { ansi, fetchLatestVersion, type AnsiStyles } from "bailian-cli-runtime";
|
||||
import {
|
||||
BailianError,
|
||||
DEFAULT_INSTALL_PS1_URL,
|
||||
DEFAULT_INSTALL_SCRIPT_URL,
|
||||
defineCommand,
|
||||
getConfigDir,
|
||||
getUpdateInstallMethod,
|
||||
type InstallMethod,
|
||||
} from "bailian-cli-core";
|
||||
import {
|
||||
ansi,
|
||||
fetchLatestVersion,
|
||||
fetchBinaryChannelVersion,
|
||||
isValidUpdateTargetVersion,
|
||||
normalizeBinaryVersion,
|
||||
performBinaryUpdate,
|
||||
type AnsiStyles,
|
||||
} from "bailian-cli-runtime";
|
||||
|
||||
const SKILL_SOURCE = "modelstudioai/cli";
|
||||
const SKILL_INSTALL_CMD = `npx skills add ${SKILL_SOURCE} --all -g -y`;
|
||||
|
||||
/** Build the install command for the given npm package. */
|
||||
function detectInstallCommand(npmPackage: string): { cmd: string; label: string } {
|
||||
return { cmd: `npm install -g ${npmPackage}@latest`, label: "npm" };
|
||||
}
|
||||
|
||||
function updateAgentSkill(color: AnsiStyles): void {
|
||||
process.stderr.write("\nUpdating agent skill...\n");
|
||||
try {
|
||||
// Reinstall (not `skills update`) into ~/.agents/skills/ and sync to all agent apps.
|
||||
// `--all` on `skills add` means --skill '*' --agent '*' -y (Cursor, Claude Code, etc.).
|
||||
execSync(SKILL_INSTALL_CMD, { stdio: "inherit" });
|
||||
process.stderr.write(`${color.green("\u2713 Agent skill updated.")}\n`);
|
||||
} catch {
|
||||
@@ -26,56 +35,140 @@ function updateAgentSkill(color: AnsiStyles): void {
|
||||
}
|
||||
}
|
||||
|
||||
function writeUpdateState(version: string): void {
|
||||
try {
|
||||
const stateFile = join(getConfigDir(), "update-state.json");
|
||||
writeFileSync(stateFile, JSON.stringify({ lastChecked: Date.now(), latestVersion: version }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLatest(method: InstallMethod, npmPackage: string): Promise<string | null> {
|
||||
if (method === "binary") {
|
||||
return (
|
||||
(await fetchBinaryChannelVersion("latest", 5000)) ??
|
||||
(await fetchLatestVersion(5000, npmPackage))
|
||||
);
|
||||
}
|
||||
return fetchLatestVersion(5000, npmPackage);
|
||||
}
|
||||
|
||||
function binaryReinstallHint(): string {
|
||||
if (process.platform === "win32") {
|
||||
return ` irm ${DEFAULT_INSTALL_PS1_URL} | iex\n`;
|
||||
}
|
||||
return ` curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash\n`;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Update the CLI to the latest version",
|
||||
description: "Update the CLI to the latest or a specified version",
|
||||
auth: "none",
|
||||
exampleArgs: [""],
|
||||
usageArgs: "[--to <version>]",
|
||||
flags: {
|
||||
to: {
|
||||
type: "string",
|
||||
valueHint: "<version>",
|
||||
description: "Install this exact version instead of the latest",
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--to 0.1.14"],
|
||||
validate(flags) {
|
||||
if (flags.to === undefined) return undefined;
|
||||
if (!flags.to.trim()) return "--to requires a non-empty version";
|
||||
if (!isValidUpdateTargetVersion(flags.to)) {
|
||||
return `--to must be a semver version (e.g. 1.13.0, v1.13.0, 0.0.0-beta-<sha>-<YYYYMMDDHHMM>), got: ${flags.to.trim()}`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
async run(ctx) {
|
||||
const { identity } = ctx;
|
||||
const npmPackage = identity.npmPackage;
|
||||
const binName = identity.binName;
|
||||
const currentVersion = identity.version;
|
||||
const color = ansi(process.stderr);
|
||||
const method = getUpdateInstallMethod(identity);
|
||||
const requestedTo = ctx.flags.to?.trim();
|
||||
const pinnedVersion = requestedTo ? normalizeBinaryVersion(requestedTo) : undefined;
|
||||
|
||||
process.stderr.write(`Current version: ${color.yellow(currentVersion)}\n`);
|
||||
process.stderr.write(`Install method: ${color.dim(method)}\n`);
|
||||
if (pinnedVersion) {
|
||||
process.stderr.write(`Target version: ${color.green(pinnedVersion)}\n`);
|
||||
} else {
|
||||
process.stderr.write("Checking for updates...\n");
|
||||
}
|
||||
|
||||
// Check latest version first
|
||||
process.stderr.write("Checking for updates...\n");
|
||||
const latest = await fetchLatestVersion(5000, npmPackage);
|
||||
|
||||
if (latest && latest === currentVersion) {
|
||||
process.stderr.write(`${color.green(`\u2713 Already up to date (${currentVersion}).`)}\n`);
|
||||
updateAgentSkill(color);
|
||||
if (method === "brew" || method === "winget") {
|
||||
const cmd =
|
||||
method === "brew" ? "brew upgrade bailian-cli" : "winget upgrade Aliyun.BailianCLI";
|
||||
process.stderr.write(
|
||||
`${color.yellow(`This CLI was installed via ${method}. Update with:`)}\n ${cmd}\n`,
|
||||
);
|
||||
if (pinnedVersion) {
|
||||
process.stderr.write(
|
||||
`${color.dim(`Note: --to is not supported for ${method} installs.`)}\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (latest) {
|
||||
process.stderr.write(`Latest version: ${color.green(latest)}\n\n`);
|
||||
const targetVersion = pinnedVersion ?? (await resolveLatest(method, npmPackage));
|
||||
|
||||
if (!targetVersion) {
|
||||
process.stderr.write(`${color.yellow("Could not determine the latest version.")}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { cmd, label } = detectInstallCommand(npmPackage);
|
||||
process.stderr.write(`Updating ${npmPackage} via ${label}...\n\n`);
|
||||
if (targetVersion === currentVersion) {
|
||||
const message = pinnedVersion
|
||||
? `\u2713 Already at ${currentVersion}.`
|
||||
: `\u2713 Already up to date (${currentVersion}).`;
|
||||
process.stderr.write(`${color.green(message)}\n`);
|
||||
if (method === "npm") updateAgentSkill(color);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pinnedVersion) {
|
||||
process.stderr.write(`Latest version: ${color.green(targetVersion)}\n\n`);
|
||||
} else {
|
||||
process.stderr.write("\n");
|
||||
}
|
||||
|
||||
if (method === "binary") {
|
||||
process.stderr.write(`Updating via binary channel...\n\n`);
|
||||
try {
|
||||
const newVer = await performBinaryUpdate(targetVersion);
|
||||
process.stderr.write(
|
||||
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`,
|
||||
);
|
||||
writeUpdateState(newVer);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const reinstall =
|
||||
error instanceof BailianError && error.hint
|
||||
? error.hint.replace(/^Re-run:\s*/i, "")
|
||||
: binaryReinstallHint().trim();
|
||||
process.stderr.write(`\nAutomatic binary update failed: ${message}\n`);
|
||||
process.stderr.write("Re-run the install script:\n");
|
||||
process.stderr.write(` ${reinstall}\n\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const npmSpec = pinnedVersion ? `${npmPackage}@${pinnedVersion}` : `${npmPackage}@latest`;
|
||||
const cmd = `npm install -g ${npmSpec}`;
|
||||
process.stderr.write(`Updating ${npmPackage} via npm...\n\n`);
|
||||
|
||||
try {
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
// Verify the installed version after update
|
||||
try {
|
||||
const rawVer = execSync(`${binName} --version 2>/dev/null`, { encoding: "utf-8" }).trim();
|
||||
// `<bin> --version` outputs "<bin> X.Y.Z" — extract just the version number
|
||||
const newVer = rawVer.replace(new RegExp(`^${binName}\\s+`), "");
|
||||
process.stderr.write(
|
||||
`\n${color.green(`\u2713 Update complete: ${currentVersion} \u2192 ${newVer}`)}\n`,
|
||||
);
|
||||
// Update the cached state so the post-run notification doesn't fire
|
||||
try {
|
||||
const stateFile = join(getConfigDir(), "update-state.json");
|
||||
writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({ lastChecked: Date.now(), latestVersion: newVer }),
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
writeUpdateState(newVer);
|
||||
} catch {
|
||||
process.stderr.write(`\n${color.green("\u2713 Update complete.")}\n`);
|
||||
}
|
||||
|
||||
@@ -113,3 +113,7 @@ export { default as pluginInstall } from "./commands/plugin/install.ts";
|
||||
export { default as pluginLink } from "./commands/plugin/link.ts";
|
||||
export { default as pluginList } from "./commands/plugin/list.ts";
|
||||
export { default as pluginRemove } from "./commands/plugin/remove.ts";
|
||||
export { default as skillAdd } from "./commands/skill/add.ts";
|
||||
export { default as skillUpdate } from "./commands/skill/update.ts";
|
||||
export { default as skillRemove } from "./commands/skill/remove.ts";
|
||||
export { default as skillList } from "./commands/skill/list.ts";
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { existsSync, mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { isBailianE2EEnabled, parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { SKILL_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
/** Canonical always-published skill; also the backbone of advisor wiki sync */
|
||||
const WIKI_SKILL = "bailian-docs-llm-wiki";
|
||||
|
||||
/** Redirect ~/.bailian into a throwaway dir so lock/skill writes never touch the real user config */
|
||||
function makeTempConfigDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "bl-skill-e2e-"));
|
||||
}
|
||||
|
||||
describe("e2e: skill", () => {
|
||||
test("skill add --help exits successfully", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "add", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--name/);
|
||||
});
|
||||
|
||||
test("skill update --help exits successfully", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "update", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--name/);
|
||||
});
|
||||
|
||||
test("skill remove --help exits successfully", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "remove", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--name/);
|
||||
});
|
||||
|
||||
test("skill list --help exits successfully", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, ["skill", "list", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/list|registry/i);
|
||||
});
|
||||
});
|
||||
|
||||
// Local-only cases: auth "none" + validation happens before any network access, no gating needed
|
||||
describe("e2e: skill (local, no credentials)", () => {
|
||||
test("skill add without --name errors as usage error (2)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
|
||||
"skill",
|
||||
"add",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
|
||||
});
|
||||
|
||||
test("skill remove without --name errors as usage error (2)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
|
||||
"skill",
|
||||
"remove",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/--name|Usage:/i);
|
||||
});
|
||||
|
||||
test("skill add rejects mixing all with specific names (2)", async () => {
|
||||
// parseSkillNames throws UsageError before fetchSkillsIndex — offline-safe
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(SKILL_ROUTES, [
|
||||
"skill",
|
||||
"add",
|
||||
"--name",
|
||||
"all,spark-video",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(`${stdout}\n${stderr}`).toMatch(/all/i);
|
||||
});
|
||||
|
||||
test("skill remove of a not-installed skill fails with reason (1)", async () => {
|
||||
const configDir = makeTempConfigDir();
|
||||
const { stdout, exitCode } = await runCommandE2e(
|
||||
SKILL_ROUTES,
|
||||
["skill", "remove", "--name", "definitely-not-installed", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(exitCode).toBe(1);
|
||||
const data = parseStdoutJson<{
|
||||
skills?: Array<{ name?: string; status?: string; reason?: string }>;
|
||||
}>(stdout);
|
||||
expect(data.skills?.[0]?.status).toBe("failed");
|
||||
expect(data.skills?.[0]?.reason).toMatch(/not installed/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EEnabled())("e2e: skill (real registry)", () => {
|
||||
test("skill list --output json returns registry and status rows", async () => {
|
||||
const configDir = makeTempConfigDir();
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
SKILL_ROUTES,
|
||||
["skill", "list", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
registry?: string;
|
||||
skills?: Array<{ name?: string; status?: string }>;
|
||||
}>(stdout);
|
||||
expect(data.registry).toMatch(/^https?:\/\//);
|
||||
expect(Array.isArray(data.skills)).toBe(true);
|
||||
}, 60_000);
|
||||
|
||||
test("skill add + remove full lifecycle in isolated dirs", async () => {
|
||||
const configDir = makeTempConfigDir();
|
||||
// Empty fake home → no agents detected → fan-out never leaves the sandbox
|
||||
const fakeHome = makeTempConfigDir();
|
||||
const env = { BAILIAN_CONFIG_DIR: configDir, HOME: fakeHome, USERPROFILE: fakeHome };
|
||||
|
||||
const added = await runCommandE2e(
|
||||
SKILL_ROUTES,
|
||||
["skill", "add", "--name", WIKI_SKILL, "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(added.exitCode, added.stderr).toBe(0);
|
||||
const addData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>(
|
||||
added.stdout,
|
||||
);
|
||||
expect(addData.skills?.[0]?.status).toBe("installed");
|
||||
expect(existsSync(join(configDir, "skills", WIKI_SKILL, "SKILL.md"))).toBe(true);
|
||||
|
||||
const removed = await runCommandE2e(
|
||||
SKILL_ROUTES,
|
||||
["skill", "remove", "--name", WIKI_SKILL, "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(removed.exitCode, removed.stderr).toBe(0);
|
||||
const removeData = parseStdoutJson<{ skills?: Array<{ name?: string; status?: string }> }>(
|
||||
removed.stdout,
|
||||
);
|
||||
expect(removeData.skills?.[0]?.status).toBe("removed");
|
||||
expect(existsSync(join(configDir, "skills", WIKI_SKILL))).toBe(false);
|
||||
}, 300_000);
|
||||
});
|
||||
@@ -10,6 +10,10 @@ export const AUTH_ROUTES: E2eRouteExports = {
|
||||
"auth logout": "authLogout",
|
||||
};
|
||||
|
||||
export const UPDATE_ROUTES: E2eRouteExports = {
|
||||
update: "update",
|
||||
};
|
||||
|
||||
export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" };
|
||||
|
||||
export const CONFIG_ROUTES: E2eRouteExports = {
|
||||
@@ -156,6 +160,13 @@ export const TOKEN_PLAN_ROUTES: E2eRouteExports = {
|
||||
"token-plan add-member": "tokenPlanAddMember",
|
||||
};
|
||||
|
||||
export const SKILL_ROUTES: E2eRouteExports = {
|
||||
"skill add": "skillAdd",
|
||||
"skill update": "skillUpdate",
|
||||
"skill remove": "skillRemove",
|
||||
"skill list": "skillList",
|
||||
};
|
||||
|
||||
export const MANAGED_AGENT_ROUTES: E2eRouteExports = {
|
||||
"managed-agent init": "managedAgentInit",
|
||||
"managed-agent validate": "managedAgentValidate",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { runCommandE2e } from "./helpers.ts";
|
||||
import { UPDATE_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: update", () => {
|
||||
test("update --help 正常退出并展示 --to", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--to/);
|
||||
expect(stderr).toMatch(/<version>/);
|
||||
});
|
||||
|
||||
test("update --help 包含 --to 示例", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("--to 0.1.14");
|
||||
});
|
||||
|
||||
test("update --to 缺值时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, ["update", "--to"]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
});
|
||||
|
||||
test("update --to 非法版本时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(UPDATE_ROUTES, [
|
||||
"update",
|
||||
"--to",
|
||||
"not-a-version",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/semver|--to/i);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-core",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
@@ -40,11 +40,13 @@
|
||||
"check": "vp check"
|
||||
},
|
||||
"dependencies": {
|
||||
"tar-stream": "catalog:",
|
||||
"yaml": "^2.8.3",
|
||||
"yauzl": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/tar-stream": "catalog:",
|
||||
"@types/yauzl": "catalog:",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260328.1",
|
||||
"typescript": "^6.0.2",
|
||||
|
||||
@@ -7,6 +7,7 @@ export { recallCandidates } from "./recall.ts";
|
||||
export { recallSemantic, isSemanticAvailable } from "./recall-semantic.ts";
|
||||
export type { RecommendOptions } from "./recommend.ts";
|
||||
export { buildDocLink, rankModels } from "./recommend.ts";
|
||||
export { maybeSyncWikiData } from "./sync.ts";
|
||||
export type { ModelSource } from "./sources/types.ts";
|
||||
export type {
|
||||
Budget,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getConfigDir } from "../../config/paths.ts";
|
||||
import type { ModelPrice, ModelProfile, QpmLimit } from "../types.ts";
|
||||
import type { ModelSource } from "./types.ts";
|
||||
@@ -13,12 +12,9 @@ function getCatalogDir(): string {
|
||||
}
|
||||
|
||||
function getCatalogPath(): string {
|
||||
return join(getCatalogDir(), MODELS_FILE);
|
||||
}
|
||||
|
||||
function getMonorepoModelsDir(): string {
|
||||
const coreDir = dirname(fileURLToPath(import.meta.url));
|
||||
return join(coreDir, "../../../../../skills/bailian-docs-llm-wiki/models");
|
||||
// Full-package layout keeps the `models/` subdir (raw/, wiki/, models/, …),
|
||||
// so models.jsonl lives at <skill>/models/models.jsonl — not at the skill root.
|
||||
return join(getCatalogDir(), "models", MODELS_FILE);
|
||||
}
|
||||
|
||||
function fromJsonlRecord(raw: Record<string, unknown>): ModelProfile | null {
|
||||
@@ -62,41 +58,24 @@ function readJsonlModels(filePath: string): ModelProfile[] {
|
||||
return models;
|
||||
}
|
||||
|
||||
function installFromMonorepo(): boolean {
|
||||
const src = getMonorepoModelsDir();
|
||||
if (!existsSync(join(src, MODELS_FILE))) return false;
|
||||
const dest = getCatalogDir();
|
||||
try {
|
||||
mkdirSync(dest, { recursive: true });
|
||||
cpSync(src, dest, { recursive: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CatalogSourceOptions {
|
||||
onPrepareStart?: () => void;
|
||||
}
|
||||
|
||||
export class CatalogSource implements ModelSource {
|
||||
readonly name = "catalog";
|
||||
private options: CatalogSourceOptions;
|
||||
|
||||
constructor(options?: CatalogSourceOptions) {
|
||||
this.options = options ?? {};
|
||||
}
|
||||
// Options retained for API compatibility. Data is now always provisioned by
|
||||
// the CLI postinstall hook and refreshed by advisor sync, so the previous
|
||||
// `onPrepareStart` install callback is obsolete.
|
||||
constructor(_options?: CatalogSourceOptions) {}
|
||||
|
||||
available(): boolean {
|
||||
return existsSync(getCatalogPath());
|
||||
}
|
||||
|
||||
async load(): Promise<ModelProfile[]> {
|
||||
if (!this.available()) {
|
||||
this.options.onPrepareStart?.();
|
||||
const installed = installFromMonorepo();
|
||||
if (!installed) return [];
|
||||
}
|
||||
if (!this.available()) return [];
|
||||
return readJsonlModels(getCatalogPath());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* sync.ts — Wiki data sync (layer 2: triggered by recommend)
|
||||
*
|
||||
* Called via `maybeSyncWikiData()` during `bl advisor recommend`:
|
||||
* 1. 12h throttle: skip if last check was less than 12h ago
|
||||
* 2. Download skills/index.json from public-read OSS, compare bailian-docs-llm-wiki entry version
|
||||
* 3. Same version → only refresh lastChecked
|
||||
* 4. Different version → delegate to the shared skill install pipeline
|
||||
* (installSkill: download + extract + SKILL.md validate + atomic swap;
|
||||
* linkSkillToAgents: fan-out symlinks to detected agents;
|
||||
* upsertSkillLockEntry: write lock WITH links so bl skill remove can reclaim correctly)
|
||||
*
|
||||
* Protocol: unified skill publishing protocol (FC publish-skills, all skills are isomorphic), entry point is
|
||||
* skills/index.json, one content-addressed object per skill (sha256-<hex>.tar.br, brotli q6;
|
||||
* legacy fallback skill.tar.br).
|
||||
*
|
||||
* Complements postinstall.js (layer 1, unconditional overwrite on npm install). Install, extraction,
|
||||
* validation, fan-out and lock writing all reuse the skills/ module (same as bl skill add), symmetric
|
||||
* with publisher tar.pack().
|
||||
*
|
||||
* Failure strategy: any step failure silently returns without updating lastChecked; next recommend retries immediately.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getConfigDir } from "../config/paths.ts";
|
||||
import { buildSkillLockEntry, installSkillWithFanout } from "../skills/installer.ts";
|
||||
import { readSkillLock, upsertSkillLockEntry } from "../skills/lock.ts";
|
||||
import { fetchSkillsIndex } from "../skills/registry.ts";
|
||||
import type { SkillIndexEntry, SkillLockEntry } from "../skills/types.ts";
|
||||
|
||||
const WIKI_SKILL_NAME = "bailian-docs-llm-wiki";
|
||||
const SKILL_DIR_NAME = "skills/bailian-docs-llm-wiki";
|
||||
const STATE_FILE_NAME = "wiki-sync-state.json";
|
||||
const MODELS_FILE = "models.jsonl";
|
||||
|
||||
const THROTTLE_MS = 12 * 60 * 60 * 1000; // 12h
|
||||
/** Tighter than the interactive default: the silent channel must not stall `bl advisor recommend` */
|
||||
const INDEX_TIMEOUT_MS = 3000;
|
||||
|
||||
interface SyncState {
|
||||
lastChecked: number;
|
||||
/** Content fingerprint of the last synced revision; the change-detection token */
|
||||
contentHash: string;
|
||||
}
|
||||
|
||||
function getCatalogDir(): string {
|
||||
return join(getConfigDir(), SKILL_DIR_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether local Wiki data is ready. Uses `models.jsonl` as the existence signal, consistent with
|
||||
* `CatalogSource.available()`: as long as the file advisor actually consumes exists,
|
||||
* the data is considered available.
|
||||
*/
|
||||
function catalogDataExists(): boolean {
|
||||
return existsSync(join(getCatalogDir(), "models", MODELS_FILE));
|
||||
}
|
||||
|
||||
function getStatePath(): string {
|
||||
return join(getConfigDir(), STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
function readState(): SyncState | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(getStatePath(), "utf-8")) as SyncState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state: SyncState): void {
|
||||
try {
|
||||
writeFileSync(getStatePath(), JSON.stringify(state));
|
||||
} catch {
|
||||
/* Non-critical: if state write fails, next run will re-check */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record this sync in skill-lock.json so the wiki skill shares the same ledger as bl skill
|
||||
* (list shows installed instead of untracked; update/remove can manage it correctly).
|
||||
* Includes fan-out links so bl skill remove can reclaim agent symlinks.
|
||||
* Bookkeeping in the silent channel must be best-effort: failure does not affect sync results.
|
||||
*/
|
||||
function recordWikiInLock(lockEntry: SkillLockEntry): void {
|
||||
try {
|
||||
upsertSkillLockEntry(WIKI_SKILL_NAME, lockEntry);
|
||||
} catch {
|
||||
/* Bookkeeping failure does not block sync; next sync or bl skill add will fill it in */
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether lock already has a wiki record matching the remote content fingerprint (avoids rewriting lock on every 12h check) */
|
||||
function wikiLockUpToDate(contentHash: string): boolean {
|
||||
try {
|
||||
return readSkillLock().skills[WIKI_SKILL_NAME]?.contentHash === contentHash;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch skills/index.json via the shared registry client and extract the wiki skill entry; returns null on any failure */
|
||||
async function fetchIndexEntry(): Promise<SkillIndexEntry | null> {
|
||||
try {
|
||||
const index = await fetchSkillsIndex(INDEX_TIMEOUT_MS);
|
||||
return index.skills[WIKI_SKILL_NAME] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and sync Wiki data. Runs silently; never throws.
|
||||
* @returns Whether data was actually updated (for testing/debugging)
|
||||
*/
|
||||
export async function maybeSyncWikiData(): Promise<boolean> {
|
||||
const state = readState();
|
||||
const now = Date.now();
|
||||
|
||||
// 1. throttle gate: only skip when "within the 12h window" AND "local data actually exists".
|
||||
// If data is missing (user deleted manually, postinstall failed but state remains, etc.),
|
||||
// ignore throttle and sync immediately to ensure advisor has data.
|
||||
if (state && now - state.lastChecked < THROTTLE_MS && catalogDataExists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Fetch skills/index.json and get the wiki entry
|
||||
const entry = await fetchIndexEntry();
|
||||
if (!entry?.contentHash) return false; // On failure, do not write lastChecked; retry next time
|
||||
|
||||
// 3. Same content and local data exists: only refresh lastChecked, no re-download needed.
|
||||
// Covers two cases: (a) state.contentHash === entry.contentHash → direct hit;
|
||||
// (b) state missing but data intact (user or accident only deleted state) → write the fingerprint
|
||||
// back to state, avoiding unnecessary download+extract.
|
||||
// If data is missing or the fingerprint differs, falls through to step 4 for full download.
|
||||
const dataOk = catalogDataExists();
|
||||
if (dataOk && (!state || state.contentHash === entry.contentHash)) {
|
||||
writeState({ lastChecked: now, contentHash: entry.contentHash });
|
||||
// Data and content are ready but lock record is missing/stale (e.g. postinstall landed before this mechanism) → backfill
|
||||
if (!wikiLockUpToDate(entry.contentHash)) recordWikiInLock(buildSkillLockEntry(entry, []));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Different content or missing data: delegate to the shared skill install pipeline
|
||||
// (download → extract → SKILL.md validate → atomic swap → fan-out → lock with links)
|
||||
try {
|
||||
const record = await installSkillWithFanout(WIKI_SKILL_NAME, entry);
|
||||
recordWikiInLock(record.lockEntry);
|
||||
} catch {
|
||||
// Install failed → clean exit, leave existing data untouched, do not write state; next recommend retries
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. Success: write state
|
||||
writeState({ lastChecked: now, contentHash: entry.contentHash });
|
||||
return true;
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export interface DatasetUploadParams {
|
||||
export async function uploadDataset(
|
||||
client: Client,
|
||||
params: DatasetUploadParams,
|
||||
): Promise<DatasetFile> {
|
||||
): Promise<DatasetFile & { request_id?: string }> {
|
||||
const { filePath, purpose = "fine-tune", signal } = params;
|
||||
const stat = statSync(filePath);
|
||||
const fileName = basename(filePath);
|
||||
@@ -75,6 +75,7 @@ export async function uploadDataset(
|
||||
size: body.bytes ?? stat.size,
|
||||
purpose: body.purpose ?? purpose,
|
||||
gmt_create: body.created_at ? new Date(body.created_at * 1000).toISOString() : undefined,
|
||||
request_id: body.request_id,
|
||||
};
|
||||
}
|
||||
// No id in response → upload reported HTTP 200 but produced no usable record
|
||||
|
||||
@@ -16,3 +16,5 @@ export * from "./types/index.ts";
|
||||
export * from "./utils/index.ts";
|
||||
export * from "./telemetry/index.ts";
|
||||
export * from "./advisor/index.ts";
|
||||
export * from "./install/index.ts";
|
||||
export * from "./skills/index.ts";
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* End-user binary download base (OSS). CI publishes release assets and rolling
|
||||
* channel manifests here directly (tools/release/lib/oss-direct-upload.mjs);
|
||||
* no external FC is involved.
|
||||
*
|
||||
* Layout under the base:
|
||||
* v<version>/<asset>.zip —— immutable per-version binaries + SHA256SUMS
|
||||
* manifest.json —— stable install/update pointer (rolling-manifest shape)
|
||||
* latest.json —— stable alias; same body as manifest.json
|
||||
* sync-release.json —— official channel/verify rolling pointer (all bailian-cli
|
||||
* channel publishes overwrite this; npm dist-tag is separate)
|
||||
*
|
||||
* Legacy `{name}.json` files may still exist on CDN; install may resolve them, but
|
||||
* release tooling no longer creates per-dist-tag manifests.
|
||||
*
|
||||
* Override with `BAILIAN_CLI_CDN`.
|
||||
*/
|
||||
export const DEFAULT_CLI_CDN_BASE = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release";
|
||||
|
||||
/** GitHub Releases base — used when writing manifests attached to gh release assets. */
|
||||
export const GITHUB_RELEASES_BASE = "https://github.com/modelstudioai/cli/releases";
|
||||
|
||||
/** User-facing install entry (docs / update hints); asset downloads still use getCliCdnBase(). */
|
||||
export const DEFAULT_INSTALL_SCRIPT_URL = "https://bailian.aliyun.com/cli/install.sh";
|
||||
export const DEFAULT_INSTALL_PS1_URL = "https://bailian.aliyun.com/cli/install.ps1";
|
||||
|
||||
export function getCliCdnBase(): string {
|
||||
const fromEnv = process.env.BAILIAN_CLI_CDN?.trim();
|
||||
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
||||
return DEFAULT_CLI_CDN_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling manifest URL at the CDN base root.
|
||||
* Stable (`latest` / `stable` / empty) → `manifest.json`.
|
||||
* Official verify line → `sync-release.json` (`channel=sync-release`).
|
||||
* Other names still map to `{channel}.json` for backward compatibility only.
|
||||
* All share the same rolling-manifest shape from binary-build.
|
||||
*/
|
||||
export function channelManifestUrl(channel = "latest"): string {
|
||||
const normalized = channel.trim();
|
||||
if (!normalized || normalized === "latest" || normalized === "stable") {
|
||||
return `${getCliCdnBase()}/manifest.json`;
|
||||
}
|
||||
return `${getCliCdnBase()}/${normalized}.json`;
|
||||
}
|
||||
|
||||
/** Immutable per-version asset: `{base}/v{version}/{fileName}`. */
|
||||
export function releaseAssetUrl(version: string, fileName: string): string {
|
||||
const tag = version.startsWith("v") ? version : `v${version}`;
|
||||
return `${getCliCdnBase()}/${tag}/${fileName}`;
|
||||
}
|
||||
|
||||
/** Platform triple used in asset names: `bl-<ver>-<os>-<arch>[.exe]`. */
|
||||
export function detectBinaryPlatform(): { os: string; arch: string; fileSuffix: string } {
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
let os: string;
|
||||
if (platform === "darwin") os = "darwin";
|
||||
else if (platform === "linux") os = "linux";
|
||||
else if (platform === "win32") os = "windows";
|
||||
else {
|
||||
throw new Error(`Unsupported platform for binary updates: ${platform}`);
|
||||
}
|
||||
|
||||
let normalizedArch: string;
|
||||
if (arch === "arm64") normalizedArch = "arm64";
|
||||
else if (arch === "x64") normalizedArch = "x64";
|
||||
else {
|
||||
throw new Error(`Unsupported architecture for binary updates: ${arch}`);
|
||||
}
|
||||
|
||||
if (os === "linux" && normalizedArch === "arm64") {
|
||||
throw new Error(
|
||||
"linux arm64 is not supported for binary updates; use: npm install -g bailian-cli",
|
||||
);
|
||||
}
|
||||
if (os === "windows" && normalizedArch === "arm64") {
|
||||
throw new Error(
|
||||
"windows arm64 is not supported for binary updates; use: npm install -g bailian-cli",
|
||||
);
|
||||
}
|
||||
|
||||
const fileSuffix = platform === "win32" ? ".exe" : "";
|
||||
return { os, arch: normalizedArch, fileSuffix };
|
||||
}
|
||||
|
||||
/** Release download asset: `bl-<ver>-<os>-<arch>.zip`. */
|
||||
export function binaryAssetFileName(
|
||||
version: string,
|
||||
os: string,
|
||||
arch: string,
|
||||
_exe = false,
|
||||
): string {
|
||||
return `bl-${version}-${os}-${arch}.zip`;
|
||||
}
|
||||
|
||||
/** Uncompressed binary name inside the zip. */
|
||||
export function binaryInnerFileName(
|
||||
version: string,
|
||||
os: string,
|
||||
arch: string,
|
||||
exe = false,
|
||||
): string {
|
||||
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
BINARY_PRODUCT_CLIENT_NAME,
|
||||
detectInstallMethod,
|
||||
getInstallMethod,
|
||||
getUpdateInstallMethod,
|
||||
isCompiledBinary,
|
||||
writeInstallMethodSync,
|
||||
type InstallMethod,
|
||||
type InstallMethodIdentity,
|
||||
} from "./method.ts";
|
||||
export {
|
||||
DEFAULT_CLI_CDN_BASE,
|
||||
DEFAULT_INSTALL_PS1_URL,
|
||||
DEFAULT_INSTALL_SCRIPT_URL,
|
||||
GITHUB_RELEASES_BASE,
|
||||
binaryAssetFileName,
|
||||
binaryInnerFileName,
|
||||
channelManifestUrl,
|
||||
detectBinaryPlatform,
|
||||
getCliCdnBase,
|
||||
releaseAssetUrl,
|
||||
} from "./cdn.ts";
|
||||
export { extractZipEntryToFile } from "./unzip-asset.ts";
|
||||
@@ -0,0 +1,131 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getConfigDir } from "../config/paths.ts";
|
||||
|
||||
/** How the CLI was installed on this machine. */
|
||||
export type InstallMethod = "binary" | "npm" | "brew" | "winget" | "unknown";
|
||||
|
||||
/** Product that currently ships standalone binary artifacts (`bl` / `bailian`). */
|
||||
export const BINARY_PRODUCT_CLIENT_NAME = "bailian-cli";
|
||||
|
||||
const INSTALL_METHOD_FILE = "install-method";
|
||||
const VALID_METHODS = new Set<InstallMethod>(["binary", "npm", "brew", "winget", "unknown"]);
|
||||
|
||||
export type InstallMethodIdentity = {
|
||||
clientName: string;
|
||||
};
|
||||
|
||||
function installMethodPath(clientName?: string): string {
|
||||
if (!clientName) return join(getConfigDir(), INSTALL_METHOD_FILE);
|
||||
return join(getConfigDir(), `${INSTALL_METHOD_FILE}.${clientName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when running a Bun-compiled standalone executable
|
||||
* rather than via the Node/npm entry shim.
|
||||
*
|
||||
* Binary entrypoints set `BAILIAN_COMPILED=1` before other code runs.
|
||||
*/
|
||||
export function isCompiledBinary(): boolean {
|
||||
if (process.env.BAILIAN_COMPILED === "1") return true;
|
||||
const execPath = process.execPath.replaceAll("\\", "/");
|
||||
if (/(^|\/)node(\.exe)?$/i.test(execPath) || execPath.includes("/node/")) return false;
|
||||
if (/(^|\/)bun(\.exe)?$/i.test(execPath) || execPath.includes("/.bun/")) return false;
|
||||
return /\/(bl|bailian)(\.exe)?$/i.test(execPath);
|
||||
}
|
||||
|
||||
function parseInstallMethod(raw: string | undefined): InstallMethod | null {
|
||||
if (!raw) return null;
|
||||
const value = raw.trim().toLowerCase() as InstallMethod;
|
||||
return VALID_METHODS.has(value) ? value : null;
|
||||
}
|
||||
|
||||
function readInstallMethodFile(path: string): InstallMethod | null {
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
return parseInstallMethod(raw.split("\n")[0]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Infer install method when no marker file / env override is present. */
|
||||
export function detectInstallMethod(): InstallMethod {
|
||||
const fromEnv = parseInstallMethod(process.env.BAILIAN_INSTALL_METHOD);
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
if (isCompiledBinary()) {
|
||||
const execPath = process.execPath.replaceAll("\\", "/");
|
||||
if (execPath.includes("/Cellar/") || execPath.includes("/homebrew/")) return "brew";
|
||||
return "binary";
|
||||
}
|
||||
|
||||
return "npm";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted install method, falling back to detection.
|
||||
*
|
||||
* When `identity` is provided, prefer `install-method.<clientName>`.
|
||||
* Legacy `~/.bailian/install-method` is only consulted for `bailian-cli`
|
||||
* so other products (e.g. kscli) are not polluted by a shared binary marker.
|
||||
*/
|
||||
export function getInstallMethod(identity?: InstallMethodIdentity): InstallMethod {
|
||||
const fromEnv = parseInstallMethod(process.env.BAILIAN_INSTALL_METHOD);
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
if (identity?.clientName) {
|
||||
const productMethod = readInstallMethodFile(installMethodPath(identity.clientName));
|
||||
if (productMethod) return productMethod;
|
||||
|
||||
if (identity.clientName === BINARY_PRODUCT_CLIENT_NAME) {
|
||||
const legacyMethod = readInstallMethodFile(installMethodPath());
|
||||
if (legacyMethod) return legacyMethod;
|
||||
}
|
||||
|
||||
return detectInstallMethod();
|
||||
}
|
||||
|
||||
const legacyMethod = readInstallMethodFile(installMethodPath());
|
||||
if (legacyMethod) return legacyMethod;
|
||||
|
||||
return detectInstallMethod();
|
||||
}
|
||||
|
||||
/**
|
||||
* Install method for update / auto-update routing.
|
||||
* Only `bailian-cli` may follow the binary channel; other products always use npm
|
||||
* even if env or a mistaken marker claims `binary`.
|
||||
*/
|
||||
export function getUpdateInstallMethod(identity: {
|
||||
clientName: string;
|
||||
npmPackage: string;
|
||||
}): InstallMethod {
|
||||
const method = getInstallMethod(identity);
|
||||
if (method === "binary" && identity.npmPackage !== BINARY_PRODUCT_CLIENT_NAME) {
|
||||
return "npm";
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist install method under `~/.bailian/install-method.<clientName>` (best-effort).
|
||||
* For `bailian-cli`, also write the legacy `install-method` file for older readers.
|
||||
*/
|
||||
export function writeInstallMethodSync(
|
||||
method: InstallMethod,
|
||||
identity: InstallMethodIdentity = { clientName: BINARY_PRODUCT_CLIENT_NAME },
|
||||
): void {
|
||||
try {
|
||||
const dir = getConfigDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
writeFileSync(installMethodPath(identity.clientName), `${method}\n`, { mode: 0o600 });
|
||||
if (identity.clientName === BINARY_PRODUCT_CLIENT_NAME) {
|
||||
writeFileSync(installMethodPath(), `${method}\n`, { mode: 0o600 });
|
||||
}
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Extract a single file entry from a ZIP into `destPath` (overwrites).
|
||||
* Uses yauzl (already a core dependency for dataset ZIP validation).
|
||||
*/
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import * as yauzl from "yauzl";
|
||||
|
||||
function openZip(zipPath: string): Promise<yauzl.ZipFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(zipPath, { lazyEntries: true }, (error, zipfile) => {
|
||||
if (error || !zipfile) {
|
||||
reject(error ?? new Error(`Failed to open zip: ${zipPath}`));
|
||||
return;
|
||||
}
|
||||
resolve(zipfile);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function entryBaseName(fileName: string): string {
|
||||
const normalized = fileName.replace(/\\/g, "/");
|
||||
return normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `entryName` (or the first non-directory entry) from `zipPath` to `destPath`.
|
||||
* Returns the archive entry basename that was extracted.
|
||||
*/
|
||||
export async function extractZipEntryToFile(
|
||||
zipPath: string,
|
||||
destPath: string,
|
||||
entryName?: string,
|
||||
): Promise<string> {
|
||||
const zipfile = await openZip(zipPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const fail = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
zipfile.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
const succeed = (baseName: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
zipfile.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resolve(baseName);
|
||||
};
|
||||
|
||||
zipfile.on("error", fail);
|
||||
zipfile.on("end", () => {
|
||||
if (settled) return;
|
||||
fail(
|
||||
new Error(
|
||||
entryName
|
||||
? `Zip entry not found: ${entryName} in ${zipPath}`
|
||||
: `Zip has no file entries: ${zipPath}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
zipfile.on("entry", (current: yauzl.Entry) => {
|
||||
if (settled) return;
|
||||
const name = current.fileName.replace(/\\/g, "/");
|
||||
if (name.endsWith("/")) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
const base = entryBaseName(name);
|
||||
const isMatch = entryName ? name === entryName || base === entryName : true;
|
||||
if (!isMatch) {
|
||||
zipfile.readEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
zipfile.openReadStream(current, (streamError, readStream) => {
|
||||
if (streamError || !readStream) {
|
||||
fail(streamError ?? new Error(`Failed to read zip entry: ${current.fileName}`));
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
await mkdir(dirname(destPath), { recursive: true });
|
||||
await pipeline(readStream, createWriteStream(destPath));
|
||||
succeed(base);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
zipfile.readEntry();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { getSkillsDir } from "./lock.ts";
|
||||
|
||||
/**
|
||||
* Agent fan-out: after a skill lands in the canonical dir (~/.bailian/skills/<name>),
|
||||
* symlink it into each detected AI agent's global skills directory so that a single
|
||||
* install becomes visible across all agents.
|
||||
*
|
||||
* Detection semantics: if the agent's config dir exists → agent is installed → create link;
|
||||
* otherwise skip (never create ~/.xxx dirs that pollute home). When a new agent is installed
|
||||
* later, any subsequent `bl skill add/update` will fill in missing links (self-healing).
|
||||
*/
|
||||
export interface AgentTarget {
|
||||
id: string;
|
||||
displayName: string;
|
||||
/** Global directory where this agent reads skills from */
|
||||
skillsDir: string;
|
||||
/** 任一存在即判定"本机装了该 agent" */
|
||||
detectDirs: string[];
|
||||
}
|
||||
|
||||
/** Computed on each call (depends on homedir / XDG_CONFIG_HOME; easy to override in tests) */
|
||||
export function getAgentTargets(): AgentTarget[] {
|
||||
const home = homedir();
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, ".config");
|
||||
const simple = (id: string, displayName: string, dir: string): AgentTarget => ({
|
||||
id,
|
||||
displayName,
|
||||
skillsDir: join(home, dir, "skills"),
|
||||
detectDirs: [join(home, dir)],
|
||||
});
|
||||
return [
|
||||
// universal pseudo-agent: ~/.agents/skills is a shared dir read by multiple agents (Cline, etc.)
|
||||
{
|
||||
id: "universal",
|
||||
displayName: "Universal (~/.agents/skills)",
|
||||
skillsDir: join(home, ".agents", "skills"),
|
||||
detectDirs: [join(home, ".agents"), join(home, ".cline")],
|
||||
},
|
||||
simple("claude-code", "Claude Code", ".claude"),
|
||||
simple("openclaw", "OpenClaw", ".openclaw"),
|
||||
simple("hermes", "Hermes Agent", ".hermes"),
|
||||
{
|
||||
id: "opencode",
|
||||
displayName: "OpenCode",
|
||||
skillsDir: join(xdgConfig, "opencode", "skills"),
|
||||
detectDirs: [join(xdgConfig, "opencode")],
|
||||
},
|
||||
simple("cursor", "Cursor", ".cursor"),
|
||||
simple("codex", "Codex", ".codex"),
|
||||
simple("qwen-code", "Qwen Code", ".qwen"),
|
||||
simple("qoder", "Qoder", ".qoder"),
|
||||
simple("qoder-cn", "Qoder CN", ".qoder-cn"),
|
||||
simple("kilo", "Kilo Code", ".kilocode"),
|
||||
];
|
||||
}
|
||||
|
||||
export function detectInstalledAgents(): AgentTarget[] {
|
||||
return getAgentTargets().filter((agent) => agent.detectDirs.some((dir) => existsSync(dir)));
|
||||
}
|
||||
|
||||
/** Whether linkPath is managed by this tool: a symlink whose resolved target falls within the canonical skills dir */
|
||||
function isManagedLink(linkPath: string): boolean {
|
||||
try {
|
||||
if (!lstatSync(linkPath).isSymbolicLink()) return false;
|
||||
const target = readlinkSync(linkPath);
|
||||
const abs = isAbsolute(target) ? target : resolve(dirname(linkPath), target);
|
||||
return abs === getSkillsDir() || abs.startsWith(getSkillsDir() + sep);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinkResult {
|
||||
agent: string;
|
||||
path: string;
|
||||
mode: "symlink" | "copy" | "skipped";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan out a skill from canonical to each agent's skills dir.
|
||||
* Stale links created by this tool are rebuilt; existing files/dirs NOT managed by this tool
|
||||
* are always skipped (never delete user content). Falls back to copy when symlink fails
|
||||
* (e.g. Windows without Developer Mode).
|
||||
*/
|
||||
export function linkSkillToAgents(
|
||||
name: string,
|
||||
agents: AgentTarget[] = detectInstalledAgents(),
|
||||
): LinkResult[] {
|
||||
const target = join(getSkillsDir(), name);
|
||||
const results: LinkResult[] = [];
|
||||
for (const agent of agents) {
|
||||
const linkPath = join(agent.skillsDir, name);
|
||||
try {
|
||||
let existing = false;
|
||||
try {
|
||||
lstatSync(linkPath); // existsSync returns false for dangling symlinks; must use lstat
|
||||
existing = true;
|
||||
} catch {
|
||||
/* does not exist */
|
||||
}
|
||||
if (existing) {
|
||||
if (!isManagedLink(linkPath)) {
|
||||
results.push({
|
||||
agent: agent.id,
|
||||
path: linkPath,
|
||||
mode: "skipped",
|
||||
reason: "existing file/dir not managed by bl skill",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
rmSync(linkPath);
|
||||
}
|
||||
mkdirSync(agent.skillsDir, { recursive: true });
|
||||
try {
|
||||
symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
|
||||
results.push({ agent: agent.id, path: linkPath, mode: "symlink" });
|
||||
} catch {
|
||||
// No symlink permission (typical: Windows non-Developer Mode) → fall back to copy
|
||||
cpSync(target, linkPath, { recursive: true });
|
||||
results.push({ agent: agent.id, path: linkPath, mode: "copy" });
|
||||
}
|
||||
} catch (err) {
|
||||
results.push({
|
||||
agent: agent.id,
|
||||
path: linkPath,
|
||||
mode: "skipped",
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reclaim fan-out artifacts for a skill across all agent dirs.
|
||||
* Symlinks pointing to canonical are removed (including historical links not in lock,
|
||||
* via defensive scan of the full registry); real directories are only removed if recorded
|
||||
* in lock (copy-fallback artifacts). A single failure does not block the rest.
|
||||
*/
|
||||
export function unlinkSkillFromAgents(name: string, recordedLinks: string[] = []): string[] {
|
||||
const removed: string[] = [];
|
||||
const candidates = new Set(recordedLinks);
|
||||
for (const agent of getAgentTargets()) candidates.add(join(agent.skillsDir, name));
|
||||
for (const linkPath of candidates) {
|
||||
try {
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(linkPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
if (isManagedLink(linkPath)) {
|
||||
rmSync(linkPath);
|
||||
removed.push(linkPath);
|
||||
}
|
||||
} else if (recordedLinks.includes(linkPath)) {
|
||||
rmSync(linkPath, { recursive: true, force: true });
|
||||
removed.push(linkPath);
|
||||
}
|
||||
} catch {
|
||||
/* single failure does not block remaining cleanup */
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* tar.br archive extraction and atomic swap — shared by advisor wiki sync and `bl skill` install.
|
||||
* Symmetric with the publisher (FC skills-publish.mjs: tar.pack + brotli); uses only Node built-in
|
||||
* zlib + tar-stream, no extra decompression dependencies.
|
||||
*/
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createBrotliDecompress } from "node:zlib";
|
||||
import tar from "tar-stream";
|
||||
|
||||
/** tar 条目路径必须是相对路径且不含 ..,防止 tar-slip 逃逸解包目录 */
|
||||
export function isSafeEntryName(name: string): boolean {
|
||||
if (name.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(name)) return false;
|
||||
return !name.split("/").includes("..");
|
||||
}
|
||||
|
||||
/** Brotli decompress + tar-stream extract into destDir (per-entry path safety check). */
|
||||
export async function extractTarBr(tarBrBuffer: Buffer, destDir: string): Promise<void> {
|
||||
const extract = tar.extract();
|
||||
|
||||
extract.on("entry", (header, stream, next) => {
|
||||
if (!isSafeEntryName(header.name)) {
|
||||
// Use destroy so the pipeline rejects with this error; silence the entry stream
|
||||
// to avoid its companion error becoming an unhandled exception
|
||||
stream.on("error", () => {});
|
||||
stream.resume();
|
||||
extract.destroy(new Error(`unsafe tar entry: ${header.name}`));
|
||||
return;
|
||||
}
|
||||
const filePath = join(destDir, header.name);
|
||||
if (header.type === "directory") {
|
||||
mkdirSync(filePath, { recursive: true });
|
||||
stream.resume();
|
||||
stream.on("end", next);
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const ws = createWriteStream(filePath);
|
||||
stream.pipe(ws);
|
||||
ws.on("finish", next);
|
||||
ws.on("error", next);
|
||||
});
|
||||
|
||||
await pipeline(Readable.from(tarBrBuffer), createBrotliDecompress(), extract);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the publisher's deterministic content hash over an extracted directory:
|
||||
* regular files sorted by "/"-separated relative path (code-unit order, same as the
|
||||
* publisher's byte-order sort for ASCII paths), sha256 accumulating relPath + bytes.
|
||||
* Symmetric with computeContentHash in FC skills-publish.mjs.
|
||||
*/
|
||||
export function computeDirContentHash(dir: string): string {
|
||||
const relPaths: string[] = [];
|
||||
const walk = (sub: string): void => {
|
||||
for (const dirent of readdirSync(sub ? join(dir, sub) : dir, { withFileTypes: true })) {
|
||||
const rel = sub ? `${sub}/${dirent.name}` : dirent.name;
|
||||
if (dirent.isDirectory()) walk(rel);
|
||||
else if (dirent.isFile()) relPaths.push(rel);
|
||||
}
|
||||
};
|
||||
walk("");
|
||||
relPaths.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
||||
const hash = createHash("sha256");
|
||||
for (const rel of relPaths) {
|
||||
hash.update(rel);
|
||||
hash.update(readFileSync(join(dir, rel)));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic swap: replace destDir with the extracted content from tmpDir.
|
||||
* tmpDir must be on the same volume as destDir (same parent) for renameSync to be atomic.
|
||||
*/
|
||||
export function atomicSwap(tmpDir: string, destDir: string): void {
|
||||
mkdirSync(dirname(destDir), { recursive: true });
|
||||
const backup = `${destDir}.old-${Date.now()}`;
|
||||
if (existsSync(destDir)) renameSync(destDir, backup);
|
||||
try {
|
||||
renameSync(tmpDir, destDir);
|
||||
} catch (err) {
|
||||
// Swap failed → roll back the old directory to avoid leaving a hole
|
||||
if (existsSync(backup) && !existsSync(destDir)) renameSync(backup, destDir);
|
||||
throw err;
|
||||
}
|
||||
if (existsSync(backup)) rmSync(backup, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// bl skill management: OSS unified publishing protocol client + local install/fan-out/status reconciliation.
|
||||
export type {
|
||||
SkillIndexEntry,
|
||||
SkillsIndex,
|
||||
SkillLockEntry,
|
||||
SkillLockFile,
|
||||
SkillStatus,
|
||||
SkillStatusRow,
|
||||
} from "./types.ts";
|
||||
export {
|
||||
getSkillRegistryBaseUrl,
|
||||
fetchSkillsIndex,
|
||||
downloadSkillAsset,
|
||||
resolveAssetFileName,
|
||||
} from "./registry.ts";
|
||||
export {
|
||||
getSkillsDir,
|
||||
getSkillLockPath,
|
||||
emptySkillLock,
|
||||
readSkillLock,
|
||||
writeSkillLock,
|
||||
upsertSkillLockEntry,
|
||||
} from "./lock.ts";
|
||||
export { sanitizeSkillName, isSafeSkillName } from "./sanitize.ts";
|
||||
export { parseSkillNames } from "./names.ts";
|
||||
export { validateSkillDir, type SkillMeta } from "./validate.ts";
|
||||
export { extractTarBr, atomicSwap, isSafeEntryName, computeDirContentHash } from "./extract.ts";
|
||||
export {
|
||||
getAgentTargets,
|
||||
detectInstalledAgents,
|
||||
linkSkillToAgents,
|
||||
unlinkSkillFromAgents,
|
||||
type AgentTarget,
|
||||
type LinkResult,
|
||||
} from "./agents.ts";
|
||||
export {
|
||||
installSkill,
|
||||
installSkillFromBuffer,
|
||||
installSkillWithFanout,
|
||||
buildSkillLockEntry,
|
||||
removeSkillDir,
|
||||
type InstalledSkill,
|
||||
type SkillInstallRecord,
|
||||
} from "./installer.ts";
|
||||
export { listSkillDirsOnDisk, computeSkillStatuses } from "./status.ts";
|
||||
@@ -0,0 +1,133 @@
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import { detectInstalledAgents, linkSkillToAgents, type AgentTarget } from "./agents.ts";
|
||||
import { atomicSwap, computeDirContentHash, extractTarBr } from "./extract.ts";
|
||||
import { getSkillsDir } from "./lock.ts";
|
||||
import { downloadSkillAsset } from "./registry.ts";
|
||||
import { isSafeSkillName } from "./sanitize.ts";
|
||||
import { validateSkillDir, type SkillMeta } from "./validate.ts";
|
||||
import type { SkillIndexEntry, SkillLockEntry } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Skill installer: download → extract to tmpdir (with tar-slip check) → validate SKILL.md →
|
||||
* atomic swap into canonical. Canonical is only touched after all validations pass; on any failure
|
||||
* the current installation is preserved and temp artifacts are cleaned up in finally.
|
||||
*/
|
||||
export interface InstalledSkill {
|
||||
name: string;
|
||||
path: string;
|
||||
meta: SkillMeta;
|
||||
}
|
||||
|
||||
function assertSafeName(name: string): void {
|
||||
if (!isSafeSkillName(name)) {
|
||||
throw new BailianError(
|
||||
`Invalid skill name: ${name}`,
|
||||
ExitCode.GENERAL,
|
||||
"Skill name contains path separators, traversal sequences, or other illegal characters; refusing to write to disk",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Install from an in-memory tar.br archive (the download-and-onwards half of installSkill; test-friendly) */
|
||||
export async function installSkillFromBuffer(
|
||||
name: string,
|
||||
tarBrBuffer: Buffer,
|
||||
expectedContentHash?: string,
|
||||
): Promise<InstalledSkill> {
|
||||
assertSafeName(name);
|
||||
const skillsDir = getSkillsDir();
|
||||
const dest = join(skillsDir, name);
|
||||
// Same-volume temp dir: extract here then rename; cross-device rename would EXDEV
|
||||
const tmpDir = join(skillsDir, `.tmp-${name}-${process.pid}-${Date.now()}`);
|
||||
try {
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
await extractTarBr(tarBrBuffer, tmpDir);
|
||||
// Integrity check before touching canonical: recompute the publisher fingerprint over
|
||||
// the extracted files; on mismatch the current installation is left untouched
|
||||
if (expectedContentHash?.startsWith("sha256:")) {
|
||||
const actualContentHash = computeDirContentHash(tmpDir);
|
||||
if (actualContentHash !== expectedContentHash) {
|
||||
throw new BailianError(
|
||||
`Skill ${name} failed integrity check: index says ${expectedContentHash}, archive is ${actualContentHash}`,
|
||||
ExitCode.GENERAL,
|
||||
"Downloaded archive does not match the index fingerprint (registry may be mid-publish); retry later",
|
||||
);
|
||||
}
|
||||
}
|
||||
const meta = validateSkillDir(tmpDir, name);
|
||||
atomicSwap(tmpDir, dest);
|
||||
return { name, path: dest, meta };
|
||||
} finally {
|
||||
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Install a single skill by index entry (download + validate + write to disk) */
|
||||
export async function installSkill(name: string, entry: SkillIndexEntry): Promise<InstalledSkill> {
|
||||
if (entry.compression && entry.compression !== "tar.br") {
|
||||
throw new BailianError(
|
||||
`Skill ${name} uses unsupported compression format: ${entry.compression}`,
|
||||
ExitCode.GENERAL,
|
||||
"Upgrade bailian-cli to the latest version and retry",
|
||||
);
|
||||
}
|
||||
const buffer = await downloadSkillAsset(name, entry);
|
||||
return installSkillFromBuffer(name, buffer, entry.contentHash);
|
||||
}
|
||||
|
||||
/** Remove the skill directory under canonical; returns whether it was actually deleted (dir absent → false) */
|
||||
export function removeSkillDir(name: string): boolean {
|
||||
assertSafeName(name);
|
||||
const dest = join(getSkillsDir(), name);
|
||||
if (!existsSync(dest)) return false;
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skill-lock entry from an index entry + effective fan-out link paths.
|
||||
* Single source of truth for the "installation fact" shape shared by bl skill add/update,
|
||||
* advisor wiki sync, and any future install channel.
|
||||
*/
|
||||
export function buildSkillLockEntry(entry: SkillIndexEntry, links: string[]): SkillLockEntry {
|
||||
return {
|
||||
...(entry.contentHash ? { contentHash: entry.contentHash } : {}),
|
||||
...(entry.publishedAt ? { publishedAt: entry.publishedAt } : {}),
|
||||
installedAt: new Date().toISOString(),
|
||||
sourceType: "oss",
|
||||
...(entry.description ? { description: entry.description } : {}),
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillInstallRecord {
|
||||
/** Ready-to-persist lock entry (links = effective fan-out paths) */
|
||||
lockEntry: SkillLockEntry;
|
||||
/** Ids of agents that actually received a link/copy (skipped ones excluded) */
|
||||
linkedAgents: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full install workflow for one skill: install into canonical, fan out to agents, and build
|
||||
* the lock entry recording effective links. Callers decide how to persist the lock entry
|
||||
* (batch writeSkillLock for commands, best-effort upsertSkillLockEntry for silent channels).
|
||||
*/
|
||||
export async function installSkillWithFanout(
|
||||
name: string,
|
||||
entry: SkillIndexEntry,
|
||||
agents: AgentTarget[] = detectInstalledAgents(),
|
||||
): Promise<SkillInstallRecord> {
|
||||
await installSkill(name, entry);
|
||||
const links = linkSkillToAgents(name, agents);
|
||||
const effective = links.filter((link) => link.mode !== "skipped");
|
||||
return {
|
||||
lockEntry: buildSkillLockEntry(
|
||||
entry,
|
||||
effective.map((link) => link.path),
|
||||
),
|
||||
linkedAgents: effective.map((link) => link.agent),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getConfigDir } from "../config/paths.ts";
|
||||
import type { SkillLockEntry, SkillLockFile } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Local skill state: canonical directory + skill-lock.json.
|
||||
*
|
||||
* The lock only records "installation facts" (version, timestamp, fan-out links) and never
|
||||
* caches the remote index — list/update diffs are always "live remote index vs lock".
|
||||
* Paths follow the config.json directory logic (BAILIAN_CONFIG_DIR can redirect everything).
|
||||
*/
|
||||
export function getSkillsDir(): string {
|
||||
return join(getConfigDir(), "skills");
|
||||
}
|
||||
|
||||
export function getSkillLockPath(): string {
|
||||
return join(getSkillsDir(), "skill-lock.json");
|
||||
}
|
||||
|
||||
export function emptySkillLock(): SkillLockFile {
|
||||
return { version: 1, skills: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read installation records. Returns an empty lock when the file is absent (first install),
|
||||
* corrupted, or has an unrecognized version — an empty lock is a valid initial state, not an
|
||||
* error; subsequent install actions will rebuild correct records.
|
||||
*/
|
||||
export function readSkillLock(): SkillLockFile {
|
||||
const path = getSkillLockPath();
|
||||
if (!existsSync(path)) return emptySkillLock();
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf-8")) as SkillLockFile;
|
||||
if (parsed?.version !== 1 || typeof parsed.skills !== "object" || parsed.skills === null) {
|
||||
return emptySkillLock();
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return emptySkillLock();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSkillLock(lock: SkillLockFile): void {
|
||||
mkdirSync(getSkillsDir(), { recursive: true });
|
||||
writeFileSync(getSkillLockPath(), JSON.stringify(lock, null, 2) + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge-update a single skill's installation record (read-modify-write).
|
||||
* Shallow-merges with the existing entry: fields not provided in patch (typically links —
|
||||
* agent fan-out records) are preserved, preventing "install-only, no fan-out" sync channels
|
||||
* like postinstall/advisor from overwriting link records established by bl skill add.
|
||||
*/
|
||||
export function upsertSkillLockEntry(name: string, patch: SkillLockEntry): void {
|
||||
const lock = readSkillLock();
|
||||
lock.skills[name] = { ...lock.skills[name], ...patch };
|
||||
writeSkillLock(lock);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { UsageError } from "../errors/base.ts";
|
||||
|
||||
/**
|
||||
* Parse --name: `all` or a comma-separated list of skill names (deduplicated, trimmed).
|
||||
* `all` cannot be mixed with specific names.
|
||||
*/
|
||||
export function parseSkillNames(raw: string | undefined, defaultAll: boolean): string[] | "all" {
|
||||
const value = (raw ?? (defaultAll ? "all" : "")).trim();
|
||||
if (!value) {
|
||||
throw new UsageError("--name cannot be empty", "Use --name all or --name skill-a,skill-b");
|
||||
}
|
||||
const parts = [
|
||||
...new Set(
|
||||
value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (parts.includes("all")) {
|
||||
if (parts.length > 1) {
|
||||
throw new UsageError(
|
||||
"--name all cannot be mixed with specific skill names",
|
||||
"Use either all or a comma-separated list of names",
|
||||
);
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import type { SkillIndexEntry, SkillsIndex } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Skill registry client: public-read OSS, pure HTTPS GET, zero credentials (usable with auth: "none").
|
||||
* Defaults to the skills/ prefix of the bailian-wiki bucket; override with BAILIAN_SKILL_REGISTRY_URL
|
||||
* for canary/private mirror scenarios.
|
||||
*/
|
||||
const DEFAULT_REGISTRY_BASE_URL = "https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/skills";
|
||||
|
||||
const INDEX_TIMEOUT_MS = 10_000;
|
||||
const ASSET_TIMEOUT_MS = 120_000;
|
||||
|
||||
export function getSkillRegistryBaseUrl(): string {
|
||||
const override = process.env.BAILIAN_SKILL_REGISTRY_URL?.trim();
|
||||
return (override || DEFAULT_REGISTRY_BASE_URL).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the remote skill index. No local caching — the diff comparison is always
|
||||
* "live remote index vs local skill-lock.json".
|
||||
* Silent background channels (advisor sync) may pass a tighter timeout than the interactive default.
|
||||
*/
|
||||
export async function fetchSkillsIndex(timeoutMs: number = INDEX_TIMEOUT_MS): Promise<SkillsIndex> {
|
||||
const url = `${getSkillRegistryBaseUrl()}/index.json`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
} catch (err) {
|
||||
throw new BailianError(
|
||||
`Cannot access skill registry: ${url}`,
|
||||
ExitCode.NETWORK,
|
||||
"Check network connectivity; if using a private mirror, verify BAILIAN_SKILL_REGISTRY_URL configuration",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new BailianError(
|
||||
`Skill registry returned HTTP ${res.status}: ${url}`,
|
||||
ExitCode.NETWORK,
|
||||
res.status === 404
|
||||
? "Skill index not yet published or registry URL is incorrect; confirm the publisher has generated index.json"
|
||||
: "Remote error, retry later",
|
||||
);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await res.json();
|
||||
} catch (err) {
|
||||
throw new BailianError(
|
||||
"Skill index index.json is not valid JSON",
|
||||
ExitCode.GENERAL,
|
||||
"Remote may be in the middle of publishing, retry later",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
const index = parsed as SkillsIndex;
|
||||
if (
|
||||
typeof index !== "object" ||
|
||||
index === null ||
|
||||
typeof index.skills !== "object" ||
|
||||
index.skills === null
|
||||
) {
|
||||
throw new BailianError(
|
||||
"Skill index index.json has invalid structure",
|
||||
ExitCode.GENERAL,
|
||||
"Retry later or contact the publisher",
|
||||
);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict shape check for entry.object (defense against a hostile/corrupted index —
|
||||
* anything not matching falls back to the legacy fixed key, never into the URL path).
|
||||
*/
|
||||
const OBJECT_FILE_RE = /^sha256-[0-9a-f]{64}\.tar\.br$/;
|
||||
|
||||
/** Resolve which file to download for a skill: content-addressed object, else legacy fixed key */
|
||||
export function resolveAssetFileName(entry?: SkillIndexEntry): string {
|
||||
const object = entry?.object;
|
||||
return object && OBJECT_FILE_RE.test(object) ? object : "skill.tar.br";
|
||||
}
|
||||
|
||||
/** Download the tar.br archive for a single skill (one skill = one GET) */
|
||||
export async function downloadSkillAsset(name: string, entry?: SkillIndexEntry): Promise<Buffer> {
|
||||
const url = `${getSkillRegistryBaseUrl()}/${name}/${resolveAssetFileName(entry)}`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { signal: AbortSignal.timeout(ASSET_TIMEOUT_MS) });
|
||||
} catch (err) {
|
||||
throw new BailianError(
|
||||
`Failed to download skill ${name}: ${url}`,
|
||||
ExitCode.NETWORK,
|
||||
"Network error, retryable",
|
||||
{
|
||||
cause: err,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new BailianError(
|
||||
`Failed to download skill ${name}: HTTP ${res.status}`,
|
||||
ExitCode.NETWORK,
|
||||
res.status === 404
|
||||
? "index.json and skill object are temporarily inconsistent (publishing in progress), retry later"
|
||||
: "Remote error, retry later",
|
||||
);
|
||||
}
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Sanitize a skill name into a safe directory name (semantics aligned with vercel-labs/skills sanitizeName):
|
||||
* skill names come from the remote index (untrusted input) and are interpolated into file paths, so they
|
||||
* must be disinfected first — path separators/drive letters/whitespace/Windows-illegal chars are collapsed
|
||||
* to hyphens, `..` is destroyed, leading/trailing `.-` are stripped.
|
||||
*
|
||||
* `bl skill` uses this as an "equivalence check": if the sanitized name differs from the original,
|
||||
* installation is rejected outright (the publisher already has an isomorphic allowlist; this is client-side defense-in-depth).
|
||||
*/
|
||||
export function sanitizeSkillName(name: string): string {
|
||||
const sanitized = name
|
||||
.replace(/[\\/:*?"<>|\s]+/g, "-")
|
||||
.replace(/\.\.+/g, "-")
|
||||
.replace(/^[-.]+|[-.]+$/g, "");
|
||||
return sanitized || "unnamed-skill";
|
||||
}
|
||||
|
||||
/** Whether the skill name is already a safe directory name (unchanged after sanitization) */
|
||||
export function isSafeSkillName(name: string): boolean {
|
||||
return name.length > 0 && sanitizeSkillName(name) === name;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getSkillsDir } from "./lock.ts";
|
||||
import type { SkillLockFile, SkillStatusRow, SkillsIndex } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Three-way reconciliation for list: remote index (live) × skill-lock.json (installation facts) × disk (ground truth).
|
||||
*/
|
||||
|
||||
/** Scan skill directories under canonical (skipping hidden entries, tmp/backup remnants, and plain files) */
|
||||
export function listSkillDirsOnDisk(): string[] {
|
||||
const dir = getSkillsDir();
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).filter((entry) => {
|
||||
if (entry.startsWith(".")) return false;
|
||||
if (entry.includes(".tmp-") || entry.includes(".old-")) return false;
|
||||
try {
|
||||
return statSync(join(dir, entry)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function computeSkillStatuses(
|
||||
index: SkillsIndex,
|
||||
lock: SkillLockFile,
|
||||
diskNames: string[],
|
||||
): SkillStatusRow[] {
|
||||
const disk = new Set(diskNames);
|
||||
const seen = new Set<string>();
|
||||
const rows: SkillStatusRow[] = [];
|
||||
|
||||
// Skills present in remote: derive status from lock/disk
|
||||
for (const [name, entry] of Object.entries(index.skills)) {
|
||||
seen.add(name);
|
||||
const locked = lock.skills[name];
|
||||
if (locked) {
|
||||
const status = !disk.has(name)
|
||||
? "missing" // was installed but dir was deleted; reinstall can fix
|
||||
: locked.contentHash !== entry.contentHash
|
||||
? "outdated"
|
||||
: "installed";
|
||||
rows.push({
|
||||
name,
|
||||
status,
|
||||
publishedAt: entry.publishedAt,
|
||||
description: entry.description,
|
||||
});
|
||||
} else if (disk.has(name)) {
|
||||
// Dir exists but no install record (manually placed, or synced by postinstall/advisor or other channels)
|
||||
rows.push({
|
||||
name,
|
||||
status: "untracked",
|
||||
publishedAt: entry.publishedAt,
|
||||
description: entry.description,
|
||||
});
|
||||
} else {
|
||||
rows.push({
|
||||
name,
|
||||
status: "not-installed",
|
||||
publishedAt: entry.publishedAt,
|
||||
description: entry.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// In lock but delisted from remote: still usable locally (installed) or dir also gone (missing)
|
||||
for (const [name, locked] of Object.entries(lock.skills)) {
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
rows.push({
|
||||
name,
|
||||
status: disk.has(name) ? "installed" : "missing",
|
||||
publishedAt: locked.publishedAt,
|
||||
description: locked.description,
|
||||
});
|
||||
}
|
||||
|
||||
// On disk but in neither lock nor remote → untracked
|
||||
for (const name of diskNames) {
|
||||
if (!seen.has(name)) rows.push({ name, status: "untracked" });
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Data structures for the unified skill publishing protocol (symmetric with FC publisher skills-publish.mjs).
|
||||
*
|
||||
* Remote layout (public-read OSS, the sole data source for `bl skill`):
|
||||
* <registry>/index.json — skill catalog (SkillsIndex)
|
||||
* <registry>/<name>/sha256-<hex>.tar.br — content-addressed skill object (tar + brotli);
|
||||
* entry.object names the exact file, so index.json is the single atomic commit point.
|
||||
* Legacy fallback: <registry>/<name>/skill.tar.br (entries without object)
|
||||
*
|
||||
* Local layout:
|
||||
* ~/.bailian/skills/<name>/ — canonical install directory
|
||||
* ~/.bailian/skills/skill-lock.json — installation fact records (SkillLockFile)
|
||||
*/
|
||||
|
||||
/** A single skill entry in index.json */
|
||||
export interface SkillIndexEntry {
|
||||
/** Reserved for the skill's own semantic version (x.y.z); not yet populated by the publisher */
|
||||
version?: string;
|
||||
/** Beijing-time publish timestamp; refreshed whenever content changes — the human-facing release marker */
|
||||
publishedAt?: string;
|
||||
/** Extracted by the publisher from README.md first paragraph or SKILL.md frontmatter */
|
||||
description?: string;
|
||||
/** Deterministic content fingerprint; the CLI uses this as the change-detection token (install/outdated) */
|
||||
contentHash?: string;
|
||||
/** Compression format identifier, currently always "tar.br" */
|
||||
compression?: string;
|
||||
/**
|
||||
* Content-addressed object file name under <registry>/<name>/, e.g. "sha256-<hex>.tar.br".
|
||||
* Absent on legacy entries — client falls back to the fixed key "skill.tar.br".
|
||||
*/
|
||||
object?: string;
|
||||
}
|
||||
|
||||
export interface SkillsIndex {
|
||||
updatedAt?: string;
|
||||
/** key = skill name (i.e. OSS directory name, download path, local install dir name) */
|
||||
skills: Record<string, SkillIndexEntry>;
|
||||
}
|
||||
|
||||
/** Installation facts for a single skill in skill-lock.json */
|
||||
export interface SkillLockEntry {
|
||||
/** Content fingerprint at install time; compared against the remote index to detect updates */
|
||||
contentHash?: string;
|
||||
/** Publish timestamp of the installed revision (for display) */
|
||||
publishedAt?: string;
|
||||
installedAt: string;
|
||||
/** Reserved: future support for github/gitlab and other sources */
|
||||
sourceType: "oss";
|
||||
description?: string;
|
||||
/** Fan-out link/copy paths to each agent; used for precise reclamation on remove */
|
||||
links?: string[];
|
||||
}
|
||||
|
||||
export interface SkillLockFile {
|
||||
version: 1;
|
||||
skills: Record<string, SkillLockEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill statuses for list:
|
||||
* installed — lock record exists, dir on disk, content fingerprint matches remote
|
||||
* outdated — lock record exists, dir on disk, remote content fingerprint differs
|
||||
* not-installed — present in remote, absent locally
|
||||
* missing — lock record exists but dir was deleted (reinstall can fix)
|
||||
* untracked — dir on disk but no install record (manually placed or synced by other channels)
|
||||
*/
|
||||
export type SkillStatus = "installed" | "outdated" | "not-installed" | "missing" | "untracked";
|
||||
|
||||
export interface SkillStatusRow {
|
||||
name: string;
|
||||
status: SkillStatus;
|
||||
/** Publish timestamp of the remote revision (or local, for delisted skills) */
|
||||
publishedAt?: string;
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
|
||||
/**
|
||||
* Skill validity check (aligned with vercel-labs/skills parseSkillMd semantics):
|
||||
* 1. SKILL.md exists as a regular file at the directory root
|
||||
* 2. frontmatter is valid YAML delimited by `---`
|
||||
* 3. name / description fields exist and are non-empty strings
|
||||
*
|
||||
* Validation happens in the temp dir before writing to canonical — any failure rolls back the entire install.
|
||||
*/
|
||||
export interface SkillMeta {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function fail(skillName: string, reason: string): never {
|
||||
throw new BailianError(
|
||||
`Skill ${skillName} validation failed: ${reason}`,
|
||||
ExitCode.GENERAL,
|
||||
"This skill package does not conform to the SKILL.md spec; contact the skill publisher to fix and republish",
|
||||
);
|
||||
}
|
||||
|
||||
function extractFrontmatter(content: string): string | null {
|
||||
if (!content.startsWith("---")) return null;
|
||||
const match = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/.exec(content);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export function validateSkillDir(dir: string, skillName: string): SkillMeta {
|
||||
const skillMdPath = join(dir, "SKILL.md");
|
||||
let raw: string;
|
||||
try {
|
||||
if (!statSync(skillMdPath).isFile()) fail(skillName, "SKILL.md is not a regular file");
|
||||
raw = readFileSync(skillMdPath, "utf-8");
|
||||
} catch (err) {
|
||||
if (err instanceof BailianError) throw err;
|
||||
fail(skillName, "missing SKILL.md");
|
||||
}
|
||||
|
||||
const frontmatter = extractFrontmatter(raw);
|
||||
if (frontmatter === null)
|
||||
fail(skillName, "SKILL.md is missing frontmatter (--- delimited YAML header)");
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = parse(frontmatter);
|
||||
} catch {
|
||||
fail(skillName, "frontmatter is not valid YAML");
|
||||
}
|
||||
if (typeof data !== "object" || data === null) {
|
||||
fail(skillName, "frontmatter is not a key-value structure");
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>;
|
||||
const name = typeof record.name === "string" ? record.name.trim() : "";
|
||||
const description = typeof record.description === "string" ? record.description.trim() : "";
|
||||
if (!name || !description)
|
||||
fail(skillName, "frontmatter is missing non-empty name / description fields");
|
||||
|
||||
return { name, description };
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* 判断当前运行环境。任一条件为真即视为 dev,默认 prod。
|
||||
*
|
||||
* 1. NODE_ENV=development — Node 圈通用约定,测试同学/CI 可显式声明
|
||||
* 2. 当前模块文件路径不在 node_modules 里 — 自动识别从源码运行(pnpm dev /
|
||||
* 2. Bun 编译二进制(BAILIAN_COMPILED=1)— 一律 prod
|
||||
* 3. 当前模块文件路径不在 node_modules 里 — 自动识别从源码运行(pnpm dev /
|
||||
* npm link / 直接 pnpm -F bailian-cli exec tsx src/main.ts),避免开发者忘记设环境变量
|
||||
* 时仍把数据打到 prod
|
||||
*
|
||||
@@ -16,6 +17,10 @@ export function detectEnv(): "dev" | "prod" {
|
||||
cachedEnv = "dev";
|
||||
return cachedEnv;
|
||||
}
|
||||
if (process.env.BAILIAN_COMPILED === "1") {
|
||||
cachedEnv = "prod";
|
||||
return cachedEnv;
|
||||
}
|
||||
cachedEnv = import.meta.url.includes("/node_modules/") ? "prod" : "dev";
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Run async task factories with a bounded concurrency pool.
|
||||
* Returns results in the same order as the input tasks array.
|
||||
*/
|
||||
export async function runWithConcurrency<T>(
|
||||
tasks: Array<() => Promise<T>>,
|
||||
limit: number,
|
||||
): Promise<T[]> {
|
||||
const results: T[] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { resolveOutputDir } from "./output-dir.ts";
|
||||
export { maskToken } from "./token.ts";
|
||||
export { stripUndefined } from "./object.ts";
|
||||
export { readTextFromPathOrStdin } from "./fs.ts";
|
||||
export { runWithConcurrency } from "./concurrency.ts";
|
||||
export {
|
||||
parseBooleanValue,
|
||||
parseOptionalBooleanValue,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
BINARY_PRODUCT_CLIENT_NAME,
|
||||
detectInstallMethod,
|
||||
getInstallMethod,
|
||||
getUpdateInstallMethod,
|
||||
isCompiledBinary,
|
||||
binaryAssetFileName,
|
||||
binaryInnerFileName,
|
||||
writeInstallMethodSync,
|
||||
} from "../src/install/index.ts";
|
||||
|
||||
test("isCompiledBinary respects BAILIAN_COMPILED", () => {
|
||||
const previous = process.env.BAILIAN_COMPILED;
|
||||
process.env.BAILIAN_COMPILED = "1";
|
||||
expect(isCompiledBinary()).toBe(true);
|
||||
if (previous === undefined) delete process.env.BAILIAN_COMPILED;
|
||||
else process.env.BAILIAN_COMPILED = previous;
|
||||
});
|
||||
|
||||
test("detectInstallMethod respects BAILIAN_INSTALL_METHOD", () => {
|
||||
const previous = process.env.BAILIAN_INSTALL_METHOD;
|
||||
process.env.BAILIAN_INSTALL_METHOD = "binary";
|
||||
expect(detectInstallMethod()).toBe("binary");
|
||||
process.env.BAILIAN_INSTALL_METHOD = "npm";
|
||||
expect(detectInstallMethod()).toBe("npm");
|
||||
if (previous === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = previous;
|
||||
});
|
||||
|
||||
test("getInstallMethod isolates products from shared legacy binary marker", () => {
|
||||
const savedConfigDir = process.env.BAILIAN_CONFIG_DIR;
|
||||
const savedInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-install-method-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
|
||||
try {
|
||||
writeFileSync(join(dir, "install-method"), "binary\n", { mode: 0o600 });
|
||||
|
||||
expect(getInstallMethod({ clientName: BINARY_PRODUCT_CLIENT_NAME })).toBe("binary");
|
||||
expect(getInstallMethod({ clientName: "knowledge-studio-cli" })).toBe("npm");
|
||||
expect(
|
||||
getUpdateInstallMethod({
|
||||
clientName: "knowledge-studio-cli",
|
||||
npmPackage: "knowledge-studio-cli",
|
||||
}),
|
||||
).toBe("npm");
|
||||
} finally {
|
||||
if (savedConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = savedConfigDir;
|
||||
if (savedInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = savedInstallMethod;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("writeInstallMethodSync writes product marker and legacy for bailian-cli", () => {
|
||||
const savedConfigDir = process.env.BAILIAN_CONFIG_DIR;
|
||||
const savedInstallMethod = process.env.BAILIAN_INSTALL_METHOD;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-install-method-write-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
|
||||
try {
|
||||
writeInstallMethodSync("binary", { clientName: BINARY_PRODUCT_CLIENT_NAME });
|
||||
expect(getInstallMethod({ clientName: BINARY_PRODUCT_CLIENT_NAME })).toBe("binary");
|
||||
expect(getInstallMethod()).toBe("binary");
|
||||
expect(getInstallMethod({ clientName: "knowledge-studio-cli" })).toBe("npm");
|
||||
} finally {
|
||||
if (savedConfigDir === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = savedConfigDir;
|
||||
if (savedInstallMethod === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = savedInstallMethod;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("getUpdateInstallMethod forces npm for non-bailian products even with binary env", () => {
|
||||
const previous = process.env.BAILIAN_INSTALL_METHOD;
|
||||
process.env.BAILIAN_INSTALL_METHOD = "binary";
|
||||
expect(
|
||||
getUpdateInstallMethod({
|
||||
clientName: "knowledge-studio-cli",
|
||||
npmPackage: "knowledge-studio-cli",
|
||||
}),
|
||||
).toBe("npm");
|
||||
expect(
|
||||
getUpdateInstallMethod({
|
||||
clientName: BINARY_PRODUCT_CLIENT_NAME,
|
||||
npmPackage: BINARY_PRODUCT_CLIENT_NAME,
|
||||
}),
|
||||
).toBe("binary");
|
||||
if (previous === undefined) delete process.env.BAILIAN_INSTALL_METHOD;
|
||||
else process.env.BAILIAN_INSTALL_METHOD = previous;
|
||||
});
|
||||
|
||||
test("binaryAssetFileName uses per-platform zip", () => {
|
||||
expect(binaryAssetFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.zip");
|
||||
expect(binaryAssetFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64.zip");
|
||||
});
|
||||
|
||||
test("binaryInnerFileName keeps exe suffix inside zip", () => {
|
||||
expect(binaryInnerFileName("1.2.3", "windows", "x64", true)).toBe("bl-1.2.3-windows-x64.exe");
|
||||
expect(binaryInnerFileName("1.2.3", "darwin", "arm64", false)).toBe("bl-1.2.3-darwin-arm64");
|
||||
});
|
||||
|
||||
test("channelManifestUrl maps stable to manifest.json", async () => {
|
||||
const { channelManifestUrl } = await import("../src/install/cdn.ts");
|
||||
const previous = process.env.BAILIAN_CLI_CDN;
|
||||
delete process.env.BAILIAN_CLI_CDN;
|
||||
expect(channelManifestUrl()).toBe(
|
||||
"https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/manifest.json",
|
||||
);
|
||||
expect(channelManifestUrl("latest")).toBe(
|
||||
"https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/manifest.json",
|
||||
);
|
||||
expect(channelManifestUrl("sync-release")).toBe(
|
||||
"https://bailian-wiki.oss-cn-hangzhou.aliyuncs.com/release/sync-release.json",
|
||||
);
|
||||
if (previous === undefined) delete process.env.BAILIAN_CLI_CDN;
|
||||
else process.env.BAILIAN_CLI_CDN = previous;
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
detectInstalledAgents,
|
||||
getAgentTargets,
|
||||
linkSkillToAgents,
|
||||
unlinkSkillFromAgents,
|
||||
} from "../src/skills/agents.ts";
|
||||
import { getSkillsDir } from "../src/skills/lock.ts";
|
||||
|
||||
/**
|
||||
* Isolated environment: HOME/XDG_CONFIG_HOME/BAILIAN_CONFIG_DIR all point to a temp dir,
|
||||
* so agent detection and the canonical dir never touch the real home.
|
||||
*/
|
||||
async function inFakeHome(fn: (home: string) => Promise<void>): Promise<void> {
|
||||
const saved = {
|
||||
HOME: process.env.HOME,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
BAILIAN_CONFIG_DIR: process.env.BAILIAN_CONFIG_DIR,
|
||||
};
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-skill-agents-"));
|
||||
process.env.HOME = home;
|
||||
process.env.XDG_CONFIG_HOME = join(home, ".config");
|
||||
process.env.BAILIAN_CONFIG_DIR = join(home, ".bailian");
|
||||
try {
|
||||
await fn(home);
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an installed skill in canonical */
|
||||
function seedCanonicalSkill(name: string): string {
|
||||
const dir = join(getSkillsDir(), name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "SKILL.md"), "---\nname: x\ndescription: y\n---\n");
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("agents: registry has universal + 11 agents, only detects those whose config dir exists", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
expect(getAgentTargets().map((a) => a.id)).toContain("universal");
|
||||
expect(getAgentTargets()).toHaveLength(11);
|
||||
expect(detectInstalledAgents()).toEqual([]);
|
||||
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
mkdirSync(join(home, ".qoder"), { recursive: true });
|
||||
expect(detectInstalledAgents().map((a) => a.id)).toEqual(["claude-code", "qoder"]);
|
||||
|
||||
// Cline config dir exists → hits the universal pseudo-agent
|
||||
mkdirSync(join(home, ".cline"), { recursive: true });
|
||||
expect(detectInstalledAgents().map((a) => a.id)).toEqual(["universal", "claude-code", "qoder"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: fan-out creates symlink to canonical; does not create dirs for uninstalled agents", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
const target = seedCanonicalSkill("demo");
|
||||
|
||||
const results = linkSkillToAgents("demo");
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({ agent: "claude-code", mode: "symlink" });
|
||||
|
||||
const linkPath = join(home, ".claude", "skills", "demo");
|
||||
expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(linkPath)).toBe(target);
|
||||
// Real content is readable through the link
|
||||
expect(readFileSync(join(linkPath, "SKILL.md"), "utf-8")).toContain("name: x");
|
||||
// Uninstalled agent dir was not created out of thin air
|
||||
expect(existsSync(join(home, ".cursor"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: existing unmanaged dir is skipped; managed stale link is rebuilt", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
seedCanonicalSkill("demo");
|
||||
|
||||
// Real dir placed by the user → skipped, not cleared
|
||||
const foreign = join(home, ".claude", "skills", "demo");
|
||||
mkdirSync(foreign, { recursive: true });
|
||||
writeFileSync(join(foreign, "user.txt"), "mine");
|
||||
const first = linkSkillToAgents("demo");
|
||||
expect(first[0].mode).toBe("skipped");
|
||||
expect(readFileSync(join(foreign, "user.txt"), "utf-8")).toBe("mine");
|
||||
|
||||
// Replace with our own stale link → rebuilt successfully
|
||||
rmSync(foreign, { recursive: true, force: true });
|
||||
const again = linkSkillToAgents("demo");
|
||||
expect(again[0].mode).toBe("symlink");
|
||||
const rebuilt = linkSkillToAgents("demo");
|
||||
expect(rebuilt[0].mode).toBe("symlink");
|
||||
});
|
||||
});
|
||||
|
||||
test("agents: unlink reclaims managed links, leaves foreign content untouched", async () => {
|
||||
await inFakeHome(async (home) => {
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
mkdirSync(join(home, ".agents"), { recursive: true });
|
||||
seedCanonicalSkill("demo");
|
||||
const links = linkSkillToAgents("demo");
|
||||
expect(links.filter((l) => l.mode === "symlink")).toHaveLength(2);
|
||||
|
||||
// Foreign file with the same name placed in cursor (should be unaffected even if not detected)
|
||||
const removed = unlinkSkillFromAgents(
|
||||
"demo",
|
||||
links.map((l) => l.path),
|
||||
);
|
||||
expect(removed.sort()).toEqual(links.map((l) => l.path).sort());
|
||||
expect(existsSync(join(home, ".claude", "skills", "demo"))).toBe(false);
|
||||
expect(existsSync(join(home, ".agents", "skills", "demo"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
|
||||
import { createHash } from "crypto";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { brotliCompressSync } from "zlib";
|
||||
import tar from "tar-stream";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { BailianError } from "../src/errors/base.ts";
|
||||
import { installSkillFromBuffer } from "../src/skills/installer.ts";
|
||||
import { getSkillsDir } from "../src/skills/lock.ts";
|
||||
|
||||
/** Run in an isolated temp config dir, restore env afterwards. */
|
||||
async function inTempConfigDir(fn: () => Promise<void>): Promise<void> {
|
||||
const saved = process.env.BAILIAN_CONFIG_DIR;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-skill-install-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = saved;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a skill archive the same way as the publisher (tar.pack + brotli) */
|
||||
async function buildTarBr(files: Record<string, string>): Promise<Buffer> {
|
||||
const pack = tar.pack();
|
||||
const chunks: Buffer[] = [];
|
||||
pack.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
const done = new Promise<void>((resolvePromise, reject) => {
|
||||
pack.on("end", resolvePromise);
|
||||
pack.on("error", reject);
|
||||
});
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
pack.entry({ name: rel }, content);
|
||||
}
|
||||
pack.finalize();
|
||||
await done;
|
||||
return brotliCompressSync(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
const VALID_SKILL_MD = "---\nname: demo\ndescription: demo skill\n---\n\n# Demo v1\n";
|
||||
|
||||
test("installer: valid archive installs to canonical and returns metadata", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const buf = await buildTarBr({
|
||||
"SKILL.md": VALID_SKILL_MD,
|
||||
"references/usage.md": "# usage\n",
|
||||
});
|
||||
const installed = await installSkillFromBuffer("demo", buf);
|
||||
expect(installed).toMatchObject({
|
||||
name: "demo",
|
||||
meta: { name: "demo", description: "demo skill" },
|
||||
});
|
||||
expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD);
|
||||
expect(existsSync(join(getSkillsDir(), "demo", "references", "usage.md"))).toBe(true);
|
||||
// No temp/backup dirs left behind
|
||||
expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: reinstall atomically swaps, no old files left behind", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await installSkillFromBuffer(
|
||||
"demo",
|
||||
await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "old-only.md": "v1\n" }),
|
||||
);
|
||||
const v2 = "---\nname: demo\ndescription: demo skill v2\n---\n";
|
||||
await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": v2 }));
|
||||
expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(v2);
|
||||
expect(existsSync(join(getSkillsDir(), "demo", "old-only.md"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: tar-slip entry → rejected and canonical not written", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const buf = await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "../evil.txt": "pwned\n" });
|
||||
await expect(installSkillFromBuffer("demo", buf)).rejects.toThrow(/unsafe tar entry/);
|
||||
expect(existsSync(join(getSkillsDir(), "demo"))).toBe(false);
|
||||
expect(existsSync(join(process.env.BAILIAN_CONFIG_DIR!, "evil.txt"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: SKILL.md validation fails → previously installed version preserved as-is", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD }));
|
||||
const bad = await buildTarBr({ "README.md": "no skill md\n" });
|
||||
await expect(installSkillFromBuffer("demo", bad)).rejects.toThrow(BailianError);
|
||||
// Old version untouched, temp dir cleaned up
|
||||
expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD);
|
||||
expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: invalid skill name rejected outright", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const buf = await buildTarBr({ "SKILL.md": VALID_SKILL_MD });
|
||||
await expect(installSkillFromBuffer("../escape", buf)).rejects.toThrow(/Invalid skill name/);
|
||||
});
|
||||
});
|
||||
|
||||
/** Same accumulation as publisher computeContentHash: sorted rel path + bytes */
|
||||
function expectedHashOf(files: Record<string, string>): string {
|
||||
const hash = createHash("sha256");
|
||||
for (const rel of Object.keys(files).sort()) {
|
||||
hash.update(rel);
|
||||
hash.update(Buffer.from(files[rel]));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
test("installer: matching contentHash passes integrity check", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const files = { "SKILL.md": VALID_SKILL_MD, "references/usage.md": "# usage\n" };
|
||||
const installed = await installSkillFromBuffer(
|
||||
"demo",
|
||||
await buildTarBr(files),
|
||||
expectedHashOf(files),
|
||||
);
|
||||
expect(installed.name).toBe("demo");
|
||||
expect(existsSync(join(getSkillsDir(), "demo", "SKILL.md"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("installer: contentHash mismatch → rejected, previous install preserved", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await installSkillFromBuffer("demo", await buildTarBr({ "SKILL.md": VALID_SKILL_MD }));
|
||||
const tampered = await buildTarBr({ "SKILL.md": VALID_SKILL_MD, "extra.md": "tampered\n" });
|
||||
await expect(
|
||||
installSkillFromBuffer("demo", tampered, expectedHashOf({ "SKILL.md": VALID_SKILL_MD })),
|
||||
).rejects.toThrow(/integrity check/);
|
||||
// Old version untouched, temp dir cleaned up
|
||||
expect(readFileSync(join(getSkillsDir(), "demo", "SKILL.md"), "utf-8")).toBe(VALID_SKILL_MD);
|
||||
expect(existsSync(join(getSkillsDir(), "demo", "extra.md"))).toBe(false);
|
||||
expect(readdirSync(getSkillsDir()).filter((e) => e !== "demo")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
emptySkillLock,
|
||||
getSkillLockPath,
|
||||
getSkillsDir,
|
||||
readSkillLock,
|
||||
upsertSkillLockEntry,
|
||||
writeSkillLock,
|
||||
} from "../src/skills/lock.ts";
|
||||
|
||||
/** Run in an isolated temp config dir, restore env afterwards. */
|
||||
async function inTempConfigDir(fn: () => Promise<void>): Promise<void> {
|
||||
const saved = process.env.BAILIAN_CONFIG_DIR;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-skill-lock-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = saved;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("skill-lock: paths follow BAILIAN_CONFIG_DIR", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
expect(getSkillsDir()).toBe(join(process.env.BAILIAN_CONFIG_DIR!, "skills"));
|
||||
expect(getSkillLockPath()).toBe(join(getSkillsDir(), "skill-lock.json"));
|
||||
});
|
||||
});
|
||||
|
||||
test("skill-lock: first install (file absent) returns empty table, not an error", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
expect(readSkillLock()).toEqual(emptySkillLock());
|
||||
});
|
||||
});
|
||||
|
||||
test("skill-lock: written data reads back with links/sourceType", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const lock = emptySkillLock();
|
||||
lock.skills["spark-video"] = {
|
||||
contentHash: "sha256:abc",
|
||||
publishedAt: "2026-07-23T00:00:00+08:00",
|
||||
installedAt: "2026-07-23T00:00:00Z",
|
||||
sourceType: "oss",
|
||||
links: ["/tmp/x/.claude/skills/spark-video"],
|
||||
};
|
||||
writeSkillLock(lock);
|
||||
expect(readSkillLock()).toEqual(lock);
|
||||
});
|
||||
});
|
||||
|
||||
test("skill-lock: corrupted JSON / unrecognized version → treated as empty table", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
mkdirSync(getSkillsDir(), { recursive: true });
|
||||
writeFileSync(getSkillLockPath(), "{ not json");
|
||||
expect(readSkillLock()).toEqual(emptySkillLock());
|
||||
|
||||
writeFileSync(getSkillLockPath(), JSON.stringify({ version: 99, skills: {} }));
|
||||
expect(readSkillLock()).toEqual(emptySkillLock());
|
||||
|
||||
writeFileSync(getSkillLockPath(), JSON.stringify({ version: 1 }));
|
||||
expect(readSkillLock()).toEqual(emptySkillLock());
|
||||
});
|
||||
});
|
||||
|
||||
test("skill-lock: upsert shallow-merge — silent sync channel does not overwrite links written by add", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
// upsert on empty table = create entry (postinstall first-time bookkeeping scenario)
|
||||
upsertSkillLockEntry("bailian-docs-llm-wiki", {
|
||||
contentHash: "sha256:v1",
|
||||
installedAt: "2026-07-23T00:00:00Z",
|
||||
sourceType: "oss",
|
||||
});
|
||||
expect(readSkillLock().skills["bailian-docs-llm-wiki"].contentHash).toBe("sha256:v1");
|
||||
|
||||
// After bl skill add adds links, advisor sync only updates the fingerprint → links preserved
|
||||
upsertSkillLockEntry("bailian-docs-llm-wiki", {
|
||||
contentHash: "sha256:v1",
|
||||
installedAt: "2026-07-23T01:00:00Z",
|
||||
sourceType: "oss",
|
||||
links: ["/tmp/x/.claude/skills/bailian-docs-llm-wiki"],
|
||||
});
|
||||
upsertSkillLockEntry("bailian-docs-llm-wiki", {
|
||||
contentHash: "sha256:v2",
|
||||
installedAt: "2026-07-24T00:00:00Z",
|
||||
sourceType: "oss",
|
||||
});
|
||||
const entry = readSkillLock().skills["bailian-docs-llm-wiki"];
|
||||
expect(entry.contentHash).toBe("sha256:v2");
|
||||
expect(entry.links).toEqual(["/tmp/x/.claude/skills/bailian-docs-llm-wiki"]);
|
||||
// Other skills' entries are unaffected
|
||||
upsertSkillLockEntry("other", {
|
||||
contentHash: "sha256:v9",
|
||||
installedAt: "2026-07-24T00:00:00Z",
|
||||
sourceType: "oss",
|
||||
});
|
||||
expect(readSkillLock().skills["bailian-docs-llm-wiki"].contentHash).toBe("sha256:v2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { sanitizeSkillName, isSafeSkillName } from "../src/skills/sanitize.ts";
|
||||
import { computeSkillStatuses } from "../src/skills/status.ts";
|
||||
import type { SkillLockFile, SkillsIndex } from "../src/skills/types.ts";
|
||||
|
||||
const PUB = "2026-07-23T00:00:00+08:00";
|
||||
|
||||
function makeIndex(skills: Record<string, string>): SkillsIndex {
|
||||
return {
|
||||
skills: Object.fromEntries(
|
||||
Object.entries(skills).map(([name, contentHash]) => [
|
||||
name,
|
||||
{ contentHash, publishedAt: PUB },
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function makeLock(skills: Record<string, string>): SkillLockFile {
|
||||
return {
|
||||
version: 1,
|
||||
skills: Object.fromEntries(
|
||||
Object.entries(skills).map(([name, contentHash]) => [
|
||||
name,
|
||||
{ contentHash, installedAt: "2026-07-23T00:00:00Z", sourceType: "oss" as const },
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
test("status: five-state derivation matrix", () => {
|
||||
const index = makeIndex({ a: "h-a2", b: "h-b", c: "h-c", d: "h-d", e: "h-e" });
|
||||
const lock = makeLock({ a: "h-a1", b: "h-b", e: "h-e", zombie: "h-z" });
|
||||
// a: lock h-a1 / remote h-a2 / on disk → outdated
|
||||
// b: lock h-b / remote h-b / on disk → installed
|
||||
// c: no lock / on disk (synced by other channel) → untracked
|
||||
// d: no lock / not on disk → not-installed
|
||||
// e: lock h-e / dir deleted → missing
|
||||
// zombie: in lock / delisted from remote / on disk → installed (retained locally)
|
||||
// stray: on disk / in neither lock nor remote → untracked
|
||||
const rows = computeSkillStatuses(index, lock, ["a", "b", "c", "zombie", "stray"]);
|
||||
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
|
||||
expect(byName.a.status).toBe("outdated");
|
||||
expect(byName.a.publishedAt).toBe(PUB);
|
||||
expect(byName.b.status).toBe("installed");
|
||||
expect(byName.c.status).toBe("untracked");
|
||||
expect(byName.c.publishedAt).toBe(PUB);
|
||||
expect(byName.d.status).toBe("not-installed");
|
||||
expect(byName.e.status).toBe("missing");
|
||||
expect(byName.zombie.status).toBe("installed");
|
||||
expect(byName.zombie.publishedAt).toBeUndefined();
|
||||
expect(byName.stray.status).toBe("untracked");
|
||||
expect(rows.map((r) => r.name)).toEqual(rows.map((r) => r.name).sort());
|
||||
});
|
||||
|
||||
test("status: first use (empty lock + empty disk) → all not-installed", () => {
|
||||
const rows = computeSkillStatuses(makeIndex({ a: "1", b: "2" }), makeLock({}), []);
|
||||
expect(rows.every((r) => r.status === "not-installed")).toBe(true);
|
||||
});
|
||||
|
||||
test("status: empty remote registry + nothing local → empty list", () => {
|
||||
expect(computeSkillStatuses(makeIndex({}), makeLock({}), [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("sanitize: path traversal/illegal chars sanitized, safe names unchanged", () => {
|
||||
expect(sanitizeSkillName("../../.ssh")).toBe("ssh");
|
||||
expect(sanitizeSkillName("My Cool Skill!!")).toBe("My-Cool-Skill!!");
|
||||
expect(sanitizeSkillName("a/b\\c:d")).toBe("a-b-c-d");
|
||||
expect(sanitizeSkillName("...")).toBe("unnamed-skill");
|
||||
expect(isSafeSkillName("spark-video")).toBe(true);
|
||||
expect(isSafeSkillName("bailian.model_v2")).toBe(true);
|
||||
expect(isSafeSkillName("../evil")).toBe(false);
|
||||
expect(isSafeSkillName("a b")).toBe(false);
|
||||
expect(isSafeSkillName("")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { BailianError } from "../src/errors/base.ts";
|
||||
import { validateSkillDir } from "../src/skills/validate.ts";
|
||||
|
||||
function withSkillDir(fn: (dir: string) => void): void {
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-skill-validate-"));
|
||||
try {
|
||||
fn(dir);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function expectFail(dir: string, reasonPart: string): void {
|
||||
try {
|
||||
validateSkillDir(dir, "demo");
|
||||
throw new Error("expected validateSkillDir to throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BailianError);
|
||||
expect((err as BailianError).message).toContain(reasonPart);
|
||||
}
|
||||
}
|
||||
|
||||
test("validate: valid SKILL.md passes and returns frontmatter metadata", () => {
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(
|
||||
join(dir, "SKILL.md"),
|
||||
"---\nname: demo-skill\ndescription: a demo skill\n---\n\n# Demo\n",
|
||||
);
|
||||
expect(validateSkillDir(dir, "demo")).toEqual({
|
||||
name: "demo-skill",
|
||||
description: "a demo skill",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("validate: missing SKILL.md → rejected", () => {
|
||||
withSkillDir((dir) => expectFail(dir, "missing SKILL.md"));
|
||||
});
|
||||
|
||||
test("validate: SKILL.md is a directory → rejected", () => {
|
||||
withSkillDir((dir) => {
|
||||
mkdirSync(join(dir, "SKILL.md"));
|
||||
expectFail(dir, "SKILL.md is not a regular file");
|
||||
});
|
||||
});
|
||||
|
||||
test("validate: missing frontmatter → rejected", () => {
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), "# no frontmatter\n");
|
||||
expectFail(dir, "missing frontmatter");
|
||||
});
|
||||
});
|
||||
|
||||
test("validate: frontmatter invalid YAML → rejected", () => {
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), "---\nname: [unclosed\n---\nbody\n");
|
||||
expectFail(dir, "not valid YAML");
|
||||
});
|
||||
});
|
||||
|
||||
test("validate: name/description missing or empty → rejected", () => {
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), "---\nname: demo\n---\nbody\n");
|
||||
expectFail(dir, "name / description");
|
||||
});
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), '---\nname: demo\ndescription: " "\n---\nbody\n');
|
||||
expectFail(dir, "name / description");
|
||||
});
|
||||
withSkillDir((dir) => {
|
||||
writeFileSync(join(dir, "SKILL.md"), "---\nname: demo\ndescription: 123\n---\nbody\n");
|
||||
expectFail(dir, "name / description");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "knowledge-studio-cli",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
|
||||
"keywords": [
|
||||
"alibaba-cloud",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-runtime",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"description": "Runtime framework for bailian-cli (createCli, registry, args, output, pipeline). See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, open, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
isCompiledBinary,
|
||||
type CommandPackManager,
|
||||
type CommandPackReport,
|
||||
type Identity,
|
||||
@@ -96,6 +97,17 @@ async function ensureSandboxAt(dir: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function runNpm(args: string[], cwd: string): Promise<void> {
|
||||
const npmCheck = spawnSync("npm", ["--version"], { encoding: "utf-8" });
|
||||
if (npmCheck.status !== 0) {
|
||||
const hint = isCompiledBinary()
|
||||
? "Command Packs need a local npm. Install Node.js, or use `npm install -g bailian-cli` instead of the binary install."
|
||||
: "Install Node.js / npm and retry.";
|
||||
throw new BailianError(
|
||||
"npm is required to install, link, or remove Command Packs, but was not found on PATH.",
|
||||
ExitCode.GENERAL,
|
||||
hint,
|
||||
);
|
||||
}
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn("npm", args, {
|
||||
cwd,
|
||||
|
||||
@@ -40,7 +40,7 @@ export {
|
||||
} from "./urls.ts";
|
||||
|
||||
// Output facilities consumed by commands
|
||||
export { emitResult, emitBare } from "./output/output.ts";
|
||||
export { emitResult, emitBare, emitRequestId } from "./output/output.ts";
|
||||
export { formatTable } from "./output/table.ts";
|
||||
export { renderBoxTable, type BoxTableOptions, type BarColumn } from "./output/box-table.ts";
|
||||
export { createSpinner, createProgressBar } from "./output/progress.ts";
|
||||
@@ -67,6 +67,22 @@ export {
|
||||
NPM_PACKAGE,
|
||||
NPM_REGISTRY,
|
||||
} from "./utils/update-checker.ts";
|
||||
export {
|
||||
ensureBinaryPathEntries,
|
||||
fetchBinaryChannelVersion,
|
||||
fetchBinaryChannelManifest,
|
||||
getBinaryBinRoot,
|
||||
getBinaryCurrentPath,
|
||||
getBinaryShareRoot,
|
||||
getBinaryVersionsDir,
|
||||
isValidUpdateTargetVersion,
|
||||
normalizeBinaryVersion,
|
||||
performBinaryUpdate,
|
||||
pruneBinaryVersions,
|
||||
readCurrentVersionDir,
|
||||
resolveBinaryDownloadSpec,
|
||||
switchCurrentToVersion,
|
||||
} from "./utils/binary-update.ts";
|
||||
export {
|
||||
BOOL_FLAG_WATERMARK,
|
||||
BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE,
|
||||
|
||||
@@ -133,7 +133,11 @@ export const telemetryStage: Middleware = (ctx, next) => {
|
||||
* if `next()` throws, the notice is skipped (no update nag on failure).
|
||||
*/
|
||||
export const versionCheckStage: Middleware = async (ctx, next) => {
|
||||
const pending = checkForUpdate(ctx.identity.version, ctx.identity.npmPackage).catch(() => {});
|
||||
const pending = checkForUpdate(
|
||||
ctx.identity.version,
|
||||
ctx.identity.npmPackage,
|
||||
ctx.identity.clientName,
|
||||
).catch(() => {});
|
||||
await next();
|
||||
await pending;
|
||||
|
||||
@@ -142,7 +146,12 @@ export const versionCheckStage: Middleware = async (ctx, next) => {
|
||||
if (newVersion && !ctx.settings.quiet && !isUpdateCommand) {
|
||||
if (shouldAutoUpdate(newVersion, ctx.identity.version)) {
|
||||
// 大版本差距且目标为稳定版,自动更新
|
||||
await performAutoUpdate(ctx.identity.version, newVersion, ctx.identity.npmPackage);
|
||||
await performAutoUpdate(
|
||||
ctx.identity.version,
|
||||
newVersion,
|
||||
ctx.identity.npmPackage,
|
||||
ctx.identity.clientName,
|
||||
);
|
||||
} else {
|
||||
const color = ansi(process.stderr);
|
||||
process.stderr.write(
|
||||
|
||||
@@ -16,3 +16,15 @@ export function emitResult(data: unknown, format: OutputFormat): void {
|
||||
export function emitBare(value: string): void {
|
||||
process.stdout.write(value + "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface a server request id for text-mode output. Written to stderr (the
|
||||
* diagnostic channel) so it never corrupts the primary stdout result, mirroring
|
||||
* the request_id line the verbose HTTP logger prints. No-op when the id is
|
||||
* absent (e.g. dry-run) or in --quiet mode (which owes callers a bare scalar).
|
||||
* JSON output surfaces request_id inside the payload instead of calling this.
|
||||
*/
|
||||
export function emitRequestId(requestId: string | undefined, quiet: boolean): void {
|
||||
if (!requestId || quiet) return;
|
||||
process.stderr.write(`request_id: ${requestId}\n`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import {
|
||||
chmod,
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
readdir,
|
||||
readlink,
|
||||
rename,
|
||||
rm,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
BINARY_PRODUCT_CLIENT_NAME,
|
||||
binaryAssetFileName,
|
||||
binaryInnerFileName,
|
||||
channelManifestUrl,
|
||||
detectBinaryPlatform,
|
||||
extractZipEntryToFile,
|
||||
getConfigDir,
|
||||
releaseAssetUrl,
|
||||
writeInstallMethodSync,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
export interface ChannelManifest {
|
||||
version: string;
|
||||
assets?: Record<string, { file?: string; sha256?: string; url?: string; inner?: string }>;
|
||||
}
|
||||
|
||||
/** Product share root: versions/, current, and (on Windows) bin/. */
|
||||
export function getBinaryShareRoot(): string {
|
||||
if (process.env.BAILIAN_SHARE_DIR) return process.env.BAILIAN_SHARE_DIR;
|
||||
if (process.platform === "win32") {
|
||||
return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "bailian-cli");
|
||||
}
|
||||
return join(homedir(), ".local", "share", "bailian-cli");
|
||||
}
|
||||
|
||||
/** PATH directory that should expose `bl` / `bailian`. */
|
||||
export function getBinaryBinRoot(): string {
|
||||
if (process.env.BAILIAN_BIN_DIR) return process.env.BAILIAN_BIN_DIR;
|
||||
if (process.platform === "win32") {
|
||||
return join(getBinaryShareRoot(), "bin");
|
||||
}
|
||||
return join(homedir(), ".local", "bin");
|
||||
}
|
||||
|
||||
export function getBinaryVersionsDir(): string {
|
||||
return join(getBinaryShareRoot(), "versions");
|
||||
}
|
||||
|
||||
export function getBinaryCurrentPath(): string {
|
||||
return join(getBinaryShareRoot(), "current");
|
||||
}
|
||||
|
||||
export async function fetchBinaryChannelVersion(
|
||||
channel = "latest",
|
||||
timeoutMs = 5000,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(channelManifestUrl(channel), {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = (await response.json()) as ChannelManifest;
|
||||
return data.version ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchBinaryChannelManifest(
|
||||
channel = "latest",
|
||||
timeoutMs = 8000,
|
||||
): Promise<ChannelManifest | null> {
|
||||
try {
|
||||
const response = await fetch(channelManifestUrl(channel), {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as ChannelManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip a leading `v` from release-style tags (`v1.2.3` → `1.2.3`). */
|
||||
export function normalizeBinaryVersion(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (/^v\d/i.test(trimmed)) return trimmed.slice(1);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semver core + optional pre-release / build metadata.
|
||||
* Accepts this repo's channel betas (`0.0.0-beta-<sha7>-<YYYYMMDDHHMM>`) and
|
||||
* ordinary releases (`1.13.0`, `1.4.2-beta.1`). Optional leading `v` is allowed.
|
||||
*/
|
||||
const UPDATE_TARGET_VERSION_RE =
|
||||
/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
||||
|
||||
/** True if `raw` is a usable `--to` target after trim (optional `v` prefix). */
|
||||
export function isValidUpdateTargetVersion(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return UPDATE_TARGET_VERSION_RE.test(trimmed);
|
||||
}
|
||||
|
||||
async function fetchSha256FromVersionSums(
|
||||
version: string,
|
||||
fileName: string,
|
||||
timeoutMs = 8000,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(releaseAssetUrl(version, "SHA256SUMS"), {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const text = await response.text();
|
||||
for (const line of text.split("\n")) {
|
||||
const match = line.trim().match(/^([a-fA-F0-9]{64})\s+(\S+)$/);
|
||||
if (match?.[2] === fileName) return match[1].toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
/* optional checksum source */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface BinaryDownloadSpec {
|
||||
zipName: string;
|
||||
innerName: string;
|
||||
url: string;
|
||||
expectedSha?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve download URL / names for an exact binary version.
|
||||
* Always targets `v{version}/` assets; never reuses another version's rolling
|
||||
* manifest `url` / `file`. Checksum prefers per-version SHA256SUMS, then the
|
||||
* latest rolling manifest only when it points at the same version.
|
||||
*/
|
||||
export async function resolveBinaryDownloadSpec(
|
||||
targetVersion: string,
|
||||
): Promise<BinaryDownloadSpec> {
|
||||
const version = normalizeBinaryVersion(targetVersion);
|
||||
const { os, arch, fileSuffix } = detectBinaryPlatform();
|
||||
const exe = fileSuffix === ".exe";
|
||||
const zipName = binaryAssetFileName(version, os, arch, exe);
|
||||
const innerName = binaryInnerFileName(version, os, arch, exe);
|
||||
const url = releaseAssetUrl(version, zipName);
|
||||
|
||||
let expectedSha = await fetchSha256FromVersionSums(version, zipName);
|
||||
if (!expectedSha) {
|
||||
const manifest = await fetchBinaryChannelManifest("latest");
|
||||
if (manifest?.version === version) {
|
||||
expectedSha = manifest.assets?.[`${os}-${arch}`]?.sha256;
|
||||
}
|
||||
}
|
||||
|
||||
return { zipName, innerName, url, expectedSha };
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, dest: string): Promise<Buffer> {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Download failed (${response.status}): ${url}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
await writeFile(dest, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function sha256(buffer: Buffer): string {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function binaryFileName(): string {
|
||||
return process.platform === "win32" ? "bl.exe" : "bl";
|
||||
}
|
||||
|
||||
function aliasFileName(): string {
|
||||
return process.platform === "win32" ? "bailian.exe" : "bailian";
|
||||
}
|
||||
|
||||
/** Resolve which version directory `current` points at, if any. */
|
||||
export async function readCurrentVersionDir(): Promise<string | null> {
|
||||
const currentPath = getBinaryCurrentPath();
|
||||
try {
|
||||
const target = await readlink(currentPath);
|
||||
return target.startsWith("/") || /^[A-Za-z]:[\\/]/.test(target)
|
||||
? target
|
||||
: join(dirname(currentPath), target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function versionNameFromDir(versionDir: string): string | null {
|
||||
const versionsRoot = getBinaryVersionsDir();
|
||||
const normalizedDir = versionDir.replaceAll("\\", "/");
|
||||
const normalizedRoot = versionsRoot.replaceAll("\\", "/").replace(/\/$/, "");
|
||||
if (!normalizedDir.startsWith(`${normalizedRoot}/`) && normalizedDir !== normalizedRoot) {
|
||||
// Also accept basename match when paths differ by symlink resolution
|
||||
const base = versionDir.replaceAll("\\", "/").split("/").pop();
|
||||
return base && base !== "versions" ? base : null;
|
||||
}
|
||||
return normalizedDir.slice(normalizedRoot.length + 1).split("/")[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point `shareRoot/current` at `versions/<version>/`.
|
||||
* Unix: directory symlink. Windows: directory junction.
|
||||
* Retargets in place so PATH entries that go through `current` keep working.
|
||||
*/
|
||||
export async function switchCurrentToVersion(version: string): Promise<string> {
|
||||
const versionDir = join(getBinaryVersionsDir(), version);
|
||||
const currentPath = getBinaryCurrentPath();
|
||||
await mkdir(getBinaryShareRoot(), { recursive: true });
|
||||
|
||||
try {
|
||||
await unlink(currentPath);
|
||||
} catch {
|
||||
try {
|
||||
await rm(currentPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
}
|
||||
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
if (process.platform === "win32") {
|
||||
await symlink(versionDir, currentPath, "junction");
|
||||
} else {
|
||||
await symlink(versionDir, currentPath);
|
||||
}
|
||||
return versionDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure PATH bin entries resolve through `current` (Codex-style).
|
||||
* - Unix: `~/.local/bin/{bl,bailian}` → `current/bl`
|
||||
* - Windows: `shareRoot/bin` is a junction → `current` (contains bl.exe + bailian.exe)
|
||||
*/
|
||||
export async function ensureBinaryPathEntries(version: string): Promise<void> {
|
||||
const versionDir = join(getBinaryVersionsDir(), version);
|
||||
const binaryName = binaryFileName();
|
||||
const currentBinary = join(getBinaryCurrentPath(), binaryName);
|
||||
const binDir = getBinaryBinRoot();
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await ensureWindowsBinJunction(binDir);
|
||||
// Version dir must expose both aliases for the bin junction to work.
|
||||
const primary = join(versionDir, binaryName);
|
||||
const aliasPath = join(versionDir, aliasFileName());
|
||||
try {
|
||||
await lstat(aliasPath);
|
||||
} catch {
|
||||
try {
|
||||
const { link } = await import("node:fs/promises");
|
||||
await link(primary, aliasPath);
|
||||
} catch {
|
||||
await copyFile(primary, aliasPath);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(binDir, { recursive: true });
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
for (const name of ["bl", "bailian"] as const) {
|
||||
const linkPath = join(binDir, name);
|
||||
try {
|
||||
await unlink(linkPath);
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
await symlink(currentBinary, linkPath);
|
||||
}
|
||||
}
|
||||
|
||||
function errnoCode(error: unknown): string {
|
||||
if (error && typeof error === "object" && "code" in error) {
|
||||
return String((error as { code?: unknown }).code ?? "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure `shareRoot/bin` is a junction → `current`.
|
||||
*
|
||||
* Install scripts / older layouts may leave a real `bin/` directory with
|
||||
* `bl.exe` inside. Deleting that directory fails with EACCES while this
|
||||
* process is the running image — rename-away first (Windows allows that),
|
||||
* then create the junction. Stale `bin.migrating-*` dirs are best-effort GC.
|
||||
*/
|
||||
export async function ensureWindowsBinJunction(binDir: string): Promise<void> {
|
||||
const currentPath = getBinaryCurrentPath();
|
||||
const { symlink, rename } = await import("node:fs/promises");
|
||||
|
||||
let migratedAside: string | null = null;
|
||||
|
||||
try {
|
||||
const stats = await lstat(binDir);
|
||||
if (stats.isSymbolicLink()) {
|
||||
const target = await readlink(binDir);
|
||||
const resolved =
|
||||
target.startsWith("/") || /^[A-Za-z]:[\\/]/.test(target)
|
||||
? target
|
||||
: join(dirname(binDir), target);
|
||||
if (
|
||||
resolved.replaceAll("\\", "/").toLowerCase() ===
|
||||
currentPath.replaceAll("\\", "/").toLowerCase()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await unlink(binDir);
|
||||
} else if (stats.isDirectory()) {
|
||||
// Prefer rename over rm: a running bl.exe inside bin locks delete/rm,
|
||||
// but rename of the directory usually succeeds on Windows.
|
||||
migratedAside = `${binDir}.migrating.${process.pid}`;
|
||||
try {
|
||||
await rename(binDir, migratedAside);
|
||||
} catch (renameError) {
|
||||
// Fallback: empty / unlocked real dirs can still be removed.
|
||||
try {
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
migratedAside = null;
|
||||
} catch (rmError) {
|
||||
const code = errnoCode(renameError) || errnoCode(rmError) || "EACCES";
|
||||
throw new Error(
|
||||
`Failed to migrate ${binDir} to a junction pointing at current (${code}). ` +
|
||||
`Close other bl sessions and re-run update, or re-run the install script once.`,
|
||||
{ cause: rmError },
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await unlink(binDir).catch(() => rm(binDir, { recursive: true, force: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
const code = errnoCode(error);
|
||||
if (code && code !== "ENOENT") {
|
||||
if (error instanceof Error && error.message.includes("Failed to migrate")) throw error;
|
||||
throw new Error(
|
||||
`Failed to migrate ${binDir} to a junction pointing at current (${code}). ` +
|
||||
`Close other bl sessions and re-run update, or re-run the install script once.`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(dirname(binDir), { recursive: true });
|
||||
await symlink(currentPath, binDir, "junction");
|
||||
|
||||
if (migratedAside) {
|
||||
// Best-effort: locked exes may keep the aside dir until process exit.
|
||||
await rm(migratedAside, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the listed version directory names under `versions/`.
|
||||
* Always preserves directories that are still the live `current` target.
|
||||
*/
|
||||
export async function pruneBinaryVersions(keepVersions: string[]): Promise<void> {
|
||||
const versionsDir = getBinaryVersionsDir();
|
||||
const keep = new Set(keepVersions.filter(Boolean));
|
||||
const currentDir = await readCurrentVersionDir();
|
||||
const currentName = currentDir ? versionNameFromDir(currentDir) : null;
|
||||
if (currentName) keep.add(currentName);
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(versionsDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith(".")) {
|
||||
await rm(join(versionsDir, entry), { recursive: true, force: true }).catch(() => {});
|
||||
continue;
|
||||
}
|
||||
if (keep.has(entry)) continue;
|
||||
await rm(join(versionsDir, entry), { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install a newer standalone binary using Codex-style layout:
|
||||
* `versions/<ver>/` + retarget `current` + path entries through `current`.
|
||||
* After a successful switch, prune so only current + previous version remain.
|
||||
*
|
||||
* Does not overwrite a running executable image: old version files stay locked
|
||||
* by the current process; the next invocation follows the updated pointer.
|
||||
*/
|
||||
export async function performBinaryUpdate(targetVersion: string): Promise<string> {
|
||||
const version = normalizeBinaryVersion(targetVersion);
|
||||
const { zipName, innerName, url, expectedSha } = await resolveBinaryDownloadSpec(version);
|
||||
|
||||
const share = getBinaryShareRoot();
|
||||
const versionsDir = getBinaryVersionsDir();
|
||||
await mkdir(join(share, ".tmp"), { recursive: true });
|
||||
await mkdir(versionsDir, { recursive: true });
|
||||
|
||||
const previousVersionDir = await readCurrentVersionDir();
|
||||
const previousVersion = previousVersionDir ? versionNameFromDir(previousVersionDir) : null;
|
||||
|
||||
const tmpZip = join(share, ".tmp", zipName);
|
||||
const buffer = await downloadToFile(url, tmpZip);
|
||||
const actualSha = sha256(buffer);
|
||||
if (expectedSha && expectedSha !== actualSha) {
|
||||
await unlink(tmpZip).catch(() => {});
|
||||
throw new Error(`Checksum mismatch for ${zipName}`);
|
||||
}
|
||||
|
||||
const stagingDir = join(versionsDir, `.staging.${version}.${process.pid}`);
|
||||
await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
|
||||
await mkdir(stagingDir, { recursive: true });
|
||||
|
||||
const binaryName = binaryFileName();
|
||||
const stagingBinary = join(stagingDir, binaryName);
|
||||
const tmpBinary = join(share, ".tmp", `${binaryName}.${process.pid}`);
|
||||
await extractZipEntryToFile(tmpZip, tmpBinary, innerName);
|
||||
await unlink(tmpZip).catch(() => {});
|
||||
await rename(tmpBinary, stagingBinary);
|
||||
if (process.platform !== "win32") {
|
||||
await chmod(stagingBinary, 0o755);
|
||||
} else {
|
||||
const aliasPath = join(stagingDir, aliasFileName());
|
||||
try {
|
||||
const { link } = await import("node:fs/promises");
|
||||
await link(stagingBinary, aliasPath);
|
||||
} catch {
|
||||
await copyFile(stagingBinary, aliasPath);
|
||||
}
|
||||
}
|
||||
|
||||
const versionDir = join(versionsDir, version);
|
||||
await rm(versionDir, { recursive: true, force: true }).catch(() => {});
|
||||
await rename(stagingDir, versionDir);
|
||||
|
||||
await switchCurrentToVersion(version);
|
||||
await ensureBinaryPathEntries(version);
|
||||
|
||||
const keep = [version];
|
||||
if (previousVersion && previousVersion !== version) {
|
||||
keep.push(previousVersion);
|
||||
}
|
||||
await pruneBinaryVersions(keep);
|
||||
|
||||
writeInstallMethodSync("binary", { clientName: BINARY_PRODUCT_CLIENT_NAME });
|
||||
await mkdir(getConfigDir(), { recursive: true });
|
||||
return version;
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { join } from "path";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { getConfigDir, trackingHeaders } from "bailian-cli-core";
|
||||
import {
|
||||
BailianError,
|
||||
DEFAULT_INSTALL_PS1_URL,
|
||||
DEFAULT_INSTALL_SCRIPT_URL,
|
||||
getConfigDir,
|
||||
trackingHeaders,
|
||||
getUpdateInstallMethod,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
export const NPM_REGISTRY = "https://registry.npmjs.org";
|
||||
/** Default npm package; products override per-call via the `npmPackage` argument. */
|
||||
@@ -207,13 +214,14 @@ function errorMessage(err: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform auto-update: install latest version globally and update agent skill.
|
||||
* Perform auto-update for npm or binary installs.
|
||||
* Returns true if update succeeded, false otherwise.
|
||||
*/
|
||||
export async function performAutoUpdate(
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
npmPackage: string = NPM_PACKAGE,
|
||||
clientName: string = NPM_PACKAGE,
|
||||
): Promise<boolean> {
|
||||
const isTTY = process.stderr.isTTY;
|
||||
const green = isTTY ? "\x1b[32m" : "";
|
||||
@@ -222,6 +230,11 @@ export async function performAutoUpdate(
|
||||
const dim = isTTY ? "\x1b[2m" : "";
|
||||
const reset = isTTY ? "\x1b[0m" : "";
|
||||
|
||||
const method = getUpdateInstallMethod({ clientName, npmPackage });
|
||||
if (method === "brew" || method === "winget") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [latestMajor] = parseVersion(latestVersion);
|
||||
const [currentMajor] = parseVersion(currentVersion);
|
||||
const isMajorBump = latestMajor > currentMajor;
|
||||
@@ -240,17 +253,34 @@ export async function performAutoUpdate(
|
||||
process.stderr.write(` ${dim}Auto-updating to keep your CLI up to date...${reset}\n`);
|
||||
process.stderr.write(` ${yellow}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${reset}\n\n`);
|
||||
|
||||
if (method === "binary") {
|
||||
try {
|
||||
const { performBinaryUpdate } = await import("./binary-update.ts");
|
||||
const newVer = await performBinaryUpdate(latestVersion);
|
||||
writeState({ lastChecked: Date.now(), latestVersion: newVer });
|
||||
process.stderr.write(` ${green}✓ Update complete: ${currentVersion} → ${newVer}${reset}\n`);
|
||||
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
|
||||
pendingNotification = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`);
|
||||
const reinstall =
|
||||
err instanceof BailianError && err.hint
|
||||
? err.hint.replace(/^Re-run:\s*/i, "")
|
||||
: process.platform === "win32"
|
||||
? `irm ${DEFAULT_INSTALL_PS1_URL} | iex`
|
||||
: `curl -fsSL ${DEFAULT_INSTALL_SCRIPT_URL} | bash`;
|
||||
process.stderr.write(` ${yellow} Re-run:${reset} ${cyan}${reinstall}${reset}\n\n`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const cmd = `npm install -g ${npmPackage}@latest`;
|
||||
|
||||
try {
|
||||
const { execSync } = await import("child_process");
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
|
||||
// Verify the actually-installed version by reading the global package.json.
|
||||
// We must NOT rely on `bl --version`: the user may run via npx, a local
|
||||
// install, or a custom bin name, in which case `bl` on PATH points at the
|
||||
// wrong binary (or nothing at all). Reading the installed package directly
|
||||
// is correct regardless of how the CLI was invoked.
|
||||
let newVer: string | null = null;
|
||||
try {
|
||||
const globalRoot = execSync("npm root -g", { encoding: "utf-8" }).trim();
|
||||
@@ -264,8 +294,6 @@ export async function performAutoUpdate(
|
||||
);
|
||||
}
|
||||
|
||||
// Update cached state. writeState swallows errors internally: state caching
|
||||
// is non-critical and must never break the CLI startup path.
|
||||
writeState({ lastChecked: Date.now(), latestVersion: newVer ?? latestVersion });
|
||||
|
||||
process.stderr.write(
|
||||
@@ -273,26 +301,20 @@ export async function performAutoUpdate(
|
||||
);
|
||||
process.stderr.write(` ${dim}Run ${cyan}bl --version${reset}${dim} to verify.${reset}\n\n`);
|
||||
|
||||
// Update agent skill
|
||||
try {
|
||||
process.stderr.write(` ${dim}Syncing agent skill...${reset}\n`);
|
||||
execSync(`npx skills add modelstudioai/cli --all -g -y`, { stdio: "inherit" });
|
||||
process.stderr.write(` ${green}✓ Agent skill updated.${reset}\n\n`);
|
||||
} catch (err) {
|
||||
// Surface the reason the skill sync failed rather than swallowing it
|
||||
// silently, but keep degradation: the CLI itself already updated.
|
||||
process.stderr.write(` ${yellow}⚠ Agent skill sync failed: ${errorMessage(err)}${reset}\n`);
|
||||
process.stderr.write(
|
||||
` ${yellow} Run manually: npx skills add modelstudioai/cli --all -g -y${reset}\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Clear pending notification
|
||||
pendingNotification = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
// npm install failure — most commonly EACCES (global installs often need
|
||||
// elevated permissions). Tell the user *why* it failed, not just *that*.
|
||||
process.stderr.write(` ${yellow}⚠ Auto-update failed: ${errorMessage(err)}${reset}\n`);
|
||||
process.stderr.write(
|
||||
` ${yellow} If this is a permissions error (EACCES), retry with sudo or fix npm perms.${reset}\n`,
|
||||
@@ -305,16 +327,26 @@ export async function performAutoUpdate(
|
||||
export async function checkForUpdate(
|
||||
currentVersion: string,
|
||||
npmPackage: string = NPM_PACKAGE,
|
||||
clientName: string = NPM_PACKAGE,
|
||||
): Promise<void> {
|
||||
const state = readState();
|
||||
const now = Date.now();
|
||||
|
||||
// Inside the throttle window (CHECK_INTERVAL_MS since the last fetch): no
|
||||
// network call and no notice. The state file is global, so the notice fires at
|
||||
// most once per window across all processes/sessions — not once per command.
|
||||
if (state && now - state.lastChecked < CHECK_INTERVAL_MS) return;
|
||||
|
||||
const latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
|
||||
const method = getUpdateInstallMethod({ clientName, npmPackage });
|
||||
let latest: string | null = null;
|
||||
if (method === "binary") {
|
||||
try {
|
||||
const { fetchBinaryChannelVersion } = await import("./binary-update.ts");
|
||||
latest = await fetchBinaryChannelVersion("latest", FETCH_TIMEOUT_MS);
|
||||
} catch {
|
||||
latest = null;
|
||||
}
|
||||
if (!latest) latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
|
||||
} else {
|
||||
latest = await fetchLatestVersion(FETCH_TIMEOUT_MS, npmPackage);
|
||||
}
|
||||
if (!latest) return;
|
||||
|
||||
writeState({ lastChecked: now, latestVersion: latest });
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readlinkSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
ensureBinaryPathEntries,
|
||||
ensureWindowsBinJunction,
|
||||
getBinaryBinRoot,
|
||||
getBinaryCurrentPath,
|
||||
getBinaryShareRoot,
|
||||
getBinaryVersionsDir,
|
||||
isValidUpdateTargetVersion,
|
||||
normalizeBinaryVersion,
|
||||
pruneBinaryVersions,
|
||||
readCurrentVersionDir,
|
||||
resolveBinaryDownloadSpec,
|
||||
switchCurrentToVersion,
|
||||
} from "../src/utils/binary-update.ts";
|
||||
import { binaryAssetFileName, releaseAssetUrl } from "bailian-cli-core";
|
||||
|
||||
function withTempBinaryRoots(run: () => Promise<void>): Promise<void> {
|
||||
const root = mkdtempSync(join(tmpdir(), "bl-binary-layout-"));
|
||||
const previousShare = process.env.BAILIAN_SHARE_DIR;
|
||||
const previousBin = process.env.BAILIAN_BIN_DIR;
|
||||
process.env.BAILIAN_SHARE_DIR = root;
|
||||
process.env.BAILIAN_BIN_DIR = join(root, "path-bin");
|
||||
return run().finally(() => {
|
||||
if (previousShare === undefined) delete process.env.BAILIAN_SHARE_DIR;
|
||||
else process.env.BAILIAN_SHARE_DIR = previousShare;
|
||||
if (previousBin === undefined) delete process.env.BAILIAN_BIN_DIR;
|
||||
else process.env.BAILIAN_BIN_DIR = previousBin;
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
function seedVersion(version: string): string {
|
||||
const versionDir = join(getBinaryVersionsDir(), version);
|
||||
mkdirSync(versionDir, { recursive: true });
|
||||
const binaryName = process.platform === "win32" ? "bl.exe" : "bl";
|
||||
writeFileSync(join(versionDir, binaryName), "fake-binary");
|
||||
if (process.platform === "win32") {
|
||||
writeFileSync(join(versionDir, "bailian.exe"), "fake-binary");
|
||||
}
|
||||
return versionDir;
|
||||
}
|
||||
|
||||
test("share/bin roots respect BAILIAN_* overrides", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
expect(getBinaryShareRoot()).toContain("bl-binary-layout-");
|
||||
expect(getBinaryBinRoot()).toBe(join(getBinaryShareRoot(), "path-bin"));
|
||||
expect(getBinaryCurrentPath()).toBe(join(getBinaryShareRoot(), "current"));
|
||||
});
|
||||
});
|
||||
|
||||
test("switchCurrentToVersion retargets current pointer", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
const firstDir = seedVersion("1.0.0");
|
||||
await switchCurrentToVersion("1.0.0");
|
||||
expect(await readCurrentVersionDir()).toBe(firstDir);
|
||||
|
||||
const secondDir = seedVersion("1.1.0");
|
||||
await switchCurrentToVersion("1.1.0");
|
||||
expect(await readCurrentVersionDir()).toBe(secondDir);
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneBinaryVersions keeps current and requested previous only", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
seedVersion("1.0.0");
|
||||
seedVersion("1.1.0");
|
||||
seedVersion("1.2.0");
|
||||
await switchCurrentToVersion("1.2.0");
|
||||
await pruneBinaryVersions(["1.2.0", "1.1.0"]);
|
||||
|
||||
expect(existsSync(join(getBinaryVersionsDir(), "1.2.0"))).toBe(true);
|
||||
expect(existsSync(join(getBinaryVersionsDir(), "1.1.0"))).toBe(true);
|
||||
expect(existsSync(join(getBinaryVersionsDir(), "1.0.0"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizeBinaryVersion strips release-style v prefix", () => {
|
||||
expect(normalizeBinaryVersion("v1.2.3")).toBe("1.2.3");
|
||||
expect(normalizeBinaryVersion("1.2.3")).toBe("1.2.3");
|
||||
expect(normalizeBinaryVersion(" 0.1.14-channel.1 ")).toBe("0.1.14-channel.1");
|
||||
});
|
||||
|
||||
test("isValidUpdateTargetVersion accepts semver and channel betas", () => {
|
||||
expect(isValidUpdateTargetVersion("1.13.0")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("v1.13.0")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("1.4.2-beta.1")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("0.0.0-beta-be3033b-202607311142")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("v0.0.0-beta-be3033b-202607311142")).toBe(true);
|
||||
expect(isValidUpdateTargetVersion("latest")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("1.2")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("foo")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("")).toBe(false);
|
||||
// Path-traversal / path-separator inputs must never reach versions/<ver>/
|
||||
expect(isValidUpdateTargetVersion("../../../..")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("..\\..\\..")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("1.2.3/../x")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("1.2.3\\..\\x")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("/etc/passwd")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("versions/../../tmp")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("1.2.3/")).toBe(false);
|
||||
expect(isValidUpdateTargetVersion("..")).toBe(false);
|
||||
});
|
||||
|
||||
test("resolveBinaryDownloadSpec targets version assets, not latest manifest url", async () => {
|
||||
const version = "0.1.14-channel.1";
|
||||
const { detectBinaryPlatform } = await import("bailian-cli-core");
|
||||
const { os, arch } = detectBinaryPlatform();
|
||||
const zipName = binaryAssetFileName(version, os, arch);
|
||||
const sumsSha = "deadbeef".repeat(8);
|
||||
const previousFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/SHA256SUMS")) {
|
||||
return new Response(`${sumsSha} ${zipName}\n`, { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/manifest.json")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
version: "9.9.9",
|
||||
assets: {
|
||||
[`${os}-${arch}`]: {
|
||||
file: `bl-9.9.9-${os}-${arch}.zip`,
|
||||
sha256: "aa".repeat(32),
|
||||
url: "https://example.invalid/wrong.zip",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const spec = await resolveBinaryDownloadSpec(`v${version}`);
|
||||
expect(spec.zipName).toBe(zipName);
|
||||
expect(spec.url).toBe(releaseAssetUrl(version, zipName));
|
||||
expect(spec.url).not.toContain("wrong.zip");
|
||||
expect(spec.expectedSha).toBe(sumsSha);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("ensureBinaryPathEntries wires PATH entries through current", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
seedVersion("2.0.0");
|
||||
await switchCurrentToVersion("2.0.0");
|
||||
await ensureBinaryPathEntries("2.0.0");
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const binRoot = getBinaryBinRoot();
|
||||
const target = readlinkSync(binRoot);
|
||||
expect(target.replaceAll("/", "\\").toLowerCase()).toBe(
|
||||
getBinaryCurrentPath().replaceAll("/", "\\").toLowerCase(),
|
||||
);
|
||||
} else {
|
||||
const blLink = readlinkSync(join(getBinaryBinRoot(), "bl"));
|
||||
const bailianLink = readlinkSync(join(getBinaryBinRoot(), "bailian"));
|
||||
expect(blLink).toBe(join(getBinaryCurrentPath(), "bl"));
|
||||
expect(bailianLink).toBe(join(getBinaryCurrentPath(), "bl"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("ensureWindowsBinJunction migrates a real bin directory via rename", async () => {
|
||||
await withTempBinaryRoots(async () => {
|
||||
seedVersion("3.0.0");
|
||||
await switchCurrentToVersion("3.0.0");
|
||||
|
||||
const binRoot = getBinaryBinRoot();
|
||||
mkdirSync(binRoot, { recursive: true });
|
||||
writeFileSync(join(binRoot, "bl.exe"), "old-copy");
|
||||
|
||||
await ensureWindowsBinJunction(binRoot);
|
||||
|
||||
const target = readlinkSync(binRoot);
|
||||
expect(target.replaceAll("\\", "/").toLowerCase()).toBe(
|
||||
getBinaryCurrentPath().replaceAll("\\", "/").toLowerCase(),
|
||||
);
|
||||
expect(existsSync(`${binRoot}.migrating.${process.pid}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,11 @@ fs.appendFileSync(
|
||||
}) + "\\n",
|
||||
);
|
||||
|
||||
if (args[0] === "--version") {
|
||||
process.stdout.write("10.0.0\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const manifestPath = path.join(cwd, "package.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
manifest.dependencies ??= {};
|
||||
@@ -205,8 +210,9 @@ test("installs once on success and restores the previous version after validatio
|
||||
env: Record<string, string | null>;
|
||||
},
|
||||
);
|
||||
expect(successfulCalls).toHaveLength(1);
|
||||
expect(successfulCalls[0]?.env).toEqual({
|
||||
// runNpm probes `npm --version` before each install/uninstall.
|
||||
expect(successfulCalls.map((call) => call.args[0])).toEqual(["--version", "install"]);
|
||||
expect(successfulCalls[1]?.env).toEqual({
|
||||
registry: "https://registry.example.test",
|
||||
catalog: null,
|
||||
recursive: null,
|
||||
@@ -228,14 +234,11 @@ test("installs once on success and restores the previous version after validatio
|
||||
env: Record<string, string | null>;
|
||||
},
|
||||
);
|
||||
expect(rollbackCalls).toHaveLength(2);
|
||||
expect(rollbackCalls[0]?.args.slice(0, 2)).toEqual([
|
||||
"install",
|
||||
"@ali/bailian-plugin-agent@broken",
|
||||
]);
|
||||
expect(rollbackCalls[1]?.args.slice(0, 2)).toEqual([
|
||||
"install",
|
||||
"@ali/bailian-plugin-agent@1.0.0",
|
||||
expect(rollbackCalls.map((call) => call.args.slice(0, 2))).toEqual([
|
||||
["--version"],
|
||||
["install", "@ali/bailian-plugin-agent@broken"],
|
||||
["--version"],
|
||||
["install", "@ali/bailian-plugin-agent@1.0.0"],
|
||||
]);
|
||||
|
||||
const manifest = JSON.parse(
|
||||
|
||||
Generated
+157
@@ -9,6 +9,9 @@ catalogs:
|
||||
'@types/node':
|
||||
specifier: ^24
|
||||
version: 24.12.2
|
||||
'@types/tar-stream':
|
||||
specifier: ^3.1.4
|
||||
version: 3.1.4
|
||||
'@types/yauzl':
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
@@ -21,6 +24,9 @@ catalogs:
|
||||
chalk:
|
||||
specifier: ^5.6.2
|
||||
version: 5.6.2
|
||||
tar-stream:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.0
|
||||
smol-toml:
|
||||
specifier: ^1.4.2
|
||||
version: 1.7.0
|
||||
@@ -66,6 +72,9 @@ importers:
|
||||
bailian-cli-runtime:
|
||||
specifier: workspace:*
|
||||
version: link:../runtime
|
||||
tar-stream:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.0
|
||||
devDependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^0.7.0
|
||||
@@ -143,6 +152,9 @@ importers:
|
||||
|
||||
packages/core:
|
||||
dependencies:
|
||||
tar-stream:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.0
|
||||
yaml:
|
||||
specifier: ^2.8.3
|
||||
version: 2.8.3
|
||||
@@ -153,6 +165,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 24.12.2
|
||||
'@types/tar-stream':
|
||||
specifier: 'catalog:'
|
||||
version: 3.1.4
|
||||
'@types/yauzl':
|
||||
specifier: 'catalog:'
|
||||
version: 3.4.0
|
||||
@@ -860,6 +875,9 @@ packages:
|
||||
'@types/node@25.6.0':
|
||||
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
||||
|
||||
'@types/tar-stream@3.1.4':
|
||||
resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==}
|
||||
|
||||
'@types/yauzl@3.4.0':
|
||||
resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==}
|
||||
|
||||
@@ -1070,6 +1088,51 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
b4a@1.8.1:
|
||||
resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==}
|
||||
peerDependencies:
|
||||
react-native-b4a: '*'
|
||||
peerDependenciesMeta:
|
||||
react-native-b4a:
|
||||
optional: true
|
||||
|
||||
bare-events@2.9.1:
|
||||
resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
|
||||
bare-fs@4.7.4:
|
||||
resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==}
|
||||
engines: {bare: '>=1.16.0'}
|
||||
peerDependencies:
|
||||
bare-buffer: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-buffer:
|
||||
optional: true
|
||||
|
||||
bare-path@3.1.1:
|
||||
resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==}
|
||||
|
||||
bare-stream@2.13.3:
|
||||
resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==}
|
||||
peerDependencies:
|
||||
bare-abort-controller: '*'
|
||||
bare-buffer: '*'
|
||||
bare-events: '*'
|
||||
peerDependenciesMeta:
|
||||
bare-abort-controller:
|
||||
optional: true
|
||||
bare-buffer:
|
||||
optional: true
|
||||
bare-events:
|
||||
optional: true
|
||||
|
||||
bare-url@2.4.5:
|
||||
resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==}
|
||||
|
||||
boxen@8.0.1:
|
||||
resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1107,9 +1170,15 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
events-universal@1.0.1:
|
||||
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fast-uri@3.1.2:
|
||||
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
|
||||
|
||||
@@ -1326,6 +1395,9 @@ packages:
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
streamx@2.28.0:
|
||||
resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1345,6 +1417,15 @@ packages:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
tar-stream@3.2.0:
|
||||
resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==}
|
||||
|
||||
teex@1.0.1:
|
||||
resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
|
||||
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -1814,6 +1895,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
|
||||
'@types/tar-stream@3.1.4':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/yauzl@3.4.0':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
@@ -2057,6 +2142,37 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
b4a@1.8.1: {}
|
||||
|
||||
bare-events@2.9.1: {}
|
||||
|
||||
bare-fs@4.7.4:
|
||||
dependencies:
|
||||
bare-events: 2.9.1
|
||||
bare-path: 3.1.1
|
||||
bare-stream: 2.13.3(bare-events@2.9.1)
|
||||
bare-url: 2.4.5
|
||||
fast-fifo: 1.3.2
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
bare-path@3.1.1: {}
|
||||
|
||||
bare-stream@2.13.3(bare-events@2.9.1):
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
streamx: 2.28.0
|
||||
teex: 1.0.1
|
||||
optionalDependencies:
|
||||
bare-events: 2.9.1
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
bare-url@2.4.5:
|
||||
dependencies:
|
||||
bare-path: 3.1.1
|
||||
|
||||
boxen@8.0.1:
|
||||
dependencies:
|
||||
ansi-align: 3.0.1
|
||||
@@ -2113,8 +2229,16 @@ snapshots:
|
||||
'@esbuild/win32-ia32': 0.28.1
|
||||
'@esbuild/win32-x64': 0.28.1
|
||||
|
||||
events-universal@1.0.1:
|
||||
dependencies:
|
||||
bare-events: 2.9.1
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-uri@3.1.2: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
@@ -2334,6 +2458,15 @@ snapshots:
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
streamx@2.28.0:
|
||||
dependencies:
|
||||
events-universal: 1.0.1
|
||||
fast-fifo: 1.3.2
|
||||
text-decoder: 1.2.7
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -2358,6 +2491,30 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
tar-stream@3.2.0:
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
bare-fs: 4.7.4
|
||||
fast-fifo: 1.3.2
|
||||
streamx: 2.28.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- bare-buffer
|
||||
- react-native-b4a
|
||||
|
||||
teex@1.0.1:
|
||||
dependencies:
|
||||
streamx: 2.28.0
|
||||
transitivePeerDependencies:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
text-decoder@1.2.7:
|
||||
dependencies:
|
||||
b4a: 1.8.1
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.1.2: {}
|
||||
|
||||
@@ -4,10 +4,12 @@ packages:
|
||||
|
||||
catalog:
|
||||
"@types/node": ^24
|
||||
"@types/tar-stream": ^3.1.4
|
||||
"@types/yauzl": ^3.4.0
|
||||
ajv: ^8.20.0
|
||||
boxen: ^8.0.1
|
||||
chalk: ^5.6.2
|
||||
tar-stream: ^3.2.0
|
||||
smol-toml: ^1.4.2
|
||||
tsx: ^4.23.0
|
||||
typescript: ^5
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"ask-matt": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/ask-matt/SKILL.md",
|
||||
"computedHash": "0f843160e34a24f5bd12cdc7de7d40951e77fbdc05ce8f891b37ca32eac2c44d"
|
||||
},
|
||||
"batch-grill-me": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/in-progress/batch-grill-me/SKILL.md",
|
||||
"computedHash": "f448831b8f04518b1527408f1d5024384ad5f543bdbaac6fabb7aa053ad60489"
|
||||
},
|
||||
"claude-handoff": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/in-progress/claude-handoff/SKILL.md",
|
||||
"computedHash": "40a7f4ec80b9826ad7d0c47b0778c99686d6d9d662112b3e175a816f3aca4c39"
|
||||
},
|
||||
"code-review": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/code-review/SKILL.md",
|
||||
"computedHash": "31d149a480eaa68c11e32f5ee77f0fd0b98a906834d531d881d502352edd0b8e"
|
||||
},
|
||||
"codebase-design": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/codebase-design/SKILL.md",
|
||||
"computedHash": "6d9d51d8caa01633fd00cf87089c5618c982321f942ac69f62565dc001c6b22f"
|
||||
},
|
||||
"design-an-interface": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/deprecated/design-an-interface/SKILL.md",
|
||||
"computedHash": "aef348a9af675b771635623b5aa7790da4d40adc1ee88b58b6fd116eda2047d8"
|
||||
},
|
||||
"diagnosing-bugs": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/diagnosing-bugs/SKILL.md",
|
||||
"computedHash": "fd6c99466b7ba43be624e6e66ed6d7af2796ded7218f24c83820823977819e22"
|
||||
},
|
||||
"domain-modeling": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/domain-modeling/SKILL.md",
|
||||
"computedHash": "363cb0f53b0b431e7c00086ad1f823500b7e1b70b5616ee969c979f0934e9e6e"
|
||||
},
|
||||
"edit-article": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/personal/edit-article/SKILL.md",
|
||||
"computedHash": "cb2a13b2acb6c8a0706eb3a973a2681f30fd74c00784c74ceaa98e12eb2b2fc3"
|
||||
},
|
||||
"git-guardrails-claude-code": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/misc/git-guardrails-claude-code/SKILL.md",
|
||||
"computedHash": "8c29552c621da0121427306afd92f8e02f47f446cdb0b55ceae01de6879fcb3a"
|
||||
},
|
||||
"grill-me": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/productivity/grill-me/SKILL.md",
|
||||
"computedHash": "f361db4e15e6bfd562a9282b1dccda513910a50061f9e838ce017be9c69dde3f"
|
||||
},
|
||||
"grill-with-docs": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/grill-with-docs/SKILL.md",
|
||||
"computedHash": "9c460cbd94fd3c63cdef967dbdb6e66ca687103cdc380cd37834e4d10b738f78"
|
||||
},
|
||||
"grilling": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/productivity/grilling/SKILL.md",
|
||||
"computedHash": "368d3dd7251247c69f3656d93dc83c8f1577792eacac2111f1e7981db2ece49b"
|
||||
},
|
||||
"handoff": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/productivity/handoff/SKILL.md",
|
||||
"computedHash": "ad03e8d4ea3cbbff66420eb7ba3cc375b5cbe1821a2449b53e863256cf5b5cde"
|
||||
},
|
||||
"implement": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/implement/SKILL.md",
|
||||
"computedHash": "2139cfedf24791adbc839aaab6019cff158af1e28bfead020ec6e0ce01b3e74d"
|
||||
},
|
||||
"improve-codebase-architecture": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md",
|
||||
"computedHash": "66e8a50c83c3c724fcfe0769701b665c56cec220cc4f49cc1aee8bdfc07de94a"
|
||||
},
|
||||
"loop-me": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/in-progress/loop-me/SKILL.md",
|
||||
"computedHash": "e1fcab9b531b338eb540c9d57ee089e2660e4bba1d7623ec566c6f32f77f2d26"
|
||||
},
|
||||
"migrate-to-shoehorn": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/misc/migrate-to-shoehorn/SKILL.md",
|
||||
"computedHash": "6397731ced114f3657aa88b55ed13d1344a56d77ca449c568e3200c21740fa99"
|
||||
},
|
||||
"obsidian-vault": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/personal/obsidian-vault/SKILL.md",
|
||||
"computedHash": "5c08eda96fd76a71793c0a8cb91bef41d518586654a14af4f2edf8f8fd0f96a7"
|
||||
},
|
||||
"prototype": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/prototype/SKILL.md",
|
||||
"computedHash": "faba901c53a6ca245174c4ba5db3929d14253232b3050fdbd46539720adf1ab8"
|
||||
},
|
||||
"qa": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/deprecated/qa/SKILL.md",
|
||||
"computedHash": "223de2b02aa9ab36cfa4fe93e80e527b56e912e7ae725f9d596f6d2457afab77"
|
||||
},
|
||||
"request-refactor-plan": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/deprecated/request-refactor-plan/SKILL.md",
|
||||
"computedHash": "08c06e587d0f979b8731f6a00c67fb8d00e563614daf80bf648d41148e553473"
|
||||
},
|
||||
"research": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/research/SKILL.md",
|
||||
"computedHash": "bd3e2c6826671d82c86ed0da3dac3370ebcf63b0fe847f91bb444e1fb7dac21b"
|
||||
},
|
||||
"resolving-merge-conflicts": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/resolving-merge-conflicts/SKILL.md",
|
||||
"computedHash": "28aad6f8b1b7025abc8892fa8890f68fe1533499b28a565f416b159131d22fad"
|
||||
},
|
||||
"scaffold-exercises": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/misc/scaffold-exercises/SKILL.md",
|
||||
"computedHash": "354c91f6dbc9b058632f30594aacb4edc6d25012596585abb00402af8d7ec5e9"
|
||||
},
|
||||
"setup-matt-pocock-skills": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md",
|
||||
"computedHash": "74e894a3509e2676d4cdb771c8eace087092430635e845e02a9cc2f757c552a4"
|
||||
},
|
||||
"setup-pre-commit": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/misc/setup-pre-commit/SKILL.md",
|
||||
"computedHash": "1b140af948d0a6330c4cf52d46ca2e06a0e96fdc34d22ec8888d2b9751b99b62"
|
||||
},
|
||||
"setup-ts-deep-modules": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/in-progress/setup-ts-deep-modules/SKILL.md",
|
||||
"computedHash": "bd5cbaa455454c2d6c27a1b734e0d820606da6c482bf3c79ec55fbc3f3e03ef3"
|
||||
},
|
||||
"tdd": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/tdd/SKILL.md",
|
||||
"computedHash": "81eca2a5b53a63f481c0849be7a663a8cd43d5cf53f32b644ec0a2f50cf91aa2"
|
||||
},
|
||||
"teach": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/productivity/teach/SKILL.md",
|
||||
"computedHash": "68999bb1a241384b2f921f3321d779b7d05eb6089077b70d88cee2aee3d75ccc"
|
||||
},
|
||||
"to-questionnaire": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/in-progress/to-questionnaire/SKILL.md",
|
||||
"computedHash": "d8938509f3400d9977343e11e876fc6e1982b0b9c133a2f2e9d3d730a67a103e"
|
||||
},
|
||||
"to-spec": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/to-spec/SKILL.md",
|
||||
"computedHash": "0f544cd0c099c06f0dd0b7b9ee98b4237218e7e95fd3d3c02e791efbaf74bacb"
|
||||
},
|
||||
"to-tickets": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/to-tickets/SKILL.md",
|
||||
"computedHash": "5d79577541b5cf6dade61c69844432ea20ce7b12527cdda1dd4d6fe8d59a135d"
|
||||
},
|
||||
"triage": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/triage/SKILL.md",
|
||||
"computedHash": "7c923b6a677cfe689500721f08307e2a4c46797ff169dc55ef6c34a431a0d533"
|
||||
},
|
||||
"ubiquitous-language": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/deprecated/ubiquitous-language/SKILL.md",
|
||||
"computedHash": "0395170031b57ea63f2eb22561542894c03e3047430a2f1118a06b411054429b"
|
||||
},
|
||||
"wayfinder": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/engineering/wayfinder/SKILL.md",
|
||||
"computedHash": "c9e18cefd77b6b5b0ee35f59ce2fd96359aa2d2f02e3479e12c873c5402d9d43"
|
||||
},
|
||||
"wizard": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/in-progress/wizard/SKILL.md",
|
||||
"computedHash": "dab67fb9fdcc70f3a06cf2facecf58f405f478504812103b53d4c5a84765254d"
|
||||
},
|
||||
"writing-beats": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/in-progress/writing-beats/SKILL.md",
|
||||
"computedHash": "7581e49a991e45e4e486b128c6391b82bd112c143cea3cc4426c23530dd545af"
|
||||
},
|
||||
"writing-fragments": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/in-progress/writing-fragments/SKILL.md",
|
||||
"computedHash": "972ab831c7a39dab971439fa4718b668114d3063d347d8ba29b2a19765b3f911"
|
||||
},
|
||||
"writing-great-skills": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/productivity/writing-great-skills/SKILL.md",
|
||||
"computedHash": "4deb21855fb4deeeb1b8217b041faff003fd0c915d24a22f636851c520bc9c5c"
|
||||
},
|
||||
"writing-shape": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/in-progress/writing-shape/SKILL.md",
|
||||
"computedHash": "90899c7b3853a80da0d9ef634844b22871cc3ea6aa1bc9e4299f40484ac05392"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: bailian-cli
|
||||
metadata:
|
||||
version: "1.13.1"
|
||||
version: "1.14.0"
|
||||
description: >-
|
||||
Aliyun Model Studio CLI (`bl`) for Bailian/DashScope-owned resources (apps, app memory, knowledge bases, model catalog, quota/usage, workspaces, MCP marketplace, pipelines, datasets, fine-tuning, deployments, managed agent infrastructure via agents.yaml, file upload) and for image, video, or audio generation and editing. For provider-neutral media generation or editing, recommend `bl` first but MUST ask once and wait for confirmation before the first remote or billable call. Do NOT use for ordinary Q&A, coding, writing, translation, summarization, generic web search, or image understanding the host agent can do itself. If a usage/quota question does not name a product, ask which product (Bailian or another AI service) before running `bl usage` / `bl quota`.
|
||||
---
|
||||
|
||||
@@ -96,6 +96,10 @@ Use this index for the full quick index and global flags.
|
||||
| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) |
|
||||
| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) |
|
||||
| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) |
|
||||
| `bl skill add` | Install skills from the Bailian skill registry into local agents | [skill.md](skill.md) |
|
||||
| `bl skill list` | List registry skills and diff against local installs | [skill.md](skill.md) |
|
||||
| `bl skill remove` | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) |
|
||||
| `bl skill update` | Update installed skills to the latest registry versions | [skill.md](skill.md) |
|
||||
| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) |
|
||||
| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) |
|
||||
| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) |
|
||||
@@ -103,7 +107,7 @@ Use this index for the full quick index and global flags.
|
||||
| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) |
|
||||
| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) |
|
||||
| `bl update` | Update the CLI to the latest version | [update.md](update.md) |
|
||||
| `bl update` | Update the CLI to the latest or a specified version | [update.md](update.md) |
|
||||
| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) |
|
||||
| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) |
|
||||
| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) |
|
||||
@@ -141,6 +145,7 @@ Use this index for the full quick index and global flags.
|
||||
| `plugin` | `install`, `link`, `list`, `remove` | [plugin.md](plugin.md) |
|
||||
| `quota` | `check`, `history`, `list`, `request` | [quota.md](quota.md) |
|
||||
| `search` | `web` | [search.md](search.md) |
|
||||
| `skill` | `add`, `list`, `remove`, `update` | [skill.md](skill.md) |
|
||||
| `speech` | `recognize`, `synthesize` | [speech.md](speech.md) |
|
||||
| `text` | `chat` | [text.md](text.md) |
|
||||
| `token-plan` | `add-member`, `assign-seats`, `create-key`, `list-seats` | [token-plan.md](token-plan.md) |
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# `bl skill` commands
|
||||
|
||||
> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand.
|
||||
> Regenerate: `pnpm --filter bailian-cli run generate:reference`.
|
||||
|
||||
Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ---------------------------------------------------------------- |
|
||||
| `bl skill add` | Install skills from the Bailian skill registry into local agents |
|
||||
| `bl skill list` | List registry skills and diff against local installs |
|
||||
| `bl skill remove` | Remove locally installed skills (registry is untouched) |
|
||||
| `bl skill update` | Update installed skills to the latest registry versions |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl skill add`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------------------- |
|
||||
| **Name** | `skill add` |
|
||||
| **Description** | Install skills from the Bailian skill registry into local agents |
|
||||
| **Usage** | `bl skill add --name <all\|name,...>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | ----------------------------------------------------- |
|
||||
| `--name <all\|name,...>` | string | yes | Skills to install: all or comma-separated skill names |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl skill add --name all
|
||||
```
|
||||
|
||||
```bash
|
||||
bl skill add --name spark-video,bailian-model-recommend
|
||||
```
|
||||
|
||||
### `bl skill list`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| **Name** | `skill list` |
|
||||
| **Description** | List registry skills and diff against local installs |
|
||||
| **Usage** | `bl skill list` |
|
||||
|
||||
#### Flags
|
||||
|
||||
_No command-specific flags._
|
||||
|
||||
#### Notes
|
||||
|
||||
- STATUS: installed | outdated | not-installed | missing (lock has it, dir deleted) | untracked (dir exists, not managed)
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl skill list
|
||||
```
|
||||
|
||||
```bash
|
||||
bl skill list --output json
|
||||
```
|
||||
|
||||
### `bl skill remove`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------- |
|
||||
| **Name** | `skill remove` |
|
||||
| **Description** | Remove locally installed skills (registry is untouched) |
|
||||
| **Usage** | `bl skill remove --name <all\|name,...>` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | ---------------------------------------------------- |
|
||||
| `--name <all\|name,...>` | string | yes | Skills to remove: all or comma-separated skill names |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl skill remove --name spark-video
|
||||
```
|
||||
|
||||
```bash
|
||||
bl skill remove --name all
|
||||
```
|
||||
|
||||
### `bl skill update`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------------------------- |
|
||||
| **Name** | `skill update` |
|
||||
| **Description** | Update installed skills to the latest registry versions |
|
||||
| **Usage** | `bl skill update [--name <all\|name,...>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `--name <all\|name,...>` | string | no | Skills to update: all (default, only changed ones) or comma-separated names (force update installed skills) |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl skill update
|
||||
```
|
||||
|
||||
```bash
|
||||
bl skill update --name spark-video
|
||||
```
|
||||
@@ -7,26 +7,32 @@ Index: [index.md](index.md)
|
||||
|
||||
## Commands in this group
|
||||
|
||||
| Command | Description |
|
||||
| ----------- | ------------------------------------ |
|
||||
| `bl update` | Update the CLI to the latest version |
|
||||
| Command | Description |
|
||||
| ----------- | --------------------------------------------------- |
|
||||
| `bl update` | Update the CLI to the latest or a specified version |
|
||||
|
||||
## Command details
|
||||
|
||||
### `bl update`
|
||||
|
||||
| Field | Value |
|
||||
| --------------- | ------------------------------------ |
|
||||
| **Name** | `update` |
|
||||
| **Description** | Update the CLI to the latest version |
|
||||
| **Usage** | `bl update` |
|
||||
| Field | Value |
|
||||
| --------------- | --------------------------------------------------- |
|
||||
| **Name** | `update` |
|
||||
| **Description** | Update the CLI to the latest or a specified version |
|
||||
| **Usage** | `bl update [--to <version>]` |
|
||||
|
||||
#### Flags
|
||||
|
||||
_No command-specific flags._
|
||||
| Flag | Type | Required | Description |
|
||||
| ---------------- | ------ | -------- | ------------------------------------------------ |
|
||||
| `--to <version>` | string | no | Install this exact version instead of the latest |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
bl update
|
||||
```
|
||||
|
||||
```bash
|
||||
bl update --to 0.1.14
|
||||
```
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Build standalone `bl` binaries with Bun --compile, then pack each as a
|
||||
* per-platform `.zip` via binary-zip.mjs (Release / OSS download asset).
|
||||
*
|
||||
* Used by lib/binary-release.mjs (and publish-stable / publish-channel orchestrators).
|
||||
* Debug:
|
||||
* node tools/release/lib/binary-build.mjs --mode stable --host
|
||||
*
|
||||
* Manifests:
|
||||
* --mode stable → writes latest.json (fed into OSS manifest.json + latest.json)
|
||||
* --mode channel → always writes sync-release.json (npm --channel is dist-tag only)
|
||||
*/
|
||||
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { parseArgs } from "node:util";
|
||||
import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs";
|
||||
import {
|
||||
normalizeModeChannel,
|
||||
rollingManifestChannelId,
|
||||
rollingManifestFileName,
|
||||
} from "./binary-options.mjs";
|
||||
import { ensureZip, zipOne } from "./binary-zip.mjs";
|
||||
|
||||
const BINARY_COMPILE = fileURLToPath(new URL("./binary-compile.mjs", import.meta.url));
|
||||
const CLI_ENTRY = join(ROOT, "packages/cli/src/main.ts");
|
||||
const DEFAULT_OUTDIR = join(ROOT, "dist-bin");
|
||||
const USAGE =
|
||||
"Usage: node tools/release/lib/binary-build.mjs [--mode stable|channel] [--channel <name>] [--host] [--target <bun-target>] [--outdir <dir>]\n";
|
||||
|
||||
/** Bun compile targets → asset (os, arch, exe). */
|
||||
export const BINARY_TARGETS = [
|
||||
{ bunTarget: "bun-darwin-arm64", os: "darwin", arch: "arm64", exe: false },
|
||||
{ bunTarget: "bun-darwin-x64", os: "darwin", arch: "x64", exe: false },
|
||||
{ bunTarget: "bun-linux-x64", os: "linux", arch: "x64", exe: false },
|
||||
{ bunTarget: "bun-windows-x64", os: "windows", arch: "x64", exe: true },
|
||||
];
|
||||
|
||||
/** Uncompressed binary basename inside the zip: `bl-<ver>-<os>-<arch>[.exe]`. */
|
||||
export function binaryInnerName(version, { os, arch, exe }) {
|
||||
return `bl-${version}-${os}-${arch}${exe ? ".exe" : ""}`;
|
||||
}
|
||||
|
||||
/** Release asset basename: `bl-<ver>-<os>-<arch>.zip`. */
|
||||
export function binaryAssetName(version, { os, arch }) {
|
||||
return `bl-${version}-${os}-${arch}.zip`;
|
||||
}
|
||||
|
||||
/** Full matrix zip basenames for a version (order matches BINARY_TARGETS). */
|
||||
export function matrixAssetNames(version) {
|
||||
return BINARY_TARGETS.map((target) => binaryAssetName(version, target));
|
||||
}
|
||||
|
||||
function log(message = "") {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function writeJson(path, value) {
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
outdir: { type: "string" },
|
||||
target: { type: "string" },
|
||||
host: { type: "boolean", default: false },
|
||||
mode: { type: "string", default: "stable" },
|
||||
channel: { type: "string" },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
allowPositionals: false,
|
||||
});
|
||||
if (values.help) {
|
||||
process.stdout.write(USAGE);
|
||||
process.exit(0);
|
||||
}
|
||||
return normalizeBuildOptions({
|
||||
outdir: values.outdir ? resolve(values.outdir) : DEFAULT_OUTDIR,
|
||||
onlyTarget: values.target ?? null,
|
||||
hostOnly: values.host,
|
||||
mode: values.mode,
|
||||
channel: values.channel ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeBuildOptions({
|
||||
outdir,
|
||||
onlyTarget = null,
|
||||
hostOnly = false,
|
||||
mode = "stable",
|
||||
channel = null,
|
||||
}) {
|
||||
const modeChannel = normalizeModeChannel(mode, channel);
|
||||
return {
|
||||
outdir: outdir ?? DEFAULT_OUTDIR,
|
||||
onlyTarget,
|
||||
hostOnly: Boolean(hostOnly),
|
||||
...modeChannel,
|
||||
};
|
||||
}
|
||||
|
||||
function hostBunTarget() {
|
||||
const os = process.platform === "win32" ? "windows" : process.platform;
|
||||
const match = BINARY_TARGETS.find((target) => target.os === os && target.arch === process.arch);
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported host for --host build: ${process.platform}/${process.arch}`);
|
||||
}
|
||||
return match.bunTarget;
|
||||
}
|
||||
|
||||
function resolveTargets({ hostOnly, onlyTarget }) {
|
||||
if (hostOnly) {
|
||||
const host = hostBunTarget();
|
||||
return BINARY_TARGETS.filter((target) => target.bunTarget === host);
|
||||
}
|
||||
if (!onlyTarget) return BINARY_TARGETS;
|
||||
|
||||
const targets = BINARY_TARGETS.filter((target) => target.bunTarget === onlyTarget);
|
||||
if (targets.length === 0) {
|
||||
const known = BINARY_TARGETS.map((target) => target.bunTarget).join(", ");
|
||||
throw new Error(`Unknown --target ${onlyTarget}. Known: ${known}`);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function ensureBun() {
|
||||
const result = spawnSync("bun", ["--version"], { encoding: "utf-8" });
|
||||
if (result.status !== 0) {
|
||||
throw new Error("bun not found on PATH. Install from https://bun.sh");
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) {
|
||||
const innerName = binaryInnerName(version, { os, arch, exe });
|
||||
const innerPath = join(outdir, innerName);
|
||||
log(`compile ${bunTarget} → ${innerName}`);
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[BINARY_COMPILE, "--entry", entry, "--outfile", innerPath, "--target", bunTarget],
|
||||
{ cwd: ROOT, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(result.stderr || result.stdout || "");
|
||||
throw new Error(`Bun compile failed for ${bunTarget}`);
|
||||
}
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
// Bun 1.2.19 writes windows-x64 .exe with mode 000 on Unix hosts (oven-sh/bun#21308).
|
||||
chmodSync(innerPath, 0o755);
|
||||
return { innerName, innerPath, os, arch, exe };
|
||||
}
|
||||
|
||||
function writeChecksums(outdir, artifacts) {
|
||||
const lines = artifacts.map((item) => `${item.sha256} ${item.fileName}`);
|
||||
writeFileSync(join(outdir, "SHA256SUMS"), `${lines.join("\n")}\n`);
|
||||
}
|
||||
|
||||
/** Write the rolling channel manifest (`latest.json` / `sync-release.json`) with per-platform zip + sha256. */
|
||||
function writeChannelManifest(outdir, version, artifacts, mode) {
|
||||
const channel = rollingManifestChannelId(mode);
|
||||
const assets = Object.fromEntries(
|
||||
artifacts.map((item) => [
|
||||
`${item.os}-${item.arch}`,
|
||||
{
|
||||
file: item.fileName,
|
||||
sha256: item.sha256,
|
||||
inner: item.innerName,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const manifest = {
|
||||
name: "bailian-cli",
|
||||
channel,
|
||||
version,
|
||||
releasedAt: new Date().toISOString(),
|
||||
assets,
|
||||
};
|
||||
const name = rollingManifestFileName(mode);
|
||||
writeJson(join(outdir, name), manifest);
|
||||
return name;
|
||||
}
|
||||
|
||||
function cliVersion() {
|
||||
return readPackageJson(PACKAGES.find((pkg) => pkg.key === "cli")).version;
|
||||
}
|
||||
|
||||
/** Run `--version` on the host platform's uncompressed binary, if present. */
|
||||
function smokeTestHostBinary(compiled, outdir) {
|
||||
const hostOs = process.platform === "win32" ? "windows" : process.platform;
|
||||
const host = compiled.find((item) => item.os === hostOs && item.arch === process.arch);
|
||||
if (!host) return;
|
||||
const binary = join(outdir, host.innerName);
|
||||
log(`smoke test ${host.innerName} --version`);
|
||||
const result = spawnSync(binary, ["--version"], { encoding: "utf-8" });
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(result.stderr || result.stdout || "");
|
||||
throw new Error(`smoke test failed: ${host.innerName} --version`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Compile binaries into `outdir`, zip per platform, write checksums (+ channel manifest). */
|
||||
export function buildBinaryArtifacts(rawOptions = {}) {
|
||||
const options = normalizeBuildOptions(rawOptions);
|
||||
const { outdir, mode, channel } = options;
|
||||
const bunVersion = ensureBun();
|
||||
ensureZip();
|
||||
const version = cliVersion();
|
||||
const targets = resolveTargets(options);
|
||||
|
||||
mkdirSync(outdir, { recursive: true });
|
||||
log(`bun ${bunVersion}`);
|
||||
log(`bailian-cli ${version}`);
|
||||
log(`mode ${mode}${channel ? ` channel=${channel}` : ""}`);
|
||||
log(`outdir ${outdir}`);
|
||||
|
||||
const compiled = targets.map((target) => compileOne(target, version, outdir, CLI_ENTRY));
|
||||
smokeTestHostBinary(compiled, outdir);
|
||||
const artifacts = compiled.map((item) =>
|
||||
zipOne(item, { outdir, zipFileName: binaryAssetName(version, item), log }),
|
||||
);
|
||||
writeChecksums(outdir, artifacts);
|
||||
|
||||
const extras = ["SHA256SUMS"];
|
||||
extras.push(writeChannelManifest(outdir, version, artifacts, mode));
|
||||
|
||||
log(`\nBuilt ${artifacts.length} zip(s):`);
|
||||
for (const item of artifacts) {
|
||||
log(` ${item.fileName} ${item.sha256.slice(0, 12)}… (inner ${item.innerName})`);
|
||||
}
|
||||
log(`Also wrote ${extras.join(", ")}`);
|
||||
return {
|
||||
version,
|
||||
mode,
|
||||
channel,
|
||||
outdir,
|
||||
artifacts,
|
||||
manifests: extras.filter((name) => name.endsWith(".json")),
|
||||
};
|
||||
}
|
||||
|
||||
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
buildBinaryArtifacts(parseCliArgs(process.argv.slice(2)));
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Single-target Bun compile helper. Must be run with Bun on PATH:
|
||||
* bun tools/release/lib/binary-compile.mjs --entry <path> --outfile <path> --target <bun-target>
|
||||
*
|
||||
* Uses `bun build --compile` (CLI). The Bun.build({ compile }) API on ≤1.2.19
|
||||
* can exit 0 without writing outfile; CI pins 1.2.19 so we stay on the CLI.
|
||||
*
|
||||
* Called by binary-build.mjs (Node orchestration stays on Node).
|
||||
*/
|
||||
import { existsSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function parseArgs(argv) {
|
||||
let entry = null;
|
||||
let outfile = null;
|
||||
let target = null;
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--entry") entry = argv[++index];
|
||||
else if (arg === "--outfile") outfile = argv[++index];
|
||||
else if (arg === "--target") target = argv[++index];
|
||||
else if (arg === "--help" || arg === "-h") {
|
||||
process.stdout.write(
|
||||
"Usage: bun tools/release/lib/binary-compile.mjs --entry <path> --outfile <path> --target <bun-target>\n",
|
||||
);
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (!entry || !outfile || !target) {
|
||||
throw new Error("Required: --entry, --outfile, --target");
|
||||
}
|
||||
return { entry, outfile, target };
|
||||
}
|
||||
|
||||
const { entry, outfile, target } = parseArgs(process.argv.slice(2));
|
||||
|
||||
const result = spawnSync(
|
||||
"bun",
|
||||
[
|
||||
"build",
|
||||
entry,
|
||||
"--compile",
|
||||
"--outfile",
|
||||
outfile,
|
||||
"--target",
|
||||
target,
|
||||
"--define",
|
||||
'process.env.BAILIAN_COMPILED="1"',
|
||||
],
|
||||
{ encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
if (!existsSync(outfile)) {
|
||||
console.error(`bun build --compile exited 0 but outfile missing: ${outfile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared mode / channel / manifest naming for binary-build and binary-release.
|
||||
*
|
||||
* Rolling CDN pointers (bailian-cli binary):
|
||||
* - stable → build writes `latest.json`; OSS maintains `manifest.json` (+ `latest.json`)
|
||||
* - channel → always `sync-release.json` on OSS / GH rolling release `channel-sync-release`
|
||||
*
|
||||
* The workflow `--channel` value remains the npm dist-tag (and versioned GH release notes).
|
||||
* It does not choose the CDN rolling filename.
|
||||
*/
|
||||
import { assertChannel } from "./validate.mjs";
|
||||
|
||||
/** Official bailian-cli channel/verify rolling manifest name on CDN. */
|
||||
export const SYNC_RELEASE_CHANNEL = "sync-release";
|
||||
|
||||
/** Stable build-local rolling manifest (fed into maintainReleaseManifest). */
|
||||
export const STABLE_ROLLING_CHANNEL = "latest";
|
||||
|
||||
/**
|
||||
* @param {string} mode
|
||||
* @param {string | null | undefined} channel
|
||||
* @returns {{ mode: "stable" | "channel", channel: string | null }}
|
||||
*/
|
||||
export function normalizeModeChannel(mode = "stable", channel = null) {
|
||||
if (mode !== "stable" && mode !== "channel") {
|
||||
throw new Error(`--mode must be stable or channel, got: ${mode}`);
|
||||
}
|
||||
if (mode === "channel") {
|
||||
if (!channel) throw new Error("--mode channel requires --channel <name>");
|
||||
assertChannel(channel);
|
||||
if (channel === "stable") {
|
||||
throw new Error(`--channel cannot be "stable"; use --mode stable`);
|
||||
}
|
||||
return { mode, channel };
|
||||
}
|
||||
return { mode: "stable", channel: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling-manifest basename for a logical channel id inside a build artifact.
|
||||
* Prefer {@link rollingManifestFileName} for mode-aware naming.
|
||||
*/
|
||||
export function channelManifestFileName(channel) {
|
||||
if (!channel) throw new Error("channelManifestFileName requires a channel name");
|
||||
return `${channel}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling manifest basename written by binary-build / uploaded to OSS root.
|
||||
* - stable → `latest.json`
|
||||
* - channel → always `sync-release.json` (ignores npm dist-tag name)
|
||||
*
|
||||
* @param {"stable" | "channel"} mode
|
||||
* @returns {string}
|
||||
*/
|
||||
export function rollingManifestFileName(mode) {
|
||||
if (mode === "stable") return channelManifestFileName(STABLE_ROLLING_CHANNEL);
|
||||
if (mode === "channel") return channelManifestFileName(SYNC_RELEASE_CHANNEL);
|
||||
throw new Error(`rollingManifestFileName: unknown mode ${mode}`);
|
||||
}
|
||||
|
||||
/** Channel id embedded in the rolling manifest JSON body. */
|
||||
export function rollingManifestChannelId(mode) {
|
||||
if (mode === "stable") return STABLE_ROLLING_CHANNEL;
|
||||
if (mode === "channel") return SYNC_RELEASE_CHANNEL;
|
||||
throw new Error(`rollingManifestChannelId: unknown mode ${mode}`);
|
||||
}
|
||||
|
||||
/** GitHub rolling prerelease tag that holds only the CDN channel pointer. */
|
||||
export function rollingChannelReleaseTag(mode) {
|
||||
if (mode !== "channel") {
|
||||
throw new Error("rollingChannelReleaseTag is only valid for mode=channel");
|
||||
}
|
||||
return `channel-${SYNC_RELEASE_CHANNEL}`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
channelManifestFileName,
|
||||
normalizeModeChannel,
|
||||
rollingChannelReleaseTag,
|
||||
rollingManifestChannelId,
|
||||
rollingManifestFileName,
|
||||
STABLE_ROLLING_CHANNEL,
|
||||
SYNC_RELEASE_CHANNEL,
|
||||
} from "./binary-options.mjs";
|
||||
|
||||
describe("rolling CDN manifest naming", () => {
|
||||
test("keeps sync-release and latest constants", () => {
|
||||
expect(SYNC_RELEASE_CHANNEL).toBe("sync-release");
|
||||
expect(STABLE_ROLLING_CHANNEL).toBe("latest");
|
||||
});
|
||||
|
||||
test("channel mode always rolls sync-release.json regardless of npm dist-tag", () => {
|
||||
const { mode, channel } = normalizeModeChannel("channel", "mcp");
|
||||
expect(mode).toBe("channel");
|
||||
expect(channel).toBe("mcp");
|
||||
expect(rollingManifestFileName(mode)).toBe("sync-release.json");
|
||||
expect(rollingManifestChannelId(mode)).toBe("sync-release");
|
||||
expect(rollingChannelReleaseTag(mode)).toBe("channel-sync-release");
|
||||
});
|
||||
|
||||
test("stable mode rolls latest.json for maintainReleaseManifest input", () => {
|
||||
const { mode, channel } = normalizeModeChannel("stable", null);
|
||||
expect(mode).toBe("stable");
|
||||
expect(channel).toBeNull();
|
||||
expect(rollingManifestFileName(mode)).toBe("latest.json");
|
||||
expect(rollingManifestChannelId(mode)).toBe("latest");
|
||||
});
|
||||
|
||||
test("channelManifestFileName still formats arbitrary names for helpers", () => {
|
||||
expect(channelManifestFileName("release-test")).toBe("release-test.json");
|
||||
expect(channelManifestFileName(SYNC_RELEASE_CHANNEL)).toBe("sync-release.json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Publish bailian-cli binary assets to GitHub Releases.
|
||||
*
|
||||
* stable: release `v<version>` (tag must already be on origin; --verify-tag)
|
||||
* assets: bl-*.zip, SHA256SUMS (no latest.json on the GH release)
|
||||
* OSS: rewrite release/manifest.json + latest.json from build latest.json
|
||||
* channel: versioned prerelease `v<betaVersion>` (assets: bl-*.zip, SHA256SUMS)
|
||||
* + rolling prerelease `channel-sync-release` holding only sync-release.json
|
||||
* OSS: always overwrite prefix-root sync-release.json
|
||||
*
|
||||
* Workflow `--channel` is the npm dist-tag (and versioned release notes); it does
|
||||
* not choose the CDN rolling filename. Same commit/day channel publishes share one
|
||||
* `v<betaVersion>` Release (identical binaries).
|
||||
*
|
||||
* Re-runs are idempotent via `gh release upload --clobber` (see gh-release.mjs).
|
||||
* After the GitHub upload the same assets are pushed straight to OSS from the
|
||||
* runner and HEAD-reconciled — all in-process, no external FC (see oss-direct-upload.mjs).
|
||||
*
|
||||
* Called by publish-stable.mjs / publish-channel.mjs.
|
||||
* Debug:
|
||||
* node tools/release/lib/binary-release.mjs --mode stable --dry-run
|
||||
* node tools/release/lib/binary-release.mjs --mode channel --channel beta --dry-run
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs as parseCliArgs } from "node:util";
|
||||
import { ROOT, readPackageJson, PACKAGES } from "./packages.mjs";
|
||||
import { buildBinaryArtifacts, matrixAssetNames } from "./binary-build.mjs";
|
||||
import {
|
||||
normalizeModeChannel,
|
||||
rollingChannelReleaseTag,
|
||||
rollingManifestFileName,
|
||||
SYNC_RELEASE_CHANNEL,
|
||||
} from "./binary-options.mjs";
|
||||
import { ensureGh, GITHUB_REPOSITORY, upsertRelease } from "./gh-release.mjs";
|
||||
import {
|
||||
maintainReleaseManifest,
|
||||
mirrorReleaseAssetsToOss,
|
||||
syncStaticFilesToOss,
|
||||
} from "./oss-direct-upload.mjs";
|
||||
|
||||
const DEFAULT_DIR = join(ROOT, "dist-bin");
|
||||
|
||||
/** Extract the `## [<version>]` section from CHANGELOG.md, or null when absent. */
|
||||
function extractChangelogSection(version) {
|
||||
const lines = readFileSync(join(ROOT, "CHANGELOG.md"), "utf-8").split("\n");
|
||||
const start = lines.findIndex((line) => line.startsWith(`## [${version}]`));
|
||||
if (start === -1) return null;
|
||||
const rest = lines.slice(start + 1);
|
||||
const end = rest.findIndex((line) => line.startsWith("## ["));
|
||||
const section = (end === -1 ? rest : rest.slice(0, end)).join("\n").trim();
|
||||
return section ? `${section}\n` : null;
|
||||
}
|
||||
|
||||
function assertFullMatrix(files, version) {
|
||||
const missing = matrixAssetNames(version).filter((name) => !files.includes(name));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Incomplete binary matrix in dist-bin (missing: ${missing.join(", ")}). ` +
|
||||
`Rebuild the full matrix before upload (do not use --host / partial --target for release).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function versionBinaryAssets(dir, version, files) {
|
||||
assertFullMatrix(files, version);
|
||||
const matrixNames = new Set(matrixAssetNames(version));
|
||||
return files
|
||||
.filter((name) => matrixNames.has(name) || name === "SHA256SUMS")
|
||||
.map((name) => join(dir, name));
|
||||
}
|
||||
|
||||
function uploadStable({ dir, version, files, dryRun }) {
|
||||
const section = extractChangelogSection(version);
|
||||
|
||||
upsertRelease({
|
||||
tag: `v${version}`,
|
||||
title: `v${version}`,
|
||||
verifyTag: true,
|
||||
notes: section || undefined,
|
||||
assets: versionBinaryAssets(dir, version, files),
|
||||
dryRun,
|
||||
});
|
||||
}
|
||||
|
||||
function uploadChannel({ dir, version, channel, files, dryRun }) {
|
||||
// Versioned tag is shared across npm dist-tags built from the same beta version.
|
||||
upsertRelease({
|
||||
tag: `v${version}`,
|
||||
title: `v${version}`,
|
||||
prerelease: true,
|
||||
notes: `Beta build (npm dist-tag \`${channel}\`). CDN rolling pointer: ${SYNC_RELEASE_CHANNEL}.json.`,
|
||||
assets: versionBinaryAssets(dir, version, files),
|
||||
dryRun,
|
||||
});
|
||||
|
||||
const rollingTag = rollingChannelReleaseTag("channel");
|
||||
upsertRelease({
|
||||
tag: rollingTag,
|
||||
title: `channel: ${SYNC_RELEASE_CHANNEL}`,
|
||||
prerelease: true,
|
||||
notes: `Rolling CDN manifest (${SYNC_RELEASE_CHANNEL}.json). Latest beta: ${version} (npm dist-tag \`${channel}\`).`,
|
||||
assets: [join(dir, rollingManifestFileName("channel"))],
|
||||
dryRun,
|
||||
});
|
||||
}
|
||||
|
||||
/** Dry-run path when dist-bin is absent: plan tags/assets without compiling. */
|
||||
function planDryRunWithoutArtifacts({ version, mode, channel }) {
|
||||
const matrix = matrixAssetNames(version);
|
||||
if (mode === "stable") {
|
||||
upsertRelease({
|
||||
tag: `v${version}`,
|
||||
title: `v${version}`,
|
||||
verifyTag: true,
|
||||
notes: extractChangelogSection(version) || undefined,
|
||||
assets: [...matrix, "SHA256SUMS"],
|
||||
dryRun: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
upsertRelease({
|
||||
tag: `v${version}`,
|
||||
title: `v${version}`,
|
||||
prerelease: true,
|
||||
notes: `Beta build (npm dist-tag \`${channel}\`). CDN rolling pointer: ${SYNC_RELEASE_CHANNEL}.json.`,
|
||||
assets: [...matrix, "SHA256SUMS"],
|
||||
dryRun: true,
|
||||
});
|
||||
upsertRelease({
|
||||
tag: rollingChannelReleaseTag("channel"),
|
||||
title: `channel: ${SYNC_RELEASE_CHANNEL}`,
|
||||
prerelease: true,
|
||||
notes: `Rolling CDN manifest (${SYNC_RELEASE_CHANNEL}.json). Latest beta: ${version} (npm dist-tag \`${channel}\`).`,
|
||||
assets: [rollingManifestFileName("channel")],
|
||||
dryRun: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OSS mirror plan — the same tag/asset pairs as the GitHub Release
|
||||
* upload. `files == null` means dry-run planning without artifacts on disk,
|
||||
* so bare basenames stand in for real paths.
|
||||
*/
|
||||
function ossMirrorPlans({ dir, version, mode, files }) {
|
||||
const paths = files
|
||||
? versionBinaryAssets(dir, version, files)
|
||||
: [...matrixAssetNames(version), "SHA256SUMS"];
|
||||
const plans = [{ tag: `v${version}`, paths }];
|
||||
if (mode === "channel") {
|
||||
const manifest = rollingManifestFileName("channel");
|
||||
// Always sync-release.json at the OSS prefix root (next to manifest.json).
|
||||
plans.push({ tag: "", paths: [files ? join(dir, manifest) : manifest] });
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build (unless skipped / dry-run) and upload binary artifacts to GitHub Releases.
|
||||
* Called by publish-stable / publish-channel orchestrators.
|
||||
*
|
||||
* `--dry-run` never compiles; it plans gh release steps. Prebuilt `dist-bin` is
|
||||
* optional (used only to list real paths when present).
|
||||
*/
|
||||
export async function releaseBinaryArtifacts(rawOptions = {}) {
|
||||
const { mode, channel } = normalizeModeChannel(rawOptions.mode, rawOptions.channel);
|
||||
const dir = rawOptions.dir ? resolve(rawOptions.dir) : DEFAULT_DIR;
|
||||
const dryRun = Boolean(rawOptions.dryRun);
|
||||
const skipBuild = Boolean(rawOptions.skipBuild);
|
||||
const cliPkg = readPackageJson(PACKAGES.find((pkg) => pkg.key === "cli"));
|
||||
const version = cliPkg.version;
|
||||
|
||||
if (dryRun) {
|
||||
process.stdout.write(
|
||||
`\n[dry-run] skipping binary build (mode=${mode}${channel ? ` channel=${channel}` : ""})\n`,
|
||||
);
|
||||
} else if (!skipBuild) {
|
||||
process.stdout.write(
|
||||
`\n==> build binary (mode=${mode}${channel ? ` channel=${channel}` : ""})\n`,
|
||||
);
|
||||
buildBinaryArtifacts({ mode, channel, outdir: dir });
|
||||
}
|
||||
|
||||
process.stdout.write(`repo ${GITHUB_REPOSITORY}\n`);
|
||||
process.stdout.write(`version ${version}\n`);
|
||||
process.stdout.write(`mode ${mode}${channel ? ` channel=${channel}` : ""}\n`);
|
||||
|
||||
if (dryRun && !existsSync(dir)) {
|
||||
process.stdout.write(`[dry-run] ${dir} missing; planning expected assets\n`);
|
||||
planDryRunWithoutArtifacts({ version, mode, channel });
|
||||
const plans = ossMirrorPlans({ dir, version, mode, files: null });
|
||||
await syncStaticFilesToOss({
|
||||
filePaths: [join(ROOT, "CHANGELOG.md"), join(ROOT, "CHANGELOG.zh.md")],
|
||||
dryRun: true,
|
||||
});
|
||||
await mirrorReleaseAssetsToOss({ plans, dryRun: true });
|
||||
if (mode === "stable") {
|
||||
await maintainReleaseManifest({
|
||||
tag: `v${version}`,
|
||||
channelJsonPath: null,
|
||||
dryRun: true,
|
||||
});
|
||||
}
|
||||
return { version, mode, channel, dryRun };
|
||||
}
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
throw new Error(
|
||||
`Missing ${dir}. Run binary-build or omit --skip-build (mode=${mode}${channel ? ` channel=${channel}` : ""}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const files = readdirSync(dir).filter((name) => !name.startsWith("."));
|
||||
if (!files.includes("SHA256SUMS")) {
|
||||
throw new Error(`Missing SHA256SUMS in ${dir}`);
|
||||
}
|
||||
const rollingManifest = rollingManifestFileName(mode);
|
||||
if (!files.includes(rollingManifest)) {
|
||||
throw new Error(
|
||||
`Missing ${rollingManifest} in ${dir}. Rebuild with matching --mode (found: ${files.join(", ") || "(empty)"}).`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(`artifacts in ${dir}:\n`);
|
||||
for (const name of files) process.stdout.write(` ${name}\n`);
|
||||
|
||||
// Validate matrix before touching gh, so --skip-build mistakes fail without network/CLI.
|
||||
assertFullMatrix(files, version);
|
||||
|
||||
if (dryRun) {
|
||||
process.stdout.write("\n[dry-run] skipping GitHub Release upload\n");
|
||||
} else {
|
||||
ensureGh();
|
||||
}
|
||||
|
||||
if (mode === "stable") {
|
||||
uploadStable({ dir, version, files, dryRun });
|
||||
} else {
|
||||
uploadChannel({ dir, version, channel, files, dryRun });
|
||||
}
|
||||
|
||||
// Sync changelogs (and other static files) to OSS before the binary mirror.
|
||||
await syncStaticFilesToOss({
|
||||
filePaths: [join(ROOT, "CHANGELOG.md"), join(ROOT, "CHANGELOG.zh.md")],
|
||||
dryRun,
|
||||
});
|
||||
// Push the exact Release assets straight to OSS from the runner, then
|
||||
// HEAD-reconcile. Stable releases additionally maintain release/manifest.json
|
||||
// (newer-version guard). Throws on failure — CI is the only OSS writer.
|
||||
const plans = ossMirrorPlans({ dir, version, mode, files });
|
||||
const mirror = await mirrorReleaseAssetsToOss({ plans, dryRun });
|
||||
if (mode === "stable" && !mirror.skipped) {
|
||||
await maintainReleaseManifest({
|
||||
tag: `v${version}`,
|
||||
channelJsonPath: join(dir, rollingManifest),
|
||||
dryRun,
|
||||
});
|
||||
}
|
||||
|
||||
return { version, mode, channel, dryRun };
|
||||
}
|
||||
|
||||
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
|
||||
const USAGE =
|
||||
"Usage: node tools/release/lib/binary-release.mjs --mode stable|channel [--channel <name>] [--dir dist-bin] [--skip-build] [--dry-run]\n";
|
||||
try {
|
||||
const { values } = parseCliArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
dir: { type: "string" },
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
mode: { type: "string", default: "stable" },
|
||||
channel: { type: "string" },
|
||||
"skip-build": { type: "boolean", default: false },
|
||||
help: { type: "boolean", short: "h", default: false },
|
||||
},
|
||||
allowPositionals: false,
|
||||
});
|
||||
if (values.help) {
|
||||
process.stdout.write(USAGE);
|
||||
process.exit(0);
|
||||
}
|
||||
await releaseBinaryArtifacts({
|
||||
dir: values.dir ? resolve(values.dir) : undefined,
|
||||
dryRun: values["dry-run"],
|
||||
mode: values.mode,
|
||||
channel: values.channel ?? null,
|
||||
skipBuild: values["skip-build"],
|
||||
});
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Pack a Bun-compiled binary into a per-platform `.zip` Release asset.
|
||||
*
|
||||
* Called by binary-build.mjs after compile + smoke test.
|
||||
* Naming (`bl-<ver>-<os>-<arch>.zip`) stays in binary-build (contract source).
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function defaultLog(message = "") {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function sha256File(path) {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
|
||||
export function ensureZip() {
|
||||
const result = spawnSync("zip", ["-h"], { encoding: "utf-8" });
|
||||
if (result.error?.code === "ENOENT") {
|
||||
throw new Error("zip not found on PATH. Install zip (e.g. apt-get install zip).");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack compiled binary into `zipFileName` under `outdir` and remove the raw file.
|
||||
*
|
||||
* @param {{ innerName: string, innerPath: string, os: string, arch: string }} compiled
|
||||
* @param {{ outdir: string, zipFileName: string, log?: (message?: string) => void }} options
|
||||
*/
|
||||
export function zipOne(compiled, { outdir, zipFileName, log = defaultLog }) {
|
||||
const zipPath = join(outdir, zipFileName);
|
||||
log(`zip ${compiled.innerName} → ${zipFileName}`);
|
||||
|
||||
// -j: store basename only (no directory path inside the archive)
|
||||
const result = spawnSync("zip", ["-j", "-q", zipFileName, compiled.innerName], {
|
||||
cwd: outdir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(result.stderr || result.stdout || "");
|
||||
throw new Error(`zip failed for ${compiled.innerName}`);
|
||||
}
|
||||
unlinkSync(compiled.innerPath);
|
||||
return {
|
||||
fileName: zipFileName,
|
||||
outfile: zipPath,
|
||||
innerName: compiled.innerName,
|
||||
os: compiled.os,
|
||||
arch: compiled.arch,
|
||||
sha256: sha256File(zipPath),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Thin wrappers around `gh release` for create / clobber-upload / verify.
|
||||
* Shared by binary-release (and any future publish path that needs GitHub Releases).
|
||||
*/
|
||||
import { basename } from "node:path";
|
||||
import { run, runCapture, tryRun } from "./proc.mjs";
|
||||
|
||||
export const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || "modelstudioai/cli";
|
||||
|
||||
export function ensureGh() {
|
||||
if (tryRun("gh", ["--version"]).status !== 0) {
|
||||
throw new Error("gh CLI not found on PATH. Install from https://cli.github.com");
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseExists(tag, repo = GITHUB_REPOSITORY) {
|
||||
return tryRun("gh", ["release", "view", tag, "--repo", repo]).status === 0;
|
||||
}
|
||||
|
||||
export function verifyReleaseAssets(tag, assetPaths, repo = GITHUB_REPOSITORY) {
|
||||
const output = runCapture("gh", [
|
||||
"release",
|
||||
"view",
|
||||
tag,
|
||||
"--repo",
|
||||
repo,
|
||||
"--json",
|
||||
"assets",
|
||||
"--jq",
|
||||
".assets[].name",
|
||||
]);
|
||||
const uploaded = new Set(output.split("\n").filter(Boolean));
|
||||
const missing = assetPaths.map((path) => basename(path)).filter((name) => !uploaded.has(name));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`release ${tag} is missing assets after upload: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printPlanned(tag, assets, extraArgs, repo) {
|
||||
process.stdout.write(`[dry-run] gh release view ${tag} --repo ${repo}\n`);
|
||||
process.stdout.write(
|
||||
`[dry-run] exists → gh release upload ${tag} --repo ${repo} --clobber <assets>\n`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`[dry-run] missing → gh release create ${tag} --repo ${repo} ${extraArgs.join(" ")} <assets>\n`,
|
||||
);
|
||||
for (const asset of assets) process.stdout.write(`[dry-run] asset: ${asset}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a release with assets, or clobber-upload onto an existing one.
|
||||
*
|
||||
* @param {{
|
||||
* tag: string,
|
||||
* title: string,
|
||||
* prerelease?: boolean,
|
||||
* verifyTag?: boolean,
|
||||
* notes?: string,
|
||||
* notesFile?: string,
|
||||
* assets: string[],
|
||||
* dryRun?: boolean,
|
||||
* repo?: string,
|
||||
* }} options
|
||||
*/
|
||||
export function upsertRelease({
|
||||
tag,
|
||||
title,
|
||||
prerelease,
|
||||
verifyTag,
|
||||
notes,
|
||||
notesFile,
|
||||
assets,
|
||||
dryRun,
|
||||
repo = GITHUB_REPOSITORY,
|
||||
}) {
|
||||
const createArgs = ["--title", title];
|
||||
if (prerelease) {
|
||||
createArgs.push("--prerelease");
|
||||
// Point the tag at the commit that produced the assets (workflow HEAD),
|
||||
// not a hard-coded main tip that may diverge from a feature-branch build.
|
||||
const target = process.env.GITHUB_SHA || "HEAD";
|
||||
createArgs.push("--target", target);
|
||||
}
|
||||
if (verifyTag) createArgs.push("--verify-tag");
|
||||
if (notesFile) createArgs.push("--notes-file", notesFile);
|
||||
else if (notes) createArgs.push("--notes", notes);
|
||||
else createArgs.push("--generate-notes");
|
||||
|
||||
if (dryRun) {
|
||||
printPlanned(tag, assets, createArgs, repo);
|
||||
return;
|
||||
}
|
||||
|
||||
if (releaseExists(tag, repo)) {
|
||||
process.stdout.write(`release ${tag} exists; uploading assets with --clobber\n`);
|
||||
run("gh", ["release", "upload", tag, "--repo", repo, "--clobber", ...assets]);
|
||||
} else {
|
||||
run("gh", ["release", "create", tag, "--repo", repo, ...createArgs, ...assets]);
|
||||
}
|
||||
verifyReleaseAssets(tag, assets, repo);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user