mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9012a6330 | |||
| 92ee845bdd | |||
| 678f60be75 | |||
| d11b55b956 | |||
| e4e3f069e1 | |||
| 440cbfe6ae | |||
| 1adfe797bd | |||
| d04012b0a4 | |||
| 81539005cc | |||
| 4c566fd60e | |||
| 853ce3caae | |||
| 3b779a708d | |||
| b4a2a1c42d | |||
| 4ca3e2de80 | |||
| 1f91fa42fa | |||
| 39f12e1a78 | |||
| a853319dd0 | |||
| bc162f4678 | |||
| 12ecac4d96 | |||
| 52f6e267e6 | |||
| c9e5913034 | |||
| ba062c1a71 | |||
| a03ba673be | |||
| 8196f67300 | |||
| 4b504a1a52 | |||
| 00b4d095ed | |||
| 92719a1a21 | |||
| 310e6ead33 | |||
| ece0c8dd1c | |||
| 39f92567d3 | |||
| 8b91b9f35f | |||
| 1fbafa8b1d | |||
| 052960e269 | |||
| a8f45e93af | |||
| 84805f287c | |||
| 196b2a1f51 | |||
| de9f1a3889 | |||
| 75a45e1a2a | |||
| 64ff057fe5 | |||
| 6d588a57b4 | |||
| 84383f1c83 | |||
| fc8351f136 | |||
| e0f3d450ae | |||
| ac4dbb9e88 | |||
| 155c9dc883 | |||
| e1532bf35c | |||
| 3fb0c7211c | |||
| 049eecd991 | |||
| 2907ad2625 | |||
| 0f23527bfc | |||
| 8e3f8586b0 | |||
| efb5243d0d |
@@ -39,7 +39,7 @@ body:
|
||||
attributes:
|
||||
label: Node version
|
||||
description: "Output of node --version"
|
||||
placeholder: "v22.12.0"
|
||||
placeholder: "v18.17.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -56,21 +56,22 @@ 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) |
|
||||
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
|
||||
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
|
||||
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
|
||||
| 场景 | 何时进入 | 详见 |
|
||||
| -------------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) |
|
||||
| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) |
|
||||
| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) |
|
||||
| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) |
|
||||
| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) |
|
||||
| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) |
|
||||
| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) |
|
||||
| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) |
|
||||
| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) |
|
||||
| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) |
|
||||
| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
|
||||
| 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,51 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
|
||||
[中文版](CHANGELOG.zh.md) · [README](README.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Token Plan defaults now use the current text, image, and dedicated text-to-video, image-to-video, and reference-to-video models.
|
||||
- The Bailian CLI Skill now distinguishes Bailian-specific tasks from ordinary host-agent work more accurately and avoids repeated consent prompts within an approved workflow.
|
||||
- Published CLI packages now support Node.js 18.17 and later, lowering the previous minimum requirement from Node.js 22.12.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Token Plan now handles local images correctly for image editing, image-to-video, reference-to-video, and vision understanding without requiring a separately hosted URL.
|
||||
|
||||
## [1.10.0] - 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- **`bl config agent`** — configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope in one command.
|
||||
|
||||
### Changed
|
||||
|
||||
- The Bailian CLI Skill now routes only matching Bailian and multimodal tasks to `bl`, and asks for consent before provider-neutral remote or billable calls.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Full `bl auth logout` now clears the model Base URL so later logins cannot inherit a stale custom or Token Plan endpoint.
|
||||
|
||||
## [1.9.0] - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
- **Token Plan support** — log in and call supported models directly without manually configuring the endpoint.
|
||||
- **Named Config Profiles** — create, switch, and manage isolated configurations; logging in to a named Profile activates it automatically.
|
||||
- **Console Access Token automation** — generate and automatically refresh Console Access Tokens.
|
||||
- **`bl workspace init`** — initialize a Bailian workspace and activate the required services in one workflow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved configuration safety and consistency, including secret masking and preservation of custom configuration fields.
|
||||
|
||||
## [1.8.3] - 2026-07-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `bl text chat --messages-file -` failing on Windows by treating standard input as a `/dev/stdin` file path; piped JSON messages are now read from standard input correctly. (#103)
|
||||
|
||||
## [1.8.2] - 2026-07-15
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -6,6 +6,51 @@
|
||||
|
||||
[English](CHANGELOG.md) · [README](README.zh.md) · [参与贡献](CONTRIBUTING.zh.md)
|
||||
|
||||
## [1.10.1] - 2026-07-22
|
||||
|
||||
### 变更
|
||||
|
||||
- Token Plan 默认模型已更新为当前文本、图片,以及文生视频、图生视频和参考生视频的专用模型。
|
||||
- 百炼 CLI Skill 现在能更准确地区分百炼专属任务与普通宿主 Agent 任务,并避免在已授权的工作流中重复征求同意。
|
||||
- 已发布的 CLI 包现在支持 Node.js 18.17 及以上版本,最低版本要求由 Node.js 22.12 下调至 18.17。
|
||||
|
||||
### 修复
|
||||
|
||||
- Token Plan 现在能在图片编辑、图生视频、参考生视频和视觉理解中正确处理本地图片,无需另行托管为 URL。
|
||||
|
||||
## [1.10.0] - 2026-07-19
|
||||
|
||||
### 新增
|
||||
|
||||
- **`bl config agent`** —— 一键配置 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 和 Codex 接入百炼模型服务。
|
||||
|
||||
### 变更
|
||||
|
||||
- 百炼 CLI Skill 现在只将匹配的百炼任务与多模态任务路由到 `bl`,并会在调用与平台无关的远程或计费能力前征求同意。
|
||||
|
||||
### 修复
|
||||
|
||||
- 完整执行 `bl auth logout` 时会同时清除模型 Base URL,避免后续登录继承失效的自定义或 Token Plan 接入地址。
|
||||
|
||||
## [1.9.0] - 2026-07-17
|
||||
|
||||
### 新增
|
||||
|
||||
- **支持 Token Plan** —— 登录后即可直接调用支持的模型,无需手动配置接入地址。
|
||||
- **命名 Config Profile** —— 支持创建、切换和管理相互隔离的配置,登录后会自动激活当前 Profile。
|
||||
- **Console Access Token 自动化** —— 支持生成并自动刷新 Console Access Token。
|
||||
- **`bl workspace init`** —— 一站式完成百炼工作空间初始化和所需服务开通。
|
||||
|
||||
### 修复
|
||||
|
||||
- 提升配置安全性与一致性,包括密钥脱敏和自定义配置字段保留。
|
||||
|
||||
## [1.8.3] - 2026-07-16
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 Windows 上 `bl text chat --messages-file -` 将标准输入当作 `/dev/stdin` 文件路径读取的问题;通过管道传入的 JSON 消息现在可以从标准输入正常读取。(#103)
|
||||
|
||||
## [1.8.2] - 2026-07-15
|
||||
|
||||
### 变更
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@ Developer guide for `bailian-cli` — the official CLI for Aliyun Model Studio (
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js ≥ 22.12
|
||||
- Node.js ≥ 22.12 (required for developing this repo)
|
||||
- End users installing the published CLI only need Node.js >= 18.17
|
||||
- pnpm 10.33.2 (`npm i -g pnpm@10.33.2`)
|
||||
- A DashScope API key for running e2e tests
|
||||
|
||||
|
||||
+3
-2
@@ -6,8 +6,9 @@
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Node.js ≥ 22.12
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 参与本仓库开发:Node.js ≥ 22.12
|
||||
- 仅安装/使用已发布的 CLI:Node.js >= 18.17
|
||||
- pnpm 10.33.2(`npm i -g pnpm@10.33.2`)
|
||||
- 跑 e2e 需要一个百炼 API Key
|
||||
|
||||
## 仓库结构
|
||||
|
||||
+16
-8
@@ -7,7 +7,7 @@
|
||||
**原则**:先执行检查;若 **Node / 包管理器 / 网络** 任一不满足下文要求,Agent 应 **在本机终端内主动完成安装或配置**(例如升级 Node、切换镜像、配置 `npm` 代理等),再继续 `bailian-cli` 安装。仅在无权限或环境无法自动化时,再向用户说明原因并请其配合。
|
||||
|
||||
1. **Node.js**
|
||||
- 要求 **≥ 22.12.0**,执行 `node -v` 确认。
|
||||
- 要求 **≥ 18.17.0**,执行 `node -v` 确认。
|
||||
- 未安装或版本不足:由 Agent 自行安装或升级 Node。
|
||||
|
||||
2. **npm**(**唯一**允许的包管理器)
|
||||
@@ -69,7 +69,7 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
> 此方式同时打通 `app list`、`usage free` 等控制台能力,并自动配置 API Key 调用所需的鉴权信息。
|
||||
|
||||
### 备选:由 Agent 引导用户输入 API Key 后登录
|
||||
### 备选一:由 Agent 引导用户输入普通 API Key 后登录
|
||||
|
||||
适用于无法拉起浏览器的对话式安装(远程 SSH、CI 调试、纯终端环境等):
|
||||
|
||||
@@ -80,6 +80,15 @@ npx skills add modelstudioai/cli --all -g
|
||||
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`,勿在对话中向用户解释底层命名。
|
||||
@@ -93,16 +102,15 @@ npx skills add modelstudioai/cli --all -g
|
||||
|
||||
---
|
||||
|
||||
## 4. 最小功能验证
|
||||
## 4. 配置验证
|
||||
|
||||
在鉴权配置完成后执行:
|
||||
API Key 登录命令本身已经完成可用性测试,通过后只需确认配置状态:
|
||||
|
||||
```bash
|
||||
bl auth status --output json
|
||||
bl text chat --message "ping" --non-interactive --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`。
|
||||
无需再执行重复的模型调用测试。若登录失败,根据 stderr / JSON 中的 `hint` 或 `message` 排查(网络、Key 无效、`base_url` 等)。DashScope 端点:使用 `--base-url` / `bl config set --key base_url` / `DASHSCOPE_BASE_URL`,默认中国大陆 `https://dashscope.aliyuncs.com`。
|
||||
|
||||
---
|
||||
|
||||
@@ -111,7 +119,7 @@ bl text chat --message "ping" --non-interactive --output json
|
||||
| 现象 | 可能原因 | 建议动作 |
|
||||
| ----------------------- | -------------------- | --------------------------------------------------------------- |
|
||||
| `bl: command not found` | 全局 bin 不在 PATH | 检查 `npm prefix -g` 与 PATH |
|
||||
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 22.12 |
|
||||
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 引导用户更新 Key 并 `bl auth login --api-key` |
|
||||
| 安装报错 engines | Node 版本过低 | 升级到 ≥ 18.17 |
|
||||
| 401 / 鉴权失败 | 未 login 或 Key 无效 | 按 Key 类型重新执行普通或 Token Plan 登录命令 |
|
||||
| 企业网络无法访问 npm | 代理 / 镜像 | 配置 registry 或代理后再装 |
|
||||
| 本机只有 pnpm、没有 npm | Agent 误用 pnpm 安装 | 先装/修好 **npm**,再用 `npm install -g bailian-cli`;勿用 pnpm |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -30,6 +30,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
@@ -80,7 +81,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -91,6 +92,12 @@ bl auth login --console
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
@@ -159,6 +166,15 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
@@ -209,6 +225,7 @@ Config file location: `~/.bailian/config.json`
|
||||
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
+19
-2
@@ -5,7 +5,7 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -30,6 +30,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
@@ -78,7 +79,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -89,6 +90,12 @@ bl auth login --console
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
@@ -157,6 +164,15 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
@@ -207,6 +223,7 @@ bl update
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
@@ -37,13 +37,16 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx
|
||||
- `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段
|
||||
- `bl auth login --open-api ...` 只更新 `access_key_id` / `access_key_secret`
|
||||
- `bl auth logout --console` 只清 `access_token`
|
||||
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret`
|
||||
- `bl auth logout` 清 `api_key` + `access_token` + `access_key_*`
|
||||
- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token`
|
||||
- `bl auth logout` 清 `api_key` + `base_url` + `access_token` + `access_key_*`
|
||||
|
||||
解析分工:
|
||||
|
||||
- `resolveApiKey()` — `auth: "apiKey"` 命令;优先级 `--api-key` > `DASHSCOPE_API_KEY` > config `api_key`
|
||||
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`
|
||||
- `resolveModelBaseUrl()` — model base URL;优先级 `--base-url` > `DASHSCOPE_BASE_URL` > config `base_url` > `REGIONS.cn`,返回前统一归一化为 URL origin(仅保留协议、host 和显式端口,去除 path、query、fragment)
|
||||
- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值
|
||||
- 显式 `auth login --config <name>` 在凭证验证并落盘成功后自动激活目标 Profile;未传
|
||||
`--config` 时继续写当前激活项,失败和 dry-run 不切换
|
||||
- `resolveConsole()` — `auth: "console"` 命令;当前 token 来自 config `access_token`,region/site/switchAgent 来自 flag > config > 默认
|
||||
- `resolveOpenApi()` — `auth: "openapi"` 命令;优先级 `--access-key-id/--access-key-secret` > `ALIBABA_CLOUD_ACCESS_KEY_ID/ALIBABA_CLOUD_ACCESS_KEY_SECRET` > config `access_key_*`。兼容读取旧字段 `openapi_access_key_*`,新写入只写短字段
|
||||
- `describeAuthState()` — `auth status` / banner / telemetry 使用的只读快照
|
||||
|
||||
@@ -67,6 +67,7 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
| 文本/搜索/记忆/配置 | `isDashScopeE2EReady()` |
|
||||
| 图像/语音 | `isBailianE2EMediaEnabled() && isDashScopeE2EReady()` |
|
||||
| 视频 | `isBailianE2EVideoEnabled() && isDashScopeE2EReady()` |
|
||||
| OpenAPI AK/SK | `isOpenApiE2EReady()`(`.env` 中必须同时提供完整 AK/SK) |
|
||||
| 视频 download/task | 另需 `BAILIAN_E2E_VIDEO_TASK_ID` |
|
||||
| 知识库 chat/search live | `isChatE2EReady()` / `isSearchE2EReady()`(`knowledge chat/search`,需 `BAILIAN_WORKSPACE_ID` + agent ID) |
|
||||
|
||||
@@ -84,7 +85,8 @@ describe.skipIf(<ready>)("e2e: <topic>(DashScope …)", () => {
|
||||
|
||||
## 安全与例外
|
||||
|
||||
- **禁止真实破坏性操作**:`auth logout` 只用 `--dry-run`;`config set` 只用 `--dry-run`
|
||||
- **禁止破坏真实用户配置**:`auth logout` 默认只用 `--dry-run`;需要验证实际落盘时,必须通过
|
||||
`BAILIAN_CONFIG_DIR` 指向隔离 fixture;`config set` 只用 `--dry-run`
|
||||
- **不加 dry-run**:`dryRun` 在 `resolveFileUrl` / `resolveCredential` / 上传**之后**的命令(如 `image edit`、`speech recognize` 带 `--url`)
|
||||
- **`--list-voices` 等旁路**:先于 `--text` 校验的 flag,缺参用例勿带该 flag
|
||||
- 新增 required option → 至少一条缺参用例;改 dry-run 输出 → 更新对应断言
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Config Profile 与激活状态变更清单
|
||||
|
||||
适用于新增 Profile 预设、修改命名 Profile 选择规则、调整 `active_config`,或新增/修改 `bl config list/use/show/ui` 等 Profile 管理能力。
|
||||
|
||||
## 1. 保持存储边界
|
||||
|
||||
- Profile 业务字段继续由 `ConfigFile` / `CONFIG_FILE_KEYS` 管理。
|
||||
- `active_config` 是 `config.json` 顶层元数据,不得进入命名 Profile block,也不得被 `config set` 当作普通字段写入。
|
||||
- 识别命名 Profile 时必须排除业务字段和顶层元数据。
|
||||
- 旧配置缺少 `active_config` 时继续等价于激活 `default`。
|
||||
|
||||
## 2. 保持选择语义
|
||||
|
||||
```text
|
||||
显式 --config <name> > active_config > default
|
||||
```
|
||||
|
||||
- 解析阶段用局部变量保留“是否显式传入 `--config`”的信息;完成 Config 选择后不进入 `Settings`。
|
||||
- `--config default` 必须显式选择顶层配置并绕过命名激活项。
|
||||
- 普通命令的显式 `--config` 只覆盖本次选择,不修改持久化激活状态;例外是
|
||||
`auth login --config ...`,凭证验证并落盘成功后自动激活该 Profile。
|
||||
- 激活状态只选择配置 block,不改变字段优先级;字段仍为 flag > env > selected config > 默认值。
|
||||
- Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。
|
||||
- Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。
|
||||
|
||||
## 3. 保持读写命令交互一致
|
||||
|
||||
- `auth login`、`config set` 等写命令未传 `--config` 时修改当前激活项。
|
||||
- `auth login --config <name>` 显式指定不存在的 Profile 时,仅在凭证验证成功并实际落盘时
|
||||
创建和激活;`config set --config <name>` 可创建但不自动激活。
|
||||
- `config show`、`auth status` 和业务消费等读命令不得因为显式指定不存在的名称而创建 Profile。
|
||||
- `auth logout` 默认只清理当前激活项;显式 `--config` 只清理指定项。
|
||||
- 按凭证域退出时必须清理该域的完整字段集合,例如 OpenAPI 同时清理 AK、SK 和 STS `security_token`。
|
||||
- 所有生产代码读取“当前配置”时优先经过 `buildSources()` 或携带解析后的 `configName`;直接调用无名称的 `readConfigFile()` / `writeConfigFile()` 只适用于明确操作顶层 default 的底层能力。
|
||||
|
||||
## 4. 保持状态一致性
|
||||
|
||||
- `config use` 只能激活已经存在的命名 Profile;`default` 始终有效。
|
||||
- 配置文件中的 `active_config` 指向不存在的 Profile 时返回 usage error,不静默回退。
|
||||
- 删除当前激活的命名 Profile 时,同一次落盘切回 `default`,不得留下悬空引用。
|
||||
- 配置写入继续使用临时文件 + rename,避免中断后留下半写文件。
|
||||
|
||||
## 5. 命令与展示联动
|
||||
|
||||
- 新增/重命名命令时同步 `packages/commands/src/index.ts` 和产品入口 `packages/cli/src/commands.ts`。
|
||||
- `config list` 标识所有 Profile 与当前激活项。
|
||||
- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。
|
||||
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。
|
||||
- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
|
||||
- 同步 E2E topic routes、Skill setup 和自动生成 reference。
|
||||
|
||||
## 6. 最小测试矩阵
|
||||
|
||||
- 旧配置无 `active_config` -> `default`。
|
||||
- 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。
|
||||
- 显式命名 `--config` 和 `--config default` 均覆盖激活项且不修改磁盘状态。
|
||||
- 激活不存在的 Profile 失败且不写盘。
|
||||
- 悬空 `active_config` 明确失败。
|
||||
- 删除激活 Profile 后切回 `default`。
|
||||
- 登录、退出、`config set` 分别覆盖“当前激活项”和“显式不存在名称成功后创建”。
|
||||
- 显式 `auth login --config <name>` 成功后激活该 Profile,失败或 dry-run 不创建、不切换;
|
||||
`--config default` 成功后切回 `default`。
|
||||
- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。
|
||||
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
|
||||
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
|
||||
|
||||
## 7. 完成检查
|
||||
|
||||
```sh
|
||||
pnpm run sync:skill-assets
|
||||
vp check
|
||||
vp test
|
||||
```
|
||||
|
||||
命令 E2E 会启动本地子进程,Config UI 测试还会监听 `127.0.0.1` 临时端口;受限沙箱内出现 `EPERM` 时,需要在允许本地进程和端口的环境中复跑。
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
### A. 版本一致性
|
||||
|
||||
- [ ] `package.json` 的 `engines.node` 与 README 的 Node.js 徽章一致
|
||||
- [ ] 发布包(`cli` 等)的 `engines.node` 与 README 的 Node.js 徽章一致;根/e2e 开发要求(`>=22.12`)与 CONTRIBUTING 一致
|
||||
- [ ] `pnpm-lock.yaml` 同步生成(运行 `pnpm install`)
|
||||
- [ ] 各源码包 `tsconfig.json`(根 + core + runtime + commands + cli + kscli)的 target / module 设置一致
|
||||
|
||||
|
||||
+12
-12
@@ -93,15 +93,15 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run
|
||||
|
||||
## 常见漏点(基于历史踩坑)
|
||||
|
||||
| 漏点 | 后果 |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
|
||||
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
|
||||
| cli 升版号但 core 没升 | check.mjs 会拦下 |
|
||||
| 发版漏更 CHANGELOG,或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
|
||||
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 |
|
||||
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
|
||||
| Node 徽章 `>=18`、engines `>=22.12` 不一致 | 用户在 Node 18 上 `npm i` 被 engine 警告或直接失败 |
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 |
|
||||
| 漏点 | 后果 |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| 只升部分包,漏升 runtime/commands/kscli | 当前 check.mjs 按所选发布集合校验,但未选择 `knowledge-studio-cli` 时不会覆盖 kscli |
|
||||
| 新增发布包但没加 `tools/release/lib/packages.mjs` | CI 不会 bump/publish/校验该包 |
|
||||
| cli 升版号但 core 没升 | check.mjs 会拦下 |
|
||||
| 发版漏更 CHANGELOG,或分类写成规范外的 `优化`/`Improved` | 用户看不到本次变更,分类与历史不一致 |
|
||||
| `1.0.0` 当 beta 直接发 | 占了 `latest` tag,所有用户被强升,撤回成本极高 |
|
||||
| README 写的 bin 名实际 `package.json.bin` 没注册 | 用户复制命令报 `command not found` |
|
||||
| Node 徽章与 `cli/package.json.engines` 不一致(当前应为 `>=18.17`) | 用户在声明外的 Node 上 `npm i` 被 engine 警告或直接失败 |
|
||||
| npm Trusted Publisher 的 workflow filename 改了没同步 | OIDC 匹配不上,publish 报 404 |
|
||||
| CI 用 Node 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 |
|
||||
| stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 |
|
||||
|
||||
@@ -19,6 +19,7 @@ runtime/src/urls.ts ← 用户面控制台 URL(cn-only)
|
||||
BAILIAN_CONSOLE_ROOT bailian.console.aliyun.com
|
||||
BAILIAN_CONSOLE BAILIAN_CONSOLE_ROOT/cn-beijing
|
||||
API_KEY_PAGE BAILIAN_CONSOLE/?tab=app#/api-key
|
||||
TOKEN_PLAN_PAGE BAILIAN_CONSOLE_ROOT/cn-beijing?tab=plan#/efm/subscription/overview
|
||||
|
||||
core/files/upload.ts ← 文件上传 endpoint(cn-pinned)
|
||||
UPLOAD_API ${REGIONS.cn}/api/v1/uploads
|
||||
|
||||
+19
-2
@@ -5,7 +5,7 @@
|
||||
**The official command-line interface for Aliyun Model Studio (DashScope) AI Platform**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -30,6 +30,7 @@ Equip your AI Agent out-of-the-box with these capabilities, composable across co
|
||||
- **Video generation & editing** — happyhorse-1.1 series: text-/image-/reference-to-video and natural-language video editing (up to 9-image reference)
|
||||
- **Speech synthesis & recognition** — CosyVoice streaming TTS, voice cloning from 5–20s samples; FunAudio-ASR covers 30 languages including 7 Chinese dialects and 20+ Mandarin accents
|
||||
- **Image & video understanding** — Qwen-VL: long-form video analysis, chart/document parsing, visual reasoning, multilingual OCR
|
||||
- **Coding agent setup** — Configure Claude Code, Qwen Code, OpenCode, OpenClaw, Hermes Agent, or Codex to use DashScope with `bl config agent`
|
||||
|
||||
> **Note:** The features below are currently available only to China site (aliyun.com) account holders and are not yet supported for international / global site accounts.
|
||||
|
||||
@@ -80,7 +81,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -91,6 +92,12 @@ bl auth login --console
|
||||
# Or authenticate with an API key
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# Or use Token Plan (Base URL built in; the key is tested during login)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# Configure a coding agent to use DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# Chat with Qwen
|
||||
bl text chat --message "What is DashScope?"
|
||||
|
||||
@@ -159,6 +166,15 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "Hello"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
Get or copy the API key from the [Token Plan subscription overview](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview).
|
||||
The CLI has the default Token Plan Base URL built in. Login tests the key first, then saves and activates the `token-plan` config only when validation succeeds.
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### Console Login (OAuth)
|
||||
|
||||
Required for console capability commands (`model list`, `app list`, `usage summary/free/stats`, `workspace list`, `quota list/request/check/history`). Opens the Bailian console in your browser to sign in.
|
||||
@@ -209,6 +225,7 @@ Config file location: `~/.bailian/config.json`
|
||||
| Qwen Model List | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| Aliyun Model Studio Console | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| Get API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| Get Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| Get AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## Changelog
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**阿里云百炼 (DashScope) AI 平台命令行工具**
|
||||
|
||||
[](https://www.npmjs.com/package/bailian-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -30,6 +30,7 @@ _专为 AI Agent 打造,每个命令均可作为结构化工具调用。_
|
||||
- **视频生成与编辑** — happyhorse-1.1 系列,支持文生 / 图生 / 参考生(最多 9 张图参考)/ 自然语言视频编辑
|
||||
- **语音合成与识别** — CosyVoice 实时流式合成,5-20s 样本即可克隆;FunAudio-ASR 覆盖 30 种语种,含汉语七大方言与 20+ 口音官话
|
||||
- **图像与视频理解** — Qwen-VL:长视频解析、复杂图表与文档识别、视觉推理、多语种 OCR
|
||||
- **Coding Agent 配置** — 使用 `bl config agent` 将 Claude Code、Qwen Code、OpenCode、OpenClaw、Hermes Agent 或 Codex 配置为使用 DashScope
|
||||
|
||||
> **注意:** 以下功能目前仅对中国站(aliyun.com)账号开放,国际站 / 全球站账号暂不支持。
|
||||
|
||||
@@ -78,7 +79,7 @@ npm install -g bailian-cli
|
||||
npx skills add modelstudioai/cli --all -g
|
||||
```
|
||||
|
||||
> 需要预先安装 Node.js >= 22.12。
|
||||
> 需要预先安装 Node.js >= 18.17。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -89,6 +90,12 @@ bl auth login --console
|
||||
# 或使用 API key 认证
|
||||
bl auth login --api-key sk-xxxxx
|
||||
|
||||
# 或使用 Token Plan(已内置 Base URL,登录时自动测试 Key)
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
|
||||
# 配置 Coding Agent 使用 DashScope
|
||||
bl config agent --agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus
|
||||
|
||||
# 和通义千问对话
|
||||
bl text chat --message "你好,介绍一下阿里云百炼平台"
|
||||
|
||||
@@ -157,6 +164,15 @@ bl auth login --api-key sk-xxxxx
|
||||
bl text chat --api-key sk-xxxxx --message "你好"
|
||||
```
|
||||
|
||||
### Token Plan API Key
|
||||
|
||||
前往 [Token Plan 订阅详情](https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview) 获取或复制 API Key。
|
||||
CLI 已内置 Token Plan 的默认 Base URL;登录命令会先测试 Key,通过后才保存并激活 `token-plan` 配置。
|
||||
|
||||
```bash
|
||||
bl auth login --config token-plan --api-key sk-sp-xxxxx
|
||||
```
|
||||
|
||||
### 控制台登录(OAuth)
|
||||
|
||||
控制台能力命令(`model list`、`app list`、`usage summary/free/stats`、`workspace list`、`quota list/request/check/history`)需要使用此登录方式。打开浏览器跳转百炼控制台完成登录。
|
||||
@@ -207,6 +223,7 @@ bl update
|
||||
| 通义千问模型列表 | https://help.aliyun.com/zh/model-studio/getting-started/models |
|
||||
| 阿里云百炼控制台 | https://bailian.console.aliyun.com/?source_channel=cli_github |
|
||||
| 获取 API Key | https://bailian.console.aliyun.com/cn-beijing/?source_channel=key_github&tab=app#/api-key |
|
||||
| 获取 Token Plan API Key | https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/overview |
|
||||
| 获取 AccessKey | https://ram.console.aliyun.com/manage/ak |
|
||||
|
||||
## 更新日志
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli",
|
||||
"version": "1.8.2",
|
||||
"version": "1.10.1",
|
||||
"description": "CLI for Aliyun Model Studio (DashScope) AI Platform.",
|
||||
"keywords": [
|
||||
"agent",
|
||||
@@ -66,6 +66,6 @@
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
authLogin,
|
||||
authStatus,
|
||||
authLogout,
|
||||
authGenerateAccessToken,
|
||||
textChat,
|
||||
textOmni,
|
||||
imageGenerate,
|
||||
@@ -15,6 +16,10 @@ import {
|
||||
visionDescribe,
|
||||
configShow,
|
||||
configSet,
|
||||
configList,
|
||||
configUse,
|
||||
configUi,
|
||||
configAgent,
|
||||
update,
|
||||
appCall,
|
||||
appList,
|
||||
@@ -79,6 +84,7 @@ import {
|
||||
tokenPlanCreateKey,
|
||||
tokenPlanAssignSeats,
|
||||
tokenPlanAddMember,
|
||||
workspaceInit,
|
||||
pluginInstall,
|
||||
pluginLink,
|
||||
pluginList,
|
||||
@@ -94,6 +100,7 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"auth login": authLogin,
|
||||
"auth status": authStatus,
|
||||
"auth logout": authLogout,
|
||||
"auth generate-access-token": authGenerateAccessToken,
|
||||
"text chat": textChat,
|
||||
omni: textOmni,
|
||||
"image generate": imageGenerate,
|
||||
@@ -106,6 +113,10 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"vision describe": visionDescribe,
|
||||
"config show": configShow,
|
||||
"config set": configSet,
|
||||
"config list": configList,
|
||||
"config use": configUse,
|
||||
"config ui": configUi,
|
||||
"config agent": configAgent,
|
||||
update,
|
||||
"app call": appCall,
|
||||
"app list": appList,
|
||||
@@ -170,6 +181,7 @@ export const commands: Record<string, AnyCommand> = {
|
||||
"token-plan create-key": tokenPlanCreateKey,
|
||||
"token-plan assign-seats": tokenPlanAssignSeats,
|
||||
"token-plan add-member": tokenPlanAddMember,
|
||||
"workspace init": workspaceInit,
|
||||
"plugin install": pluginInstall,
|
||||
"plugin link": pluginLink,
|
||||
"plugin list": pluginList,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCli } from "./helpers.ts";
|
||||
|
||||
function withTempConfigDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-config-profile-"));
|
||||
return fn(dir).finally(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(dir: string, data: Record<string, unknown>): void {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(data, null, 2) + "\n");
|
||||
}
|
||||
|
||||
describe("e2e: named config", () => {
|
||||
test("根帮助展示 --config 全局标志", async () => {
|
||||
const { stderr, exitCode } = await runCli(["--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/--config <name>/);
|
||||
});
|
||||
|
||||
test("config set --config 写入命名 block 且不影响默认配置", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, { output: "text", api_key: "sk-default" });
|
||||
|
||||
const setResult = await runCli(
|
||||
[
|
||||
"config",
|
||||
"set",
|
||||
"--config",
|
||||
"dev",
|
||||
"--key",
|
||||
"output",
|
||||
"--value",
|
||||
"json",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ BAILIAN_CONFIG_DIR: dir },
|
||||
);
|
||||
expect(setResult.exitCode, setResult.stderr).toBe(0);
|
||||
const setData = parseStdoutJson<{
|
||||
output?: string;
|
||||
config?: string;
|
||||
config_file?: string;
|
||||
}>(setResult.stdout);
|
||||
expect(setData.output).toBe("json");
|
||||
expect(setData.config).toBe("dev");
|
||||
expect(setData.config_file).toBe(join(dir, "config.json"));
|
||||
|
||||
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(raw.output).toBe("text");
|
||||
expect((raw.dev as Record<string, unknown>).output).toBe("json");
|
||||
});
|
||||
});
|
||||
|
||||
test("config show --config 只展示命名 block", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, {
|
||||
output: "text",
|
||||
api_key: "sk-default",
|
||||
dev: { output: "json", access_token: "tok-dev" },
|
||||
});
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
["config", "show", "--config", "dev", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: dir },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<Record<string, unknown>>(stdout);
|
||||
expect(data.config).toBe("dev");
|
||||
expect(data.config_file).toBe(join(dir, "config.json"));
|
||||
expect(data.output).toBe("json");
|
||||
expect(data.access_token).toBeDefined();
|
||||
expect(data.api_key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("auth status --config 不继承默认凭证", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, { api_key: "sk-default", dev: { output: "json" } });
|
||||
|
||||
const devStatus = await runCli(["auth", "status", "--config", "dev", "--output", "json"], {
|
||||
BAILIAN_CONFIG_DIR: dir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
});
|
||||
expect(devStatus.exitCode, devStatus.stderr).toBe(0);
|
||||
const devData = parseStdoutJson<Record<string, unknown>>(devStatus.stdout);
|
||||
expect(devData.authenticated).toBe(false);
|
||||
expect(devData.config).toBe("dev");
|
||||
|
||||
const defaultStatus = await runCli(["auth", "status", "--output", "json"], {
|
||||
BAILIAN_CONFIG_DIR: dir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
});
|
||||
expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0);
|
||||
const defaultData = parseStdoutJson<Record<string, unknown>>(defaultStatus.stdout);
|
||||
expect(defaultData.authenticated).toBe(true);
|
||||
expect(defaultData.config).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
test("--config default 等价默认配置", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, {
|
||||
active_config: "token-plan",
|
||||
output: "json",
|
||||
api_key: "sk-default",
|
||||
"token-plan": { output: "text", api_key: "sk-token" },
|
||||
});
|
||||
const { stdout, stderr, exitCode } = await runCli(
|
||||
["config", "show", "--config", "default", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: dir },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<Record<string, unknown>>(stdout);
|
||||
expect(data.config).toBe("default");
|
||||
expect(data.active).toBeUndefined();
|
||||
expect(data.api_key).toBeDefined();
|
||||
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(raw.active_config).toBe("token-plan");
|
||||
});
|
||||
});
|
||||
|
||||
test("非法 --config 名称报 usage error", async () => {
|
||||
const { stderr, exitCode } = await runCli(["auth", "status", "--config", "../evil"]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid config name/);
|
||||
});
|
||||
|
||||
test("auth status 文本输出分行展示选中 Config 和配置文件", async () => {
|
||||
await withTempConfigDir(async (dir) => {
|
||||
writeConfig(dir, {
|
||||
active_config: "token-plan",
|
||||
"token-plan": { api_key: "sk-token" },
|
||||
});
|
||||
|
||||
const result = await runCli(["auth", "status", "--output", "text"], {
|
||||
BAILIAN_CONFIG_DIR: dir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("Config: token-plan\n");
|
||||
expect(result.stdout).toContain(`Config file: ${join(dir, "config.json")}\n`);
|
||||
expect(result.stdout).not.toContain("Active config:");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-commands",
|
||||
"version": "1.8.2",
|
||||
"version": "1.10.1",
|
||||
"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": {
|
||||
@@ -44,6 +44,7 @@
|
||||
"bailian-cli-runtime": "workspace:*",
|
||||
"boxen": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"smol-toml": "catalog:",
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -54,6 +55,6 @@
|
||||
"vite-plus": "0.1.22"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
generateCLIAccessToken,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const FLAGS = {
|
||||
accessKeyId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Alibaba Cloud Access Key ID",
|
||||
required: true,
|
||||
},
|
||||
accessKeySecret: {
|
||||
type: "string",
|
||||
valueHint: "<secret>",
|
||||
description: "Alibaba Cloud Access Key Secret",
|
||||
required: true,
|
||||
},
|
||||
securityToken: {
|
||||
type: "string",
|
||||
valueHint: "<token>",
|
||||
description: "Alibaba Cloud STS Security Token to store (optional)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Generate a CLI access token using OpenAPI AK/SK",
|
||||
auth: "none",
|
||||
usageArgs: "--access-key-id <id> --access-key-secret <secret> --security-token <token>",
|
||||
flags: FLAGS,
|
||||
exampleArgs: ["--access-key-id LTAIxxxxx --access-key-secret xxxxx --security-token <token>"],
|
||||
async run(ctx) {
|
||||
const { identity, settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const resp = await generateCLIAccessToken({
|
||||
identity,
|
||||
settings,
|
||||
baseUrl: ctx.client.baseUrl,
|
||||
accessKeyId: flags.accessKeyId,
|
||||
accessKeySecret: flags.accessKeySecret,
|
||||
securityToken: flags.securityToken || undefined,
|
||||
});
|
||||
|
||||
emitResult(resp, format);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
chatPath,
|
||||
requestJson,
|
||||
normalizeModelBaseUrl,
|
||||
type AuthPersistPatch,
|
||||
type AuthStore,
|
||||
type Identity,
|
||||
type Settings,
|
||||
} from "bailian-cli-core";
|
||||
|
||||
interface ApiKeyLoginDeps {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
authStore: AuthStore;
|
||||
}
|
||||
|
||||
interface ApiKeyLoginProfile {
|
||||
baseUrl: string;
|
||||
persistBaseUrl?: string;
|
||||
defaultTextModel?: string;
|
||||
defaultVideoModel?: string;
|
||||
defaultImageToVideoModel?: string;
|
||||
defaultReferenceToVideoModel?: string;
|
||||
defaultImageModel?: string;
|
||||
persistPatch?: AuthPersistPatch;
|
||||
}
|
||||
|
||||
const RETRY_DELAY_BASE_MS = 500;
|
||||
|
||||
function canRetry(error: unknown): boolean {
|
||||
if (error instanceof BailianError) {
|
||||
if (error.exitCode === ExitCode.NETWORK || error.exitCode === ExitCode.TIMEOUT) return true;
|
||||
const status = error.api?.httpStatus;
|
||||
return status === 401 || (status !== undefined && status >= 500);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "AbortError" ||
|
||||
error.name === "TimeoutError" ||
|
||||
error.message.includes("timed out") ||
|
||||
error.message === "fetch failed"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function validateAndPersistApiKey(
|
||||
deps: ApiKeyLoginDeps,
|
||||
key: string,
|
||||
profile: ApiKeyLoginProfile,
|
||||
): Promise<void> {
|
||||
process.stderr.write("Testing key... ");
|
||||
const httpDeps = { identity: deps.identity, settings: deps.settings };
|
||||
const baseUrl = normalizeModelBaseUrl(profile.baseUrl);
|
||||
const persistBaseUrl = profile.persistBaseUrl
|
||||
? normalizeModelBaseUrl(profile.persistBaseUrl)
|
||||
: undefined;
|
||||
const validationModel = profile.defaultTextModel || "qwen3.7-max";
|
||||
const requestOpts = {
|
||||
url: baseUrl + chatPath(),
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
timeout: Math.min(deps.settings.timeout, 30),
|
||||
body: {
|
||||
model: validationModel,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
enable_thinking: validationModel === "qwen3.8-max-preview",
|
||||
},
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await requestJson<unknown>(httpDeps, requestOpts);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt >= 3 || !canRetry(error)) {
|
||||
process.stderr.write("Failed\n");
|
||||
throw error;
|
||||
}
|
||||
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write("Valid\n");
|
||||
await deps.authStore.login({
|
||||
...profile.persistPatch,
|
||||
api_key: key,
|
||||
base_url: persistBaseUrl,
|
||||
default_text_model: profile.defaultTextModel,
|
||||
default_video_model: profile.defaultVideoModel,
|
||||
default_image_to_video_model: profile.defaultImageToVideoModel,
|
||||
default_reference_to_video_model: profile.defaultReferenceToVideoModel,
|
||||
default_image_model: profile.defaultImageModel,
|
||||
});
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import http from "node:http";
|
||||
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
chatPath,
|
||||
getConfigPath,
|
||||
requestJson,
|
||||
type AuthPersistPatch,
|
||||
type AuthStore,
|
||||
type ConfigFile,
|
||||
type Identity,
|
||||
type Settings,
|
||||
} from "bailian-cli-core";
|
||||
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
|
||||
import { validateAndPersistApiKey } from "./login-api-key.ts";
|
||||
|
||||
/** 登录流程的能力面:身份(UA)、有效配置(timeout 等)、auth 域落盘。 */
|
||||
export interface LoginDeps {
|
||||
@@ -23,6 +22,9 @@ export interface LoginDeps {
|
||||
|
||||
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const MAX_AUTH_CALLBACK_BODY = 65536;
|
||||
// Regex for double newline (\r\n\r\n or \n\n); built via RegExp to avoid
|
||||
// literal multi-line splitting in source.
|
||||
const REGEX_DOUBLE_NEWLINE = new RegExp("\r\n\r\n|\n\n");
|
||||
|
||||
const CONSOLE_ORIGINS: Record<string, string> = {
|
||||
domestic: "https://bailian.console.aliyun.com",
|
||||
@@ -76,7 +78,7 @@ function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): stri
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const part = segments[i]!;
|
||||
if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue;
|
||||
const sep = part.match(/\r\n\r\n|\n\n/);
|
||||
const sep = part.match(REGEX_DOUBLE_NEWLINE);
|
||||
if (!sep || sep.index === undefined) continue;
|
||||
let value = part.slice(sep.index + sep[0].length);
|
||||
value = value
|
||||
@@ -359,90 +361,7 @@ async function extractCredentialsFromRequest(
|
||||
}
|
||||
|
||||
function listenServerOnFreeLocalPort(server: http.Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onErr = (e: Error) => reject(e);
|
||||
server.once("error", onErr);
|
||||
server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => {
|
||||
server.off("error", onErr);
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
reject(new Error("Expected TCP socket address"));
|
||||
return;
|
||||
}
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openInBrowser(url: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const RETRY_DELAY_BASE_MS = 500;
|
||||
|
||||
function canRetry(err: unknown): boolean {
|
||||
if (err instanceof BailianError) {
|
||||
if (err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT) return true;
|
||||
const status = err.api?.httpStatus;
|
||||
return status === 401 || (status !== undefined && status >= 500);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return (
|
||||
err.name === "AbortError" ||
|
||||
err.name === "TimeoutError" ||
|
||||
err.message.includes("timed out") ||
|
||||
err.message === "fetch failed"
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function validateAndPersistApiKey(
|
||||
deps: LoginDeps,
|
||||
key: string,
|
||||
baseUrl: string,
|
||||
): Promise<void> {
|
||||
process.stderr.write("Testing key... ");
|
||||
const httpDeps = { identity: deps.identity, settings: deps.settings };
|
||||
const requestOpts = {
|
||||
url: baseUrl + chatPath(),
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
timeout: Math.min(deps.settings.timeout, 30),
|
||||
body: {
|
||||
model: "qwen3.7-max",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
},
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
await requestJson<unknown>(httpDeps, requestOpts);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (attempt >= 3 || !canRetry(err)) {
|
||||
process.stderr.write("Failed\n");
|
||||
throw new BailianError("API key validation failed", ExitCode.AUTH, "Invalid API key.", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
const delayMs = RETRY_DELAY_BASE_MS * 2 ** (attempt - 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write("Valid\n");
|
||||
await deps.authStore.login({ api_key: key });
|
||||
return listenLocalServer(server);
|
||||
}
|
||||
|
||||
export async function runConsoleLogin(
|
||||
@@ -486,20 +405,27 @@ export async function runConsoleLogin(
|
||||
|
||||
if (hasConfig || apiKey) {
|
||||
try {
|
||||
if (hasConfig) {
|
||||
await deps.authStore.login({
|
||||
access_token: accessToken || undefined,
|
||||
base_url: baseUrl || undefined,
|
||||
console_site: (consoleSite || undefined) as ConfigFile["console_site"],
|
||||
console_region: consoleRegion || undefined,
|
||||
console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined,
|
||||
workspace_id: workspaceId || undefined,
|
||||
});
|
||||
process.stderr.write(`Config saved to ${getConfigPath()}\n`);
|
||||
}
|
||||
const callbackPatch: AuthPersistPatch = {
|
||||
access_token: accessToken || undefined,
|
||||
console_site: (consoleSite || undefined) as ConfigFile["console_site"],
|
||||
console_region: consoleRegion || undefined,
|
||||
console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined,
|
||||
workspace_id: workspaceId || undefined,
|
||||
};
|
||||
if (apiKey) {
|
||||
const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl();
|
||||
await validateAndPersistApiKey(deps, apiKey, testBaseUrl);
|
||||
await validateAndPersistApiKey(deps, apiKey, {
|
||||
baseUrl: testBaseUrl,
|
||||
persistBaseUrl: baseUrl || undefined,
|
||||
persistPatch: callbackPatch,
|
||||
});
|
||||
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
|
||||
} else if (hasConfig) {
|
||||
await deps.authStore.login({
|
||||
...callbackPatch,
|
||||
base_url: baseUrl || undefined,
|
||||
});
|
||||
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
callbackError = err;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { defineCommand, getConfigPath } from "bailian-cli-core";
|
||||
import { emitBare } from "bailian-cli-runtime";
|
||||
import {
|
||||
resolveConsoleOrigin,
|
||||
runConsoleLogin,
|
||||
validateAndPersistApiKey,
|
||||
} from "./login-console.ts";
|
||||
defineCommand,
|
||||
generateCLIAccessToken,
|
||||
getModelProfilePreset,
|
||||
normalizeModelBaseUrl,
|
||||
} from "bailian-cli-core";
|
||||
import { emitBare } from "bailian-cli-runtime";
|
||||
import { validateAndPersistApiKey } from "./login-api-key.ts";
|
||||
import { resolveConsoleOrigin, runConsoleLogin } from "./login-console.ts";
|
||||
|
||||
const LOGIN_MODE_HINT = "Choose exactly one login mode: --api-key, --console, or --open-api";
|
||||
|
||||
@@ -19,11 +21,11 @@ export default defineCommand({
|
||||
usageArgs:
|
||||
"--api-key <key> | --console | --open-api --access-key-id <id> --access-key-secret <secret>",
|
||||
flags: {
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "DashScope API key to store" },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "Model API key to store" },
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
description: "DashScope API base URL (used with --api-key for validation)",
|
||||
description: "Model API base URL (used with --api-key for validation)",
|
||||
},
|
||||
console: {
|
||||
type: "switch",
|
||||
@@ -52,6 +54,7 @@ export default defineCommand({
|
||||
},
|
||||
exampleArgs: [
|
||||
"--api-key sk-xxxxx",
|
||||
"--config token-plan --api-key sk-sp-xxxxx",
|
||||
"--console",
|
||||
"--open-api --access-key-id LTAIxxxxx --access-key-secret xxxxx",
|
||||
],
|
||||
@@ -90,7 +93,7 @@ export default defineCommand({
|
||||
const store = ctx.authStore;
|
||||
const deps = { identity, settings, authStore: store };
|
||||
const key = flags.apiKey;
|
||||
const baseUrl = flags.baseUrl || undefined;
|
||||
const baseUrl = flags.baseUrl ? normalizeModelBaseUrl(flags.baseUrl) : undefined;
|
||||
|
||||
if (flags.console) {
|
||||
if (settings.dryRun) {
|
||||
@@ -110,14 +113,25 @@ export default defineCommand({
|
||||
|
||||
if (flags.openApi) {
|
||||
if (settings.dryRun) {
|
||||
emitBare("Would save OpenAPI AK/SK credentials.");
|
||||
emitBare("Would save OpenAPI AK/SK credentials and generate CLI access token.");
|
||||
return;
|
||||
}
|
||||
const resolvedBaseUrl = store.resolveBaseUrl();
|
||||
process.stderr.write("Generating CLI access token... ");
|
||||
const resp = await generateCLIAccessToken({
|
||||
identity,
|
||||
settings,
|
||||
baseUrl: resolvedBaseUrl,
|
||||
accessKeyId: flags.accessKeyId!,
|
||||
accessKeySecret: flags.accessKeySecret!,
|
||||
});
|
||||
const accessToken = resp.cliAccessToken;
|
||||
await store.login({
|
||||
access_key_id: flags.accessKeyId,
|
||||
access_key_secret: flags.accessKeySecret,
|
||||
access_token: accessToken,
|
||||
});
|
||||
process.stderr.write(`OpenAPI credentials saved to ${getConfigPath()}\n`);
|
||||
process.stderr.write(`OpenAPI credentials saved to ${store.path}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,9 +142,18 @@ export default defineCommand({
|
||||
emitBare("Would validate and save API key.");
|
||||
return;
|
||||
}
|
||||
if (baseUrl) {
|
||||
await store.login({ base_url: baseUrl });
|
||||
}
|
||||
await validateAndPersistApiKey(deps, key, baseUrl || store.resolveBaseUrl());
|
||||
const profilePreset = getModelProfilePreset(settings.configName);
|
||||
const storedBaseUrl = store.stored().baseUrl;
|
||||
const resolvedBaseUrl = baseUrl || store.resolveBaseUrl(profilePreset?.baseUrl);
|
||||
const persistBaseUrl = baseUrl || (!storedBaseUrl ? profilePreset?.baseUrl : undefined);
|
||||
await validateAndPersistApiKey(deps, key, {
|
||||
baseUrl: resolvedBaseUrl,
|
||||
persistBaseUrl,
|
||||
defaultTextModel: profilePreset?.defaultTextModel,
|
||||
defaultVideoModel: profilePreset?.defaultVideoModel,
|
||||
defaultImageToVideoModel: profilePreset?.defaultImageToVideoModel,
|
||||
defaultReferenceToVideoModel: profilePreset?.defaultReferenceToVideoModel,
|
||||
defaultImageModel: profilePreset?.defaultImageModel,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineCommand, getConfigPath } from "bailian-cli-core";
|
||||
import { defineCommand } from "bailian-cli-core";
|
||||
import { emitBare } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Clear stored credentials",
|
||||
description: "Clear stored credentials; full logout also clears the model Base URL",
|
||||
auth: "none",
|
||||
usageArgs: "[--console | --open-api] [--dry-run]",
|
||||
flags: {
|
||||
@@ -12,7 +12,7 @@ export default defineCommand({
|
||||
},
|
||||
openApi: {
|
||||
type: "switch",
|
||||
description: "Only clear OpenAPI AK/SK credentials, keep other credentials intact",
|
||||
description: "Only clear OpenAPI AK/SK/STS credentials, keep other credentials intact",
|
||||
},
|
||||
},
|
||||
exampleArgs: ["", "--console", "--open-api", "--dry-run"],
|
||||
@@ -25,13 +25,13 @@ export default defineCommand({
|
||||
|
||||
if (flags.console) {
|
||||
if (settings.dryRun) {
|
||||
if (stored.console) emitBare("Would clear access_token from ~/.bailian/config.json");
|
||||
if (stored.console) emitBare(`Would clear access_token from ${store.path}`);
|
||||
else emitBare("No console access_token to clear.");
|
||||
emitBare("No changes made.");
|
||||
return;
|
||||
}
|
||||
if (await store.logout("console")) {
|
||||
process.stderr.write(`Cleared access_token from ${getConfigPath()}\n`);
|
||||
process.stderr.write(`Cleared access_token from ${store.path}\n`);
|
||||
if (stored.apiKey) {
|
||||
process.stderr.write(
|
||||
"api_key is still configured and will be used for authentication.\n",
|
||||
@@ -46,13 +46,17 @@ export default defineCommand({
|
||||
if (flags.openApi) {
|
||||
if (settings.dryRun) {
|
||||
if (stored.openapi)
|
||||
emitBare("Would clear access_key_id / access_key_secret from ~/.bailian/config.json");
|
||||
emitBare(
|
||||
`Would clear access_key_id / access_key_secret / security_token from ${store.path}`,
|
||||
);
|
||||
else emitBare("No OpenAPI AK/SK credentials to clear.");
|
||||
emitBare("No changes made.");
|
||||
return;
|
||||
}
|
||||
if (await store.logout("openapi")) {
|
||||
process.stderr.write(`Cleared access_key_id / access_key_secret from ${getConfigPath()}\n`);
|
||||
process.stderr.write(
|
||||
`Cleared access_key_id / access_key_secret / security_token from ${store.path}\n`,
|
||||
);
|
||||
if (stored.apiKey || stored.console) {
|
||||
process.stderr.write(
|
||||
"Other credentials are still configured and will be used for authentication.\n",
|
||||
@@ -64,24 +68,24 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const hasKey = stored.apiKey || stored.console || stored.openapi;
|
||||
const hasStoredAuth = stored.apiKey || stored.console || stored.openapi || !!stored.baseUrl;
|
||||
|
||||
if (settings.dryRun) {
|
||||
if (hasKey)
|
||||
if (hasStoredAuth)
|
||||
emitBare(
|
||||
"Would clear api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json",
|
||||
`Would clear api_key / base_url / access_token / access_key_id / access_key_secret / security_token from ${store.path}`,
|
||||
);
|
||||
else emitBare("No credentials to clear.");
|
||||
else emitBare("No credentials or model Base URL to clear.");
|
||||
emitBare("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await store.logout("all")) {
|
||||
process.stderr.write(
|
||||
"Cleared api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json\n",
|
||||
`Cleared api_key / base_url / access_token / access_key_id / access_key_secret / security_token from ${store.path}\n`,
|
||||
);
|
||||
} else {
|
||||
process.stderr.write("No credentials to clear.\n");
|
||||
process.stderr.write("No credentials or model Base URL to clear.\n");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,11 +35,15 @@ export default defineCommand({
|
||||
: undefined;
|
||||
|
||||
const authenticated = !!(apiKey || consoleCred || openapi);
|
||||
const configName = settings.configName ?? "default";
|
||||
const configFile = ctx.authStore.path;
|
||||
|
||||
if (!authenticated) {
|
||||
emitResult(
|
||||
{
|
||||
authenticated: false,
|
||||
config: configName,
|
||||
config_file: configFile,
|
||||
message: "Not authenticated.",
|
||||
hint: [
|
||||
`API key (model): ${identity.binName} auth login --api-key <key> or DASHSCOPE_API_KEY`,
|
||||
@@ -54,10 +58,22 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
if (format !== "text") {
|
||||
emitResult({ authenticated: true, api_key: apiKey, console: consoleCred, openapi }, format);
|
||||
emitResult(
|
||||
{
|
||||
authenticated: true,
|
||||
config: configName,
|
||||
config_file: configFile,
|
||||
api_key: apiKey,
|
||||
console: consoleCred,
|
||||
openapi,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emitBare(`Config: ${configName}`);
|
||||
emitBare(`Config file: ${configFile}`);
|
||||
emitBare("Authentication Status:");
|
||||
if (apiKey) {
|
||||
emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { platform } from "os";
|
||||
import { defineCommand, detectOutputFormat, maskToken, type FlagsDef } from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { AGENTS, VALID_AGENT_NAMES, type WriteParams } from "./writers.ts";
|
||||
|
||||
const FLAGS = {
|
||||
agent: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: `Target agent: ${VALID_AGENT_NAMES.join(", ")}`,
|
||||
required: true,
|
||||
choices: VALID_AGENT_NAMES,
|
||||
},
|
||||
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL", required: true },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "API key", required: true },
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Default model name",
|
||||
required: true,
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Configure a coding agent to use DashScope API",
|
||||
auth: "none",
|
||||
usageArgs: "--agent <name> --base-url <url> --api-key <key> --model <model>",
|
||||
flags: FLAGS,
|
||||
exampleArgs: [
|
||||
"--agent claude-code --base-url https://dashscope.aliyuncs.com/apps/anthropic --api-key sk-xxxxx --model qwen3-max",
|
||||
"--agent qwen-code --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
"--agent codex --base-url https://dashscope.aliyuncs.com/compatible-mode/v1 --api-key sk-xxxxx --model qwen3-coder-plus",
|
||||
],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const agentName = flags.agent;
|
||||
const { baseUrl, apiKey, model } = flags;
|
||||
const agentDef = AGENTS[agentName];
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// Hermes has no native Windows support.
|
||||
if (agentName === "hermes" && platform() === "win32") {
|
||||
process.stderr.write(
|
||||
"Warning: Hermes Agent does not support native Windows. Please use WSL2.\n",
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
agent: agentName,
|
||||
label: agentDef.label,
|
||||
base_url: baseUrl,
|
||||
api_key: maskToken(apiKey),
|
||||
model,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const params: WriteParams = { baseUrl, apiKey, model };
|
||||
const summary = agentDef.write(params);
|
||||
|
||||
if (!settings.quiet) {
|
||||
emitBare(`${agentDef.label} configured successfully.`);
|
||||
for (const path of summary.paths) emitBare(` Written: ${path}`);
|
||||
emitBare(` ${summary.nextStep}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type { WriteParams, WriteSummary, AgentDef } from "./writers/utils.ts";
|
||||
|
||||
import type { AgentDef } from "./writers/utils.ts";
|
||||
import claudeCode from "./writers/claude-code.ts";
|
||||
import qwenCode from "./writers/qwen-code.ts";
|
||||
import opencode from "./writers/opencode.ts";
|
||||
import openclaw from "./writers/openclaw.ts";
|
||||
import hermes from "./writers/hermes.ts";
|
||||
import codex from "./writers/codex.ts";
|
||||
|
||||
export const AGENTS: Record<string, AgentDef> = {
|
||||
"claude-code": claudeCode,
|
||||
"qwen-code": qwenCode,
|
||||
opencode,
|
||||
openclaw,
|
||||
hermes,
|
||||
codex,
|
||||
};
|
||||
|
||||
export const VALID_AGENT_NAMES = Object.keys(AGENTS) as [string, ...string[]];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "Claude Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".claude", "settings.json");
|
||||
const onboardingPath = join(homedir(), ".claude.json");
|
||||
|
||||
// settings.json — merge env. Base URL + auth token connect Claude Code to
|
||||
// the endpoint; the model tier vars force every tier onto the chosen model.
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
env.ANTHROPIC_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
|
||||
env.CLAUDE_CODE_SUBAGENT_MODEL = model;
|
||||
settings.env = env;
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
// .claude.json — skip the onboarding prompt on first launch.
|
||||
backup(onboardingPath);
|
||||
const onboarding = readJson(onboardingPath);
|
||||
onboarding.hasCompletedOnboarding = true;
|
||||
writeJsonAtomic(onboardingPath, onboarding);
|
||||
|
||||
return {
|
||||
paths: [settingsPath, onboardingPath],
|
||||
nextStep: "Run `claude` to start using Claude Code with DashScope.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } from "./utils.ts";
|
||||
|
||||
const PROVIDER_KEY = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Codex",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".codex", "config.toml");
|
||||
|
||||
// config.toml — merge into existing config so unrelated settings
|
||||
// (mcp_servers, approval_policy, other providers, ...) are preserved.
|
||||
backup(configPath);
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
config = parseToml(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
}
|
||||
|
||||
config.model_provider = PROVIDER_KEY;
|
||||
config.model = model;
|
||||
config.model_reasoning_effort = "high";
|
||||
config.disable_response_storage = true;
|
||||
|
||||
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
|
||||
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
|
||||
providers[PROVIDER_KEY] = {
|
||||
...existing,
|
||||
name: PROVIDER_KEY,
|
||||
base_url: baseUrl,
|
||||
wire_api: "responses",
|
||||
requires_openai_auth: true,
|
||||
};
|
||||
config.model_providers = providers;
|
||||
|
||||
writeTextAtomic(configPath, stringifyToml(config) + "\n");
|
||||
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here.
|
||||
const authPath = join(homedir(), ".codex", "auth.json");
|
||||
backup(authPath);
|
||||
const auth = readJson(authPath);
|
||||
auth.OPENAI_API_KEY = apiKey;
|
||||
writeJsonAtomic(authPath, auth);
|
||||
|
||||
return {
|
||||
paths: [configPath, authPath],
|
||||
nextStep: "Run `codex` to start using Codex with DashScope.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import yaml from "yaml";
|
||||
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const PROVIDER_NAME = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Hermes Agent",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".hermes", "config.yaml");
|
||||
|
||||
backup(configPath);
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
}
|
||||
|
||||
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
|
||||
const providerEntry = {
|
||||
name: PROVIDER_NAME,
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
api_mode: apiMode,
|
||||
models: [{ id: model, name: model }],
|
||||
};
|
||||
|
||||
// custom_providers — upsert the bailian-cli entry by name.
|
||||
const providers = Array.isArray(config.custom_providers)
|
||||
? (config.custom_providers as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME);
|
||||
if (index >= 0) providers[index] = providerEntry;
|
||||
else providers.push(providerEntry);
|
||||
config.custom_providers = providers;
|
||||
|
||||
// model — select the bailian-cli provider and default model.
|
||||
config.model = { default: model, provider: PROVIDER_NAME };
|
||||
|
||||
writeTextAtomic(configPath, yaml.stringify(config));
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: 'Run `hermes chat -q "hello"` to verify.',
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "OpenClaw",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
|
||||
// models.providers["bailian-cli"]
|
||||
const models = (config.models ?? {}) as Record<string, unknown>;
|
||||
models.mode = "merge";
|
||||
const providers = (models.providers ?? {}) as Record<string, unknown>;
|
||||
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
|
||||
providers["bailian-cli"] = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
api,
|
||||
models: [
|
||||
{
|
||||
id: model,
|
||||
name: model,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 0, output: 0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
|
||||
// agents.defaults
|
||||
const agents = (config.agents ?? {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
|
||||
defaults.model = { primary: `bailian-cli/${model}` };
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
|
||||
writeJsonAtomic(configPath, config);
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: "Run `openclaw` to start using OpenClaw with DashScope.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "OpenCode",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
|
||||
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
|
||||
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
|
||||
|
||||
const provider = (config.provider ?? {}) as Record<string, unknown>;
|
||||
const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible";
|
||||
provider["bailian-cli"] = {
|
||||
npm,
|
||||
name: "Alibaba Cloud Model Studio",
|
||||
options: { baseURL: baseUrl, apiKey, setCacheKey: true },
|
||||
models: { [model]: { name: model } },
|
||||
};
|
||||
config.provider = provider;
|
||||
|
||||
writeJsonAtomic(configPath, config);
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: "Run `opencode` then type `/models` to select your model.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const ENV_KEY = "BAILIAN_CLI_API_KEY";
|
||||
|
||||
/**
|
||||
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
|
||||
* protocol (an AuthType string), not by a free-form provider id — the runtime
|
||||
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
|
||||
* therefore lives in the model entry `name` and the env var name.
|
||||
*/
|
||||
export default {
|
||||
label: "Qwen Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".qwen", "settings.json");
|
||||
const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai";
|
||||
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
|
||||
// env — API key read by the provider entry's envKey.
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env[ENV_KEY] = apiKey;
|
||||
settings.env = env;
|
||||
|
||||
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
|
||||
const providers = (settings.modelProviders ?? {}) as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
|
||||
const existing = entries.find(
|
||||
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
|
||||
);
|
||||
if (existing) {
|
||||
existing.name = "bailian-cli";
|
||||
existing.baseUrl = baseUrl;
|
||||
existing.envKey = ENV_KEY;
|
||||
} else {
|
||||
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
|
||||
}
|
||||
providers[protocol] = entries;
|
||||
settings.modelProviders = providers;
|
||||
|
||||
// security.auth — select the protocol and carry the OpenAI-compatible creds.
|
||||
const security = (settings.security ?? {}) as Record<string, unknown>;
|
||||
security.auth = { selectedType: protocol, apiKey, baseUrl };
|
||||
settings.security = security;
|
||||
|
||||
// model — active model, disambiguated by baseUrl.
|
||||
settings.model = { name: model, baseUrl };
|
||||
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
return {
|
||||
paths: [settingsPath],
|
||||
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { dirname } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
|
||||
|
||||
/** Parameters shared by every agent writer. */
|
||||
export interface WriteParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** What a writer reports back after configuring an agent. */
|
||||
export interface WriteSummary {
|
||||
paths: string[];
|
||||
nextStep: string;
|
||||
}
|
||||
|
||||
/** An agent configuration writer: a human label plus a `write` that applies it. */
|
||||
export interface AgentDef {
|
||||
label: string;
|
||||
write(params: WriteParams): WriteSummary;
|
||||
}
|
||||
|
||||
/** Read a JSON object file, returning `{}` when missing or unparseable. */
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write `data` as pretty JSON with owner-only permissions. */
|
||||
export function writeJsonAtomic(path: string, data: unknown): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = path + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Atomically write raw text with owner-only permissions. */
|
||||
export function writeTextAtomic(path: string, content: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tmp = path + ".tmp";
|
||||
writeFileSync(tmp, content, { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Copy an existing file to a timestamped `.bak.<epoch>` sibling. No-op if absent. */
|
||||
export function backup(path: string): void {
|
||||
if (!existsSync(path)) return;
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
copyFileSync(path, `${path}.bak.${timestamp}`);
|
||||
}
|
||||
|
||||
/** Whether a base URL targets the Anthropic-messages compatible endpoint. */
|
||||
export function isAnthropicEndpoint(baseUrl: string): boolean {
|
||||
return baseUrl.includes("/apps/anthropic");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitBare, emitResult } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "List config profiles and show the active profile",
|
||||
auth: "none",
|
||||
exampleArgs: ["", "--output json"],
|
||||
async run(ctx) {
|
||||
const profiles = ctx.configStore.profiles();
|
||||
const names = ["default", ...Object.keys(profiles.named).sort()];
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
|
||||
if (format === "json") {
|
||||
emitResult(
|
||||
{
|
||||
active_config: profiles.active,
|
||||
profiles: names,
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nameWidth = Math.max("NAME".length, ...names.map((name) => name.length));
|
||||
emitBare(`${"NAME".padEnd(nameWidth)} ACTIVE`);
|
||||
for (const name of names) {
|
||||
emitBare(`${name.padEnd(nameWidth)} ${name === profiles.active ? "*" : ""}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,50 +1,6 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
maskToken,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
type ConfigFile,
|
||||
} from "bailian-cli-core";
|
||||
import { defineCommand, detectOutputFormat, maskToken, type ConfigFile } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
const VALID_KEYS = [
|
||||
"base_url",
|
||||
"output",
|
||||
"output_dir",
|
||||
"timeout",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"access_key_id",
|
||||
"access_key_secret",
|
||||
"default_text_model",
|
||||
"default_video_model",
|
||||
"default_image_model",
|
||||
"default_speech_model",
|
||||
"default_omni_model",
|
||||
"workspace_id",
|
||||
];
|
||||
|
||||
// Keys whose values are secrets. Their stored value must never be echoed back in
|
||||
// cleartext (CI logs, pipes, shared terminals); show a masked form instead — the
|
||||
// same policy `config show` and `auth status` already follow.
|
||||
const SECRET_KEYS = new Set(["api_key", "access_token", "access_key_id", "access_key_secret"]);
|
||||
|
||||
// Allow hyphen-style keys (e.g. default-text-model → default_text_model)
|
||||
const KEY_ALIASES: Record<string, string> = {
|
||||
"base-url": "base_url",
|
||||
"output-dir": "output_dir",
|
||||
"api-key": "api_key",
|
||||
"access-token": "access_token",
|
||||
"access-key-id": "access_key_id",
|
||||
"access-key-secret": "access_key_secret",
|
||||
"default-text-model": "default_text_model",
|
||||
"default-video-model": "default_video_model",
|
||||
"default-image-model": "default_image_model",
|
||||
"default-speech-model": "default_speech_model",
|
||||
"default-omni-model": "default_omni_model",
|
||||
"workspace-id": "workspace_id",
|
||||
};
|
||||
import { SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Set a config value",
|
||||
@@ -55,7 +11,7 @@ export default defineCommand({
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description:
|
||||
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, default_*_model, workspace_id)",
|
||||
"Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)",
|
||||
required: true,
|
||||
},
|
||||
value: { type: "string", valueHint: "<value>", description: "Value to set", required: true },
|
||||
@@ -70,47 +26,36 @@ export default defineCommand({
|
||||
const key = flags.key;
|
||||
const value = flags.value;
|
||||
|
||||
// Resolve hyphen aliases to underscore keys
|
||||
const resolvedKey: string = KEY_ALIASES[key] || key;
|
||||
|
||||
if (!VALID_KEYS.includes(resolvedKey)) {
|
||||
throw new BailianError(
|
||||
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate specific values
|
||||
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
|
||||
throw new BailianError(
|
||||
`Invalid output format "${value}". Valid values: text, json`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedKey === "timeout") {
|
||||
const num = Number(value);
|
||||
if (isNaN(num) || num <= 0) {
|
||||
throw new BailianError(
|
||||
`Invalid timeout "${value}". Must be a positive number.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Resolve hyphen aliases to underscore keys and validate/coerce the value.
|
||||
const resolvedKey: string = resolveKey(key);
|
||||
const coerced = validateAndCoerce(key, value);
|
||||
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ would_set: { [resolvedKey]: value } }, format);
|
||||
emitResult(
|
||||
{
|
||||
would_set: { [resolvedKey]: coerced },
|
||||
config: settings.configName ?? "default",
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const coerced = resolvedKey === "timeout" ? Number(value) : value;
|
||||
await ctx.configStore.write({ [resolvedKey]: coerced } as Partial<ConfigFile>);
|
||||
|
||||
if (!settings.quiet) {
|
||||
const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced;
|
||||
emitResult({ [resolvedKey]: shown }, format);
|
||||
emitResult(
|
||||
{
|
||||
[resolvedKey]: shown,
|
||||
config: settings.configName ?? "default",
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { BailianError, ExitCode, normalizeModelBaseUrl } from "bailian-cli-core";
|
||||
|
||||
/** Config keys that `config set` / `config ui` accept for read/write. */
|
||||
export const VALID_KEYS = [
|
||||
"base_url",
|
||||
"output",
|
||||
"output_dir",
|
||||
"timeout",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"access_key_id",
|
||||
"access_key_secret",
|
||||
"security_token",
|
||||
"default_text_model",
|
||||
"default_video_model",
|
||||
"default_image_to_video_model",
|
||||
"default_reference_to_video_model",
|
||||
"default_image_model",
|
||||
"default_speech_model",
|
||||
"default_omni_model",
|
||||
"workspace_id",
|
||||
] as const;
|
||||
|
||||
// Keys whose values are secrets. `config set` / `config show` mask these; the
|
||||
// web UI renders them as password fields (values are still sent in cleartext
|
||||
// over the token-gated localhost socket).
|
||||
export const SECRET_KEYS = new Set<string>([
|
||||
"api_key",
|
||||
"access_token",
|
||||
"access_key_id",
|
||||
"access_key_secret",
|
||||
"security_token",
|
||||
]);
|
||||
|
||||
// Allow hyphen-style keys (e.g. default-text-model → default_text_model).
|
||||
export const KEY_ALIASES: Record<string, string> = {
|
||||
"base-url": "base_url",
|
||||
"output-dir": "output_dir",
|
||||
"api-key": "api_key",
|
||||
"access-token": "access_token",
|
||||
"access-key-id": "access_key_id",
|
||||
"access-key-secret": "access_key_secret",
|
||||
"security-token": "security_token",
|
||||
"default-text-model": "default_text_model",
|
||||
"default-video-model": "default_video_model",
|
||||
"default-image-to-video-model": "default_image_to_video_model",
|
||||
"default-reference-to-video-model": "default_reference_to_video_model",
|
||||
"default-image-model": "default_image_model",
|
||||
"default-speech-model": "default_speech_model",
|
||||
"default-omni-model": "default_omni_model",
|
||||
"workspace-id": "workspace_id",
|
||||
};
|
||||
|
||||
/** Resolve a hyphen alias to its underscore config key. */
|
||||
export function resolveKey(key: string): string {
|
||||
return KEY_ALIASES[key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single config entry and coerce its value to the stored type.
|
||||
* Throws BailianError(USAGE) for unknown keys or invalid values.
|
||||
*/
|
||||
export function validateAndCoerce(key: string, value: string): string | number {
|
||||
const resolvedKey = resolveKey(key);
|
||||
|
||||
if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) {
|
||||
throw new BailianError(
|
||||
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedKey === "output" && !["text", "json"].includes(value)) {
|
||||
throw new BailianError(
|
||||
`Invalid output format "${value}". Valid values: text, json`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
if (resolvedKey === "timeout") {
|
||||
const num = Number(value);
|
||||
if (isNaN(num) || num <= 0) {
|
||||
throw new BailianError(
|
||||
`Invalid timeout "${value}". Must be a positive number.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
if (resolvedKey === "base_url") return normalizeModelBaseUrl(value);
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineCommand, detectOutputFormat, maskToken } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
import { SECRET_KEYS } from "./shared.ts";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Display current configuration",
|
||||
@@ -16,16 +17,13 @@ export default defineCommand({
|
||||
base_url: client.baseUrl,
|
||||
output: settings.output,
|
||||
timeout: settings.timeout,
|
||||
config: settings.configName ?? "default",
|
||||
config_file: store.path,
|
||||
};
|
||||
|
||||
if (typeof result.api_key === "string") result.api_key = maskToken(result.api_key);
|
||||
if (typeof result.access_token === "string")
|
||||
result.access_token = maskToken(result.access_token);
|
||||
if (typeof result.access_key_id === "string")
|
||||
result.access_key_id = maskToken(result.access_key_id);
|
||||
if (typeof result.access_key_secret === "string")
|
||||
result.access_key_secret = maskToken(result.access_key_secret);
|
||||
for (const key of SECRET_KEYS) {
|
||||
if (typeof result[key] === "string") result[key] = maskToken(result[key]);
|
||||
}
|
||||
|
||||
emitResult(result, format);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
// Self-contained single-page web UI for managing config profiles. Served as a
|
||||
// string by `config ui`; no build step, no client dependencies. All fetches
|
||||
// carry the session token from the page URL.
|
||||
export const PAGE_HTML = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>bailian-cli config</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1f2328; background: #f6f8fa; }
|
||||
#app { display: flex; min-height: 100vh; }
|
||||
#sidebar { width: 240px; background: #fff; border-right: 1px solid #d0d7de; padding: 16px; }
|
||||
#sidebar h1 { font-size: 15px; margin: 0 0 12px; }
|
||||
#profileList { list-style: none; margin: 0 0 12px; padding: 0; }
|
||||
#profileList li { padding: 8px 10px; border-radius: 6px; cursor: pointer; word-break: break-all; }
|
||||
#profileList li:hover { background: #f0f3f6; }
|
||||
#profileList li.selected { background: #0969da; color: #fff; }
|
||||
main { flex: 1; padding: 24px 32px; max-width: 720px; }
|
||||
#editorHead { display: flex; align-items: center; justify-content: space-between; }
|
||||
h2 { font-size: 18px; margin: 0 0 4px; }
|
||||
.row { display: flex; flex-direction: column; margin: 12px 0; }
|
||||
.row label { font-weight: 600; margin-bottom: 4px; }
|
||||
.inputwrap { display: flex; gap: 6px; }
|
||||
input { flex: 1; padding: 7px 9px; border: 1px solid #d0d7de; border-radius: 6px; font: inherit; width: 100%; }
|
||||
button { padding: 7px 12px; border: 1px solid #d0d7de; border-radius: 6px; background: #f6f8fa; cursor: pointer; font: inherit; }
|
||||
button:hover { background: #eef1f4; }
|
||||
#saveBtn { background: #1f883d; color: #fff; border-color: #1f883d; }
|
||||
#saveBtn:hover { background: #1a7f37; }
|
||||
.danger { color: #cf222e; }
|
||||
.toggle { flex: none; }
|
||||
.actions { margin-top: 20px; display: flex; align-items: center; gap: 12px; }
|
||||
.muted { color: #656d76; font-size: 12px; word-break: break-all; }
|
||||
.err { color: #cf222e; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside id="sidebar">
|
||||
<h1>Config Profiles</h1>
|
||||
<ul id="profileList"></ul>
|
||||
<button id="newBtn">+ New profile</button>
|
||||
<p id="cfgFile" class="muted"></p>
|
||||
</aside>
|
||||
<main id="editor">
|
||||
<div id="editorHead">
|
||||
<h2 id="currentName"></h2>
|
||||
<button id="deleteBtn" class="danger">Delete</button>
|
||||
</div>
|
||||
<form id="form" onsubmit="return false"></form>
|
||||
<div class="actions">
|
||||
<button id="saveBtn">Save</button>
|
||||
<button id="useBtn">Save & Activate</button>
|
||||
<span id="status" class="muted"></span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
var token = new URLSearchParams(location.search).get('token') || '';
|
||||
var KEYS = [], SECRETS = [], DATA = { default: {}, named: {} }, CURRENT = '', ACTIVE = 'default';
|
||||
|
||||
function api(path, opts) {
|
||||
var sep = path.indexOf('?') >= 0 ? '&' : '?';
|
||||
return fetch(path + sep + 'token=' + encodeURIComponent(token), opts || {});
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
var el = document.getElementById('status');
|
||||
el.textContent = msg || '';
|
||||
el.className = isErr ? 'err' : 'muted';
|
||||
}
|
||||
|
||||
function profileData(name) {
|
||||
return name === '' ? DATA.default : (DATA.named[name] || {});
|
||||
}
|
||||
|
||||
function load() {
|
||||
api('/api/config').then(function (r) { return r.json(); }).then(function (j) {
|
||||
KEYS = j.keys || [];
|
||||
SECRETS = j.secretKeys || [];
|
||||
DATA = { default: j.default || {}, named: j.named || {} };
|
||||
ACTIVE = j.activeProfile || 'default';
|
||||
document.getElementById('cfgFile').textContent = j.configFile || '';
|
||||
CURRENT = ACTIVE === 'default' ? '' : ACTIVE;
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
}).catch(function (e) { setStatus('Load failed: ' + e, true); });
|
||||
}
|
||||
|
||||
function renderProfiles() {
|
||||
var ul = document.getElementById('profileList');
|
||||
ul.innerHTML = '';
|
||||
var names = [''].concat(Object.keys(DATA.named));
|
||||
names.forEach(function (name) {
|
||||
var li = document.createElement('li');
|
||||
var displayName = name === '' ? 'default' : name;
|
||||
li.textContent = displayName + (displayName === ACTIVE ? ' *' : '');
|
||||
if (name === CURRENT) li.className = 'selected';
|
||||
li.onclick = function () { CURRENT = name; renderProfiles(); renderForm(); setStatus(''); };
|
||||
ul.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
var form = document.getElementById('form');
|
||||
form.innerHTML = '';
|
||||
document.getElementById('currentName').textContent = CURRENT === '' ? 'default (top-level)' : CURRENT;
|
||||
document.getElementById('deleteBtn').style.display = CURRENT === '' ? 'none' : '';
|
||||
var selectedName = CURRENT === '' ? 'default' : CURRENT;
|
||||
var useBtn = document.getElementById('useBtn');
|
||||
useBtn.disabled = selectedName === ACTIVE;
|
||||
useBtn.textContent = selectedName === ACTIVE ? 'Active' : 'Save & Activate';
|
||||
var data = profileData(CURRENT);
|
||||
KEYS.forEach(function (key) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
var label = document.createElement('label');
|
||||
label.textContent = key;
|
||||
label.htmlFor = 'f_' + key;
|
||||
var input = document.createElement('input');
|
||||
input.id = 'f_' + key;
|
||||
input.name = key;
|
||||
var val = data[key];
|
||||
input.value = (val === undefined || val === null) ? '' : String(val);
|
||||
if (SECRETS.indexOf(key) >= 0) {
|
||||
input.type = 'password';
|
||||
var toggle = document.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'toggle';
|
||||
toggle.textContent = 'show';
|
||||
toggle.onclick = function () {
|
||||
if (input.type === 'password') { input.type = 'text'; toggle.textContent = 'hide'; }
|
||||
else { input.type = 'password'; toggle.textContent = 'show'; }
|
||||
};
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'inputwrap';
|
||||
wrap.appendChild(input);
|
||||
wrap.appendChild(toggle);
|
||||
row.appendChild(label);
|
||||
row.appendChild(wrap);
|
||||
} else {
|
||||
input.type = 'text';
|
||||
row.appendChild(label);
|
||||
row.appendChild(input);
|
||||
}
|
||||
form.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function collect() {
|
||||
var data = {};
|
||||
KEYS.forEach(function (key) {
|
||||
var el = document.getElementById('f_' + key);
|
||||
data[key] = el ? el.value : '';
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
function saveProfile(name, data) {
|
||||
var profileData = data === undefined ? collect() : data;
|
||||
var body = JSON.stringify({ name: name, data: profileData });
|
||||
return api('/api/profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body
|
||||
})
|
||||
.then(function (response) {
|
||||
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
|
||||
})
|
||||
.then(function (result) {
|
||||
if (!result.ok) {
|
||||
throw new Error((result.json && result.json.error) || 'error');
|
||||
}
|
||||
var saved = result.json.saved || {};
|
||||
if (name === '') DATA.default = saved; else DATA.named[name] = saved;
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
function save() {
|
||||
var name = CURRENT;
|
||||
saveProfile(name)
|
||||
.then(function () {
|
||||
renderForm();
|
||||
setStatus('Saved.');
|
||||
})
|
||||
.catch(function (error) { setStatus('Save failed: ' + error.message, true); });
|
||||
}
|
||||
|
||||
function newProfile() {
|
||||
var name = prompt('New profile name (letters, numbers, - or _):');
|
||||
if (!name) return;
|
||||
if (DATA.named[name] !== undefined) {
|
||||
CURRENT = name;
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
setStatus('Profile already exists.');
|
||||
return;
|
||||
}
|
||||
setStatus('Creating profile...');
|
||||
saveProfile(name, {})
|
||||
.then(function () {
|
||||
CURRENT = name;
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
setStatus('Profile created and saved.');
|
||||
})
|
||||
.catch(function (error) { setStatus('Create failed: ' + error.message, true); });
|
||||
}
|
||||
|
||||
function saveAndActivateProfile() {
|
||||
var name = CURRENT === '' ? 'default' : CURRENT;
|
||||
var profileName = CURRENT;
|
||||
setStatus('Saving...');
|
||||
saveProfile(profileName)
|
||||
.then(function () {
|
||||
return api('/api/active', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name })
|
||||
});
|
||||
})
|
||||
.then(function (response) {
|
||||
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
|
||||
})
|
||||
.then(function (result) {
|
||||
if (!result.ok) {
|
||||
throw new Error('Saved, but activation failed: ' + ((result.json && result.json.error) || 'error'));
|
||||
}
|
||||
ACTIVE = result.json.activeProfile || 'default';
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
setStatus('Saved and activated.');
|
||||
})
|
||||
.catch(function (error) { setStatus(error.message || String(error), true); });
|
||||
}
|
||||
|
||||
function deleteProfile() {
|
||||
if (CURRENT === '') return;
|
||||
if (!confirm('Delete profile "' + CURRENT + '"?')) return;
|
||||
api('/api/profile?name=' + encodeURIComponent(CURRENT), { method: 'DELETE' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'error'); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function (result) {
|
||||
delete DATA.named[CURRENT];
|
||||
ACTIVE = result.activeProfile || 'default';
|
||||
CURRENT = '';
|
||||
renderProfiles();
|
||||
renderForm();
|
||||
setStatus('Deleted.');
|
||||
})
|
||||
.catch(function (e) { setStatus('Delete failed: ' + e, true); });
|
||||
}
|
||||
|
||||
document.getElementById('saveBtn').onclick = save;
|
||||
document.getElementById('newBtn').onclick = newProfile;
|
||||
document.getElementById('useBtn').onclick = saveAndActivateProfile;
|
||||
document.getElementById('deleteBtn').onclick = deleteProfile;
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
@@ -0,0 +1,264 @@
|
||||
import http from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
normalizeConfigName,
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
deleteConfigProfile,
|
||||
type ConfigStore,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { listenLocalServer, openInBrowser } from "../shared/local-server.ts";
|
||||
import { PAGE_HTML } from "./ui-html.ts";
|
||||
import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts";
|
||||
|
||||
const FLAGS = {
|
||||
port: {
|
||||
type: "number",
|
||||
valueHint: "<port>",
|
||||
description: "Port to listen on (default: random free port)",
|
||||
},
|
||||
noOpen: { type: "switch", description: "Do not open the browser automatically" },
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const MAX_BODY = 1 << 20; // 1 MiB
|
||||
|
||||
function errMessage(err: unknown): string {
|
||||
return err instanceof BailianError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
}
|
||||
|
||||
function sendJson(res: http.ServerResponse, status: number, obj: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > MAX_BODY) {
|
||||
reject(new Error("payload too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the request cleaned/validated config block from a posted `data` map. */
|
||||
function buildProfilePatch(data: Record<string, unknown>): Record<string, string | number> {
|
||||
const cleaned: Record<string, string | number> = {};
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
let value = "";
|
||||
if (typeof v === "string") value = v;
|
||||
else if (typeof v === "number" || typeof v === "boolean") value = String(v);
|
||||
// null/undefined/objects fall through as "" and clear the key
|
||||
if (value === "") continue;
|
||||
cleaned[resolveKey(k)] = validateAndCoerce(k, value);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** Preserve valid Config fields that the UI does not expose or manage. */
|
||||
function mergeUnmanagedProfileFields(
|
||||
existing: Record<string, unknown>,
|
||||
managedPatch: Record<string, string | number>,
|
||||
): Record<string, unknown> {
|
||||
const managedKeys = new Set<string>(VALID_KEYS);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(existing)) {
|
||||
if (!managedKeys.has(key)) merged[key] = value;
|
||||
}
|
||||
return { ...merged, ...managedPatch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the config-UI http server. Exported for tests. The handler enforces:
|
||||
* - Host header must be a loopback name (anti DNS-rebinding).
|
||||
* - every request must carry `?token=` matching the session token.
|
||||
*/
|
||||
export function createConfigUiServer(token: string, configStore: ConfigStore): http.Server {
|
||||
return http.createServer(async (req, res) => {
|
||||
try {
|
||||
const host = (req.headers.host || "").split(":")[0];
|
||||
if (host !== "127.0.0.1" && host !== "localhost") {
|
||||
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("forbidden host\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (u.searchParams.get("token") !== token) {
|
||||
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("unauthorized\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const method = req.method ?? "GET";
|
||||
const path = u.pathname;
|
||||
|
||||
if (path === "/" && method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(PAGE_HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/config" && method === "GET") {
|
||||
const profiles = configStore.profiles();
|
||||
sendJson(res, 200, {
|
||||
configFile: configStore.path,
|
||||
keys: VALID_KEYS,
|
||||
secretKeys: [...SECRET_KEYS],
|
||||
activeProfile: profiles.active,
|
||||
default: profiles.default,
|
||||
named: profiles.named,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/active" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as { name?: unknown };
|
||||
try {
|
||||
const activeProfile = await configStore.activate(body.name);
|
||||
sendJson(res, 200, { activeProfile });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/profile" && method === "POST") {
|
||||
const raw = await readBody(req);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: "invalid JSON body" });
|
||||
return;
|
||||
}
|
||||
const body = parsed as { name?: unknown; data?: unknown };
|
||||
if (!body.data || typeof body.data !== "object" || Array.isArray(body.data)) {
|
||||
sendJson(res, 400, { error: "missing or invalid 'data'" });
|
||||
return;
|
||||
}
|
||||
let normalized: string | undefined;
|
||||
let cleaned: Record<string, string | number>;
|
||||
try {
|
||||
normalized = normalizeConfigName(body.name);
|
||||
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
return;
|
||||
}
|
||||
const existing = readConfigFile(normalized) as Record<string, unknown>;
|
||||
const saved = mergeUnmanagedProfileFields(existing, cleaned);
|
||||
await writeConfigFile(saved, normalized);
|
||||
sendJson(res, 200, { saved });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/profile" && method === "DELETE") {
|
||||
try {
|
||||
const deleted = await deleteConfigProfile(u.searchParams.get("name") ?? undefined);
|
||||
sendJson(res, 200, { deleted, activeProfile: configStore.profiles().active });
|
||||
} catch (err) {
|
||||
sendJson(res, 400, { error: errMessage(err) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("not found\n");
|
||||
} catch {
|
||||
if (!res.headersSent) res.writeHead(500);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Open a local web UI to manage config profiles",
|
||||
auth: "none",
|
||||
usageArgs: "[--port <port>] [--no-open]",
|
||||
flags: FLAGS,
|
||||
exampleArgs: ["", "--port 8787", "--no-open"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: flags.port ?? "random free port",
|
||||
config_file: ctx.configStore.path,
|
||||
routes: [
|
||||
"GET / -> web UI",
|
||||
"GET /api/config -> read all profiles",
|
||||
"POST /api/profile -> save a profile",
|
||||
"POST /api/active -> activate a profile",
|
||||
"DELETE /api/profile -> delete a named profile",
|
||||
],
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const server = createConfigUiServer(token, ctx.configStore);
|
||||
|
||||
let port: number;
|
||||
try {
|
||||
port = await listenLocalServer(server, flags.port ?? 0);
|
||||
} catch (err) {
|
||||
throw new BailianError(
|
||||
`Could not bind to 127.0.0.1 (no free port or permission denied): ${errMessage(err)}`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
const url = `http://127.0.0.1:${port}/?token=${token}`;
|
||||
|
||||
if (!flags.noOpen) {
|
||||
try {
|
||||
await openInBrowser(url);
|
||||
emitBare("Opened the config UI in your default browser.");
|
||||
} catch {
|
||||
emitBare("Could not open the browser automatically. Open the URL below manually.");
|
||||
}
|
||||
}
|
||||
emitBare(`Config UI running at ${url}`);
|
||||
emitBare("Note: credentials are shown in cleartext in the browser (localhost only).");
|
||||
emitBare("Press Ctrl+C to stop.");
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const shutdown = () => server.close(() => resolve());
|
||||
process.once("SIGINT", shutdown);
|
||||
process.once("SIGTERM", shutdown);
|
||||
server.once("close", () => resolve());
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
|
||||
import { emitResult } from "bailian-cli-runtime";
|
||||
|
||||
export default defineCommand({
|
||||
description: "Set the active config profile",
|
||||
auth: "none",
|
||||
usageArgs: "--name <name>",
|
||||
flags: {
|
||||
name: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Existing profile name, or default",
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
exampleArgs: ["--name token-plan", "--name default"],
|
||||
async run(ctx) {
|
||||
const format = detectOutputFormat(ctx.settings.output);
|
||||
if (ctx.settings.dryRun) {
|
||||
const activeConfig = ctx.configStore.validateActivation(ctx.flags.name);
|
||||
emitResult(
|
||||
{
|
||||
would_activate: activeConfig,
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeConfig = await ctx.configStore.activate(ctx.flags.name);
|
||||
if (!ctx.settings.quiet) {
|
||||
emitResult(
|
||||
{
|
||||
active_config: activeConfig,
|
||||
config_file: ctx.configStore.path,
|
||||
},
|
||||
format,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resolveWatermark,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
import { downloadFile } from "bailian-cli-runtime";
|
||||
@@ -31,10 +32,15 @@ import { resolveImageSize } from "bailian-cli-runtime";
|
||||
import { join } from "path";
|
||||
import { BOOL_FLAG_PROMPT_EXTEND_CLI_TRUE, BOOL_FLAG_WATERMARK } from "bailian-cli-runtime";
|
||||
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
|
||||
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
|
||||
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
function enablesPromptExtendByDefault(model: string): boolean {
|
||||
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
const EDIT_FLAGS = {
|
||||
@@ -98,7 +104,7 @@ const EDIT_FLAGS = {
|
||||
type EditFlags = ParsedFlags<typeof EDIT_FLAGS>;
|
||||
|
||||
export default defineCommand({
|
||||
description: "Edit an existing image with text instructions (Qwen-Image)",
|
||||
description: "Edit an existing image with text instructions (Qwen-Image / Wan 2.7)",
|
||||
auth: "apiKey",
|
||||
usageArgs: "--image <url> --prompt <text> [flags]",
|
||||
flags: EDIT_FLAGS,
|
||||
@@ -107,6 +113,7 @@ export default defineCommand({
|
||||
'--image https://example.com/logo.png --prompt "Change color to blue" --n 3',
|
||||
'--image ./a.png --image ./b.png --prompt "Merge two images into one collage"',
|
||||
'--image https://example.com/photo.png --prompt "Remove the person" --model qwen-image-2.0-pro',
|
||||
'--image ./photo.png --prompt "Change the style" --model wan2.7-image',
|
||||
'--image ./photo.png --prompt "Replace the background with a beach" --watermark false',
|
||||
],
|
||||
async run(ctx) {
|
||||
@@ -125,13 +132,13 @@ export default defineCommand({
|
||||
|
||||
// Auto-upload local files (resolve all images in parallel)
|
||||
const resolvedImages = await Promise.all(
|
||||
rawImages.map((img) => ctx.client.uploadFile(img, model)),
|
||||
rawImages.map((image) => ctx.client.resolveImageInput(image, model)),
|
||||
);
|
||||
const n = flags.n ?? 1;
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
useSync ? true : undefined,
|
||||
enablesPromptExtendByDefault(model) ? true : undefined,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
@@ -169,7 +176,18 @@ export default defineCommand({
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body, mode: useSync ? "sync" : "async" }, format);
|
||||
const previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
messages: body.input.messages.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((item) =>
|
||||
item.image ? { ...item, image: redactDataUri(item.image) } : item,
|
||||
),
|
||||
})),
|
||||
},
|
||||
};
|
||||
emitResult({ request: previewBody, mode: useSync ? "sync" : "async" }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,11 +31,16 @@ import { BOOL_FLAG_PROMPT_EXTEND_IMAGE_GENERATE, BOOL_FLAG_WATERMARK } from "bai
|
||||
|
||||
import { join } from "path";
|
||||
|
||||
// qwen-image-2.0 series uses the sync multimodal-generation endpoint
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
// Qwen-Image 2.0 and Wan 2.7 use the sync multimodal-generation endpoint.
|
||||
const SYNC_MODEL_PREFIXES = ["qwen-image-2.0", "qwen-image-max", "wan2.7-image"];
|
||||
const PROMPT_EXTEND_DEFAULT_PREFIXES = ["qwen-image-2.0", "qwen-image-max"];
|
||||
|
||||
function isSyncModel(model: string): boolean {
|
||||
return SYNC_MODEL_PREFIXES.some((p) => model.startsWith(p));
|
||||
return SYNC_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
function enablesPromptExtendByDefault(model: string): boolean {
|
||||
return PROMPT_EXTEND_DEFAULT_PREFIXES.some((prefix) => model.startsWith(prefix));
|
||||
}
|
||||
|
||||
const GENERATE_FLAGS = {
|
||||
@@ -121,7 +126,7 @@ export default defineCommand({
|
||||
|
||||
const promptExtend = resolveBooleanFlag(
|
||||
flags.promptExtend,
|
||||
useSync ? true : undefined,
|
||||
enablesPromptExtendByDefault(model) ? true : undefined,
|
||||
"prompt-extend",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import http from "node:http";
|
||||
|
||||
/**
|
||||
* Bind an http server to a loopback-only TCP port and resolve the chosen port.
|
||||
* `port = 0` (default) lets the OS pick a free port. Always binds 127.0.0.1 so
|
||||
* the server is never reachable off the local machine.
|
||||
*/
|
||||
export function listenLocalServer(server: http.Server, port = 0): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const onErr = (e: Error) => reject(e);
|
||||
server.once("error", onErr);
|
||||
server.listen({ port, host: "127.0.0.1", exclusive: true }, () => {
|
||||
server.off("error", onErr);
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
reject(new Error("Expected TCP socket address"));
|
||||
return;
|
||||
}
|
||||
resolve(addr.port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
const platform = process.platform;
|
||||
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { windowsHide: true }, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
chatPath,
|
||||
parseSSE,
|
||||
detectOutputFormat,
|
||||
readTextFromPathOrStdin,
|
||||
type ChatMessage,
|
||||
type ChatRequest,
|
||||
type ChatResponse,
|
||||
@@ -69,9 +70,7 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
|
||||
}
|
||||
|
||||
if (flags.messagesFile) {
|
||||
const filePath = flags.messagesFile;
|
||||
const raw =
|
||||
filePath === "-" ? readFileSync("/dev/stdin", "utf-8") : readFileSync(filePath, "utf-8");
|
||||
const raw = readTextFromPathOrStdin(flags.messagesFile);
|
||||
const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>;
|
||||
for (const m of parsed) {
|
||||
if (m.role === "system") {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveWatermark,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
import { downloadFile, formatBytes } from "bailian-cli-runtime";
|
||||
@@ -103,8 +104,9 @@ export default defineCommand({
|
||||
|
||||
const model =
|
||||
flags.model ||
|
||||
settings.defaultVideoModel ||
|
||||
(flags.image ? "happyhorse-1.1-i2v" : "happyhorse-1.1-t2v");
|
||||
(flags.image
|
||||
? settings.defaultImageToVideoModel || "happyhorse-1.1-i2v"
|
||||
: settings.defaultVideoModel || "happyhorse-1.1-t2v");
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
const imageUrl = flags.image;
|
||||
@@ -112,7 +114,7 @@ export default defineCommand({
|
||||
// Auto-upload local image file for i2v
|
||||
let resolvedImageUrl: string | undefined;
|
||||
if (imageUrl) {
|
||||
resolvedImageUrl = await ctx.client.uploadFile(imageUrl, model);
|
||||
resolvedImageUrl = await ctx.client.resolveImageInput(imageUrl, model);
|
||||
}
|
||||
|
||||
const watermark = resolveWatermark(flags.watermark);
|
||||
@@ -139,7 +141,16 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body }, format);
|
||||
const previewBody = resolvedImageUrl
|
||||
? {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
media: [{ type: "first_frame" as const, url: redactDataUri(resolvedImageUrl) }],
|
||||
},
|
||||
}
|
||||
: body;
|
||||
emitResult({ request: previewBody }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveWatermark,
|
||||
ASYNC_FLAG,
|
||||
CONCURRENT_FLAG,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { poll } from "bailian-cli-runtime";
|
||||
import { downloadFile, formatBytes } from "bailian-cli-runtime";
|
||||
@@ -117,23 +118,23 @@ export default defineCommand({
|
||||
const imageVoices = flags.imageVoice || [];
|
||||
const videoVoices = flags.videoVoice || [];
|
||||
|
||||
const model = flags.model || "happyhorse-1.1-r2v";
|
||||
const model = flags.model || settings.defaultReferenceToVideoModel || "happyhorse-1.1-r2v";
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
// --- Resolve file URLs (auto-upload local files) ---
|
||||
const media: DashScopeVideoRefRequest["input"]["media"] = [];
|
||||
|
||||
// Add reference images
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const resolved = await ctx.client.uploadFile(images[i]!, model);
|
||||
for (let imageIndex = 0; imageIndex < images.length; imageIndex++) {
|
||||
const resolved = await ctx.client.resolveImageInput(images[imageIndex]!, model);
|
||||
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
|
||||
type: "reference_image",
|
||||
url: resolved,
|
||||
};
|
||||
|
||||
// Pair voice by position
|
||||
if (imageVoices[i]) {
|
||||
const resolvedVoice = await ctx.client.uploadFile(imageVoices[i]!, model);
|
||||
if (imageVoices[imageIndex]) {
|
||||
const resolvedVoice = await ctx.client.uploadFile(imageVoices[imageIndex]!, model);
|
||||
entry.reference_voice = resolvedVoice;
|
||||
}
|
||||
|
||||
@@ -141,16 +142,16 @@ export default defineCommand({
|
||||
}
|
||||
|
||||
// Add reference videos
|
||||
for (let i = 0; i < refVideos.length; i++) {
|
||||
const resolved = await ctx.client.uploadFile(refVideos[i]!, model);
|
||||
for (let videoIndex = 0; videoIndex < refVideos.length; videoIndex++) {
|
||||
const resolved = await ctx.client.uploadFile(refVideos[videoIndex]!, model);
|
||||
const entry: DashScopeVideoRefRequest["input"]["media"][number] = {
|
||||
type: "reference_video",
|
||||
url: resolved,
|
||||
};
|
||||
|
||||
// Pair voice by position
|
||||
if (videoVoices[i]) {
|
||||
const resolvedVoice = await ctx.client.uploadFile(videoVoices[i]!, model);
|
||||
if (videoVoices[videoIndex]) {
|
||||
const resolvedVoice = await ctx.client.uploadFile(videoVoices[videoIndex]!, model);
|
||||
entry.reference_voice = resolvedVoice;
|
||||
}
|
||||
|
||||
@@ -178,7 +179,18 @@ export default defineCommand({
|
||||
};
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult({ request: body }, format);
|
||||
const previewBody = {
|
||||
...body,
|
||||
input: {
|
||||
...body.input,
|
||||
media: body.input.media.map((item) => ({
|
||||
...item,
|
||||
url: redactDataUri(item.url),
|
||||
reference_voice: item.reference_voice ? redactDataUri(item.reference_voice) : undefined,
|
||||
})),
|
||||
},
|
||||
};
|
||||
emitResult({ request: previewBody }, format);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -233,11 +245,11 @@ export default defineCommand({
|
||||
);
|
||||
|
||||
const videos: Array<{ taskId: string; videoUrl: string }> = [];
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i]!;
|
||||
for (let resultIndex = 0; resultIndex < results.length; resultIndex++) {
|
||||
const result = results[resultIndex]!;
|
||||
const videoUrl =
|
||||
result.output.video_url || (result.output.results && result.output.results[0]?.url);
|
||||
if (videoUrl) videos.push({ taskId: taskIds[i]!, videoUrl });
|
||||
if (videoUrl) videos.push({ taskId: taskIds[resultIndex]!, videoUrl });
|
||||
}
|
||||
|
||||
if (videos.length === 0) {
|
||||
|
||||
@@ -8,18 +8,13 @@ import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
isLocalFile,
|
||||
imageFileToDataUri,
|
||||
redactDataUri,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { existsSync, statSync } from "fs";
|
||||
import { extname } from "path";
|
||||
|
||||
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"]);
|
||||
|
||||
function isVideoInput(input: string): boolean {
|
||||
@@ -35,18 +30,7 @@ async function toImageUrl(image: string): Promise<string> {
|
||||
if (image.startsWith("data:")) return image;
|
||||
if (image.startsWith("http://") || image.startsWith("https://")) return image;
|
||||
if (image.startsWith("oss://")) return image;
|
||||
|
||||
// Local file → data URI (for small files < 10MB, fallback)
|
||||
if (!existsSync(image)) throw new BailianError(`File not found: ${image}`, ExitCode.USAGE);
|
||||
const ext = extname(image).toLowerCase();
|
||||
const mime = IMAGE_MIME_TYPES[ext];
|
||||
if (!mime)
|
||||
throw new BailianError(
|
||||
`Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
const buf = readFileSync(image);
|
||||
return `data:${mime};base64,${buf.toString("base64")}`;
|
||||
return imageFileToDataUri(image);
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
@@ -86,7 +70,10 @@ export default defineCommand({
|
||||
const { settings, flags } = ctx;
|
||||
let image = flags.image;
|
||||
const videoInputs = flags.video ?? [];
|
||||
const model = flags.model || "qwen3-vl-plus";
|
||||
const model =
|
||||
flags.model ||
|
||||
(ctx.client.usesTokenPlanEndpoint() ? settings.defaultTextModel : undefined) ||
|
||||
"qwen3-vl-plus";
|
||||
|
||||
// Auto-detect: if --image was given a video file, treat it as --video
|
||||
if (image && isVideoInput(image)) {
|
||||
@@ -102,7 +89,14 @@ export default defineCommand({
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{ request: { prompt, image, video: videoInputs.length ? videoInputs : undefined, model } },
|
||||
{
|
||||
request: {
|
||||
prompt,
|
||||
image: image ? redactDataUri(image) : undefined,
|
||||
video: videoInputs.length ? videoInputs.map(redactDataUri) : undefined,
|
||||
model,
|
||||
},
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
@@ -132,10 +126,9 @@ export default defineCommand({
|
||||
|
||||
let finalImageUrl = imageUrl;
|
||||
if (isLocalFile(image) && imageUrl.startsWith("data:")) {
|
||||
const { statSync } = await import("fs");
|
||||
const fileSize = statSync(image).size;
|
||||
if (fileSize > 5 * 1024 * 1024) {
|
||||
finalImageUrl = await ctx.client.uploadFile(image, model);
|
||||
finalImageUrl = await ctx.client.resolveImageInput(image, model);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
defineCommand,
|
||||
detectOutputFormat,
|
||||
BailianError,
|
||||
ExitCode,
|
||||
generateCLIAccessToken,
|
||||
callConsoleGateway,
|
||||
effectiveConsoleGatewayConfig,
|
||||
createBailianControlUser,
|
||||
listBailianControlWorkspaces,
|
||||
resetBailianControlPolicies4Agent,
|
||||
type ConsoleGatewayTarget,
|
||||
type FlagsDef,
|
||||
} from "bailian-cli-core";
|
||||
import { emitResult, emitBare } from "bailian-cli-runtime";
|
||||
|
||||
const API = {
|
||||
loginInfo: "zeldaEasy.cornerstone-portal.cs-console.loginInfo",
|
||||
initSpace: "zeldaEasy.bailian-dash-workspace.space.initSpace",
|
||||
queryBuyResult: "zeldaEasy.bailian-commerce.bill.queryBuyPostpaidResult",
|
||||
commodityOrderInfo: "zeldaEasy.bailian-commerce.bill.postpaidCommodityOrderInfo",
|
||||
buyCommodity: "zeldaEasy.bailian-commerce.bill.buyPostpaidCommodity",
|
||||
} as const;
|
||||
|
||||
const FLAGS = {
|
||||
accessKeyId: {
|
||||
type: "string",
|
||||
valueHint: "<id>",
|
||||
description: "Alibaba Cloud Access Key ID",
|
||||
},
|
||||
accessKeySecret: {
|
||||
type: "string",
|
||||
valueHint: "<secret>",
|
||||
description: "Alibaba Cloud Access Key Secret",
|
||||
},
|
||||
securityToken: {
|
||||
type: "string",
|
||||
valueHint: "<token>",
|
||||
description: "Alibaba Cloud STS Security Token (optional)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const MAX_POLL_ATTEMPTS = 20;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function extractData(resp: any): any {
|
||||
return resp?.data?.DataV2?.data?.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the agent id from a ListWorkspaces response. Workspaces live under
|
||||
* `data.data`; the agent id is the workspace's `tenantId`. Prefer the default
|
||||
* workspace (`defaultAgent`), else fall back to the first one.
|
||||
*/
|
||||
function extractAgentId(resp: any): number | undefined {
|
||||
const workspaces = resp?.data?.data;
|
||||
if (!Array.isArray(workspaces) || workspaces.length === 0) return undefined;
|
||||
const chosen = workspaces.find((workspace) => workspace?.defaultAgent === true) ?? workspaces[0];
|
||||
const tenantId = chosen?.tenantId;
|
||||
const agentId = typeof tenantId === "string" ? Number(tenantId) : tenantId;
|
||||
return typeof agentId === "number" && Number.isFinite(agentId) ? agentId : undefined;
|
||||
}
|
||||
|
||||
interface CommodityItem {
|
||||
commodityCode?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
description: "Initialize Bailian workspace and activate postpaid services",
|
||||
auth: "none",
|
||||
usageArgs: "--access-key-id <id> --access-key-secret <secret> [--security-token <token>]",
|
||||
flags: FLAGS,
|
||||
exampleArgs: ["--access-key-id LTAIxxxxx --access-key-secret xxxxx"],
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
if (settings.dryRun) {
|
||||
emitResult(
|
||||
{
|
||||
apis: [
|
||||
{
|
||||
step: 0,
|
||||
api: "GenerateCLIAccessToken",
|
||||
description: "Generate CLI access token from AK/SK",
|
||||
},
|
||||
{
|
||||
step: 1,
|
||||
api: API.loginInfo,
|
||||
description: "Check login & workspace status",
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
api: API.initSpace,
|
||||
description: "Initialize workspace (if needed)",
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
api: "CreateUser",
|
||||
description: "Create console user via BailianControl OpenAPI (CreateUser)",
|
||||
},
|
||||
{
|
||||
step: 4,
|
||||
api: "ListWorkspaces",
|
||||
description: "List workspaces to resolve agentId",
|
||||
},
|
||||
{
|
||||
step: 5,
|
||||
api: "ResetPolicies4Agent",
|
||||
description: "Authorize user permissions",
|
||||
},
|
||||
{
|
||||
step: 6,
|
||||
api: API.queryBuyResult,
|
||||
description: "Query postpaid order status",
|
||||
},
|
||||
{
|
||||
step: 7,
|
||||
api: API.commodityOrderInfo,
|
||||
description: "Query commodity activation status",
|
||||
},
|
||||
{
|
||||
step: 8,
|
||||
api: API.buyCommodity,
|
||||
description: "Activate postpaid commodities (if needed)",
|
||||
},
|
||||
],
|
||||
},
|
||||
format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { accessKeyId, accessKeySecret } = flags;
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new BailianError(
|
||||
"workspace init requires --access-key-id and --access-key-secret.",
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
const securityToken = flags.securityToken || undefined;
|
||||
|
||||
// Step 0: Exchange AK/SK for a temporary CLI access token used by console calls.
|
||||
const tokenResp = await generateCLIAccessToken({
|
||||
identity: ctx.identity,
|
||||
settings,
|
||||
baseUrl: ctx.client.baseUrl,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
});
|
||||
const accessToken: string | undefined = tokenResp.cliAccessToken;
|
||||
if (!accessToken) {
|
||||
throw new BailianError("Failed to generate CLI access token from AK/SK.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
const gateway = effectiveConsoleGatewayConfig(settings);
|
||||
const target: ConsoleGatewayTarget = {
|
||||
region: gateway.consoleRegion,
|
||||
site: gateway.consoleSite,
|
||||
...(gateway.consoleSwitchAgent != null ? { switchAgent: gateway.consoleSwitchAgent } : {}),
|
||||
token: accessToken,
|
||||
};
|
||||
|
||||
const verbose = settings.verbose;
|
||||
const callApi = async (api: string, data: Record<string, unknown> = {}) => {
|
||||
if (verbose) process.stderr.write(`> ${api}\n`);
|
||||
try {
|
||||
const resp = await callConsoleGateway(target, settings.timeout, { api, data }, settings);
|
||||
if (verbose) process.stderr.write(`< ${JSON.stringify(resp)}\n`);
|
||||
return resp;
|
||||
} catch (err) {
|
||||
if (verbose) {
|
||||
const message =
|
||||
err instanceof BailianError ? (err.rawResponse ?? err.message) : String(err);
|
||||
process.stderr.write(`< ERROR: ${message}\n`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Step 1: Check login info
|
||||
emitBare("Checking workspace status...");
|
||||
const loginResp = await callApi(API.loginInfo);
|
||||
const loginData = extractData(loginResp);
|
||||
const spaceInited = loginData?.spaceInited === true;
|
||||
|
||||
// Step 2: Init space if needed
|
||||
if (!spaceInited) {
|
||||
emitBare("Initializing workspace...");
|
||||
await callApi(API.initSpace);
|
||||
emitBare("Workspace initialized.");
|
||||
} else {
|
||||
emitBare("Workspace already initialized.");
|
||||
}
|
||||
|
||||
// Step 3: Create console user via BailianControl OpenAPI (AK/SK signed)
|
||||
const uid = loginData?.aliyun?.uid;
|
||||
if (typeof uid !== "string" || uid.length === 0) {
|
||||
throw new BailianError("Console login info did not include aliyun.uid.", ExitCode.GENERAL);
|
||||
}
|
||||
const bailianControlAuth = {
|
||||
identity: ctx.identity,
|
||||
settings,
|
||||
baseUrl: ctx.client.baseUrl,
|
||||
regionId: gateway.consoleRegion,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
};
|
||||
|
||||
try {
|
||||
await createBailianControlUser({
|
||||
...bailianControlAuth,
|
||||
reqDTO: {
|
||||
outerKey: uid,
|
||||
nickName: uid,
|
||||
userName: uid,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Re-running workspace init is idempotent: an already-existing user is
|
||||
// not fatal, so swallow it and continue with the remaining steps.
|
||||
if (!(err instanceof BailianError) || !/already exists/i.test(err.message)) {
|
||||
throw err;
|
||||
}
|
||||
emitBare("Console user already exists, continuing.");
|
||||
}
|
||||
|
||||
// Step 4-5: Resolve the workspace agent id, then authorize user permissions.
|
||||
emitBare("Resolving workspace agent...");
|
||||
const workspacesResp = await listBailianControlWorkspaces(bailianControlAuth);
|
||||
const agentId = extractAgentId(workspacesResp);
|
||||
if (agentId == null) {
|
||||
throw new BailianError(
|
||||
"Could not resolve agentId from ListWorkspaces response.",
|
||||
ExitCode.GENERAL,
|
||||
"Re-run with --verbose to inspect the ListWorkspaces response body.",
|
||||
);
|
||||
}
|
||||
|
||||
emitBare("Authorizing user permissions...");
|
||||
await resetBailianControlPolicies4Agent({
|
||||
...bailianControlAuth,
|
||||
outerKey: uid,
|
||||
agentId,
|
||||
policyIndexList: [1],
|
||||
});
|
||||
|
||||
// Step 6-8: Order & commodity flow
|
||||
await ensureCommoditiesActive(callApi, format);
|
||||
},
|
||||
});
|
||||
|
||||
type ApiCall = (api: string, data?: Record<string, unknown>) => Promise<any>;
|
||||
|
||||
async function ensureCommoditiesActive(call: ApiCall, format: "text" | "json"): Promise<void> {
|
||||
emitBare("Checking service activation status...");
|
||||
let buyResult: string | undefined;
|
||||
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
|
||||
const resp = await call(API.queryBuyResult);
|
||||
const data = extractData(resp);
|
||||
buyResult = typeof data === "string" ? data : data?.result;
|
||||
if (buyResult !== "buying") break;
|
||||
if (i === 0) emitBare("Service activation in progress, polling...");
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (buyResult === "fail") {
|
||||
throw new BailianError("Service activation failed.", ExitCode.GENERAL);
|
||||
}
|
||||
|
||||
if (buyResult === "success") {
|
||||
await pollCommoditiesUntilActive(call, format);
|
||||
return;
|
||||
}
|
||||
|
||||
await checkAndActivateCommodities(call, format);
|
||||
}
|
||||
|
||||
function extractCommodities(resp: any): CommodityItem[] {
|
||||
const data = extractData(resp);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
async function checkAndActivateCommodities(call: ApiCall, format: "text" | "json"): Promise<void> {
|
||||
const resp = await call(API.commodityOrderInfo);
|
||||
const items = extractCommodities(resp);
|
||||
|
||||
const overdue = items.filter((c) => c.status === 11);
|
||||
if (overdue.length > 0) {
|
||||
emitBare("Warning: Some services are overdue:");
|
||||
for (const c of overdue) emitBare(` - ${c.commodityCode}`);
|
||||
}
|
||||
|
||||
const notActivated = items.filter((c) => c.status === 1);
|
||||
if (notActivated.length > 0) {
|
||||
emitBare(`Activating ${notActivated.length} postpaid services...`);
|
||||
await call(API.buyCommodity);
|
||||
await pollCommoditiesUntilActive(call, format);
|
||||
return;
|
||||
}
|
||||
|
||||
const active = items.filter((c) => c.status === 10);
|
||||
emitResult({ status: "ready", activeServices: active.map((c) => c.commodityCode) }, format);
|
||||
}
|
||||
|
||||
async function pollCommoditiesUntilActive(call: ApiCall, format: "text" | "json"): Promise<void> {
|
||||
emitBare("Waiting for services to activate...");
|
||||
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
|
||||
const resp = await call(API.commodityOrderInfo);
|
||||
const items = extractCommodities(resp);
|
||||
|
||||
const pending = items.filter((c) => c.status !== 10 && c.status !== 11);
|
||||
if (pending.length === 0) {
|
||||
const overdue = items.filter((c) => c.status === 11);
|
||||
if (overdue.length > 0) {
|
||||
emitBare("Warning: Some services are overdue:");
|
||||
for (const c of overdue) emitBare(` - ${c.commodityCode}`);
|
||||
}
|
||||
const active = items.filter((c) => c.status === 10);
|
||||
emitResult({ status: "ready", activeServices: active.map((c) => c.commodityCode) }, format);
|
||||
return;
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new BailianError("Timed out waiting for services to activate.", ExitCode.TIMEOUT);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
export { default as authLogin } from "./commands/auth/login.ts";
|
||||
export { default as authStatus } from "./commands/auth/status.ts";
|
||||
export { default as authLogout } from "./commands/auth/logout.ts";
|
||||
export { default as authGenerateAccessToken } from "./commands/auth/generate-access-token.ts";
|
||||
export { default as textChat } from "./commands/text/chat.ts";
|
||||
export { default as textOmni } from "./commands/omni/chat.ts";
|
||||
export { default as imageGenerate } from "./commands/image/generate.ts";
|
||||
@@ -18,6 +19,10 @@ export { default as videoDownload } from "./commands/video/download.ts";
|
||||
export { default as visionDescribe } from "./commands/vision/describe.ts";
|
||||
export { default as configShow } from "./commands/config/show.ts";
|
||||
export { default as configSet } from "./commands/config/set.ts";
|
||||
export { default as configList } from "./commands/config/list.ts";
|
||||
export { default as configUse } from "./commands/config/use.ts";
|
||||
export { default as configUi } from "./commands/config/ui.ts";
|
||||
export { default as configAgent } from "./commands/config/agent/index.ts";
|
||||
export { default as update } from "./commands/update.ts";
|
||||
export { default as appCall } from "./commands/app/call.ts";
|
||||
export { default as appList } from "./commands/app/list.ts";
|
||||
@@ -86,6 +91,7 @@ export { default as tokenPlanListSeats } from "./commands/token-plan/list-seats.
|
||||
export { default as tokenPlanCreateKey } from "./commands/token-plan/create-key.ts";
|
||||
export { default as tokenPlanAssignSeats } from "./commands/token-plan/assign-seats.ts";
|
||||
export { default as tokenPlanAddMember } from "./commands/token-plan/add-member.ts";
|
||||
export { default as workspaceInit } from "./commands/workspace/init.ts";
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
||||
import { tmpdir, homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
|
||||
import claudeCode from "../src/commands/config/agent/writers/claude-code.ts";
|
||||
import qwenCode from "../src/commands/config/agent/writers/qwen-code.ts";
|
||||
import opencode from "../src/commands/config/agent/writers/opencode.ts";
|
||||
import openclaw from "../src/commands/config/agent/writers/openclaw.ts";
|
||||
import hermes from "../src/commands/config/agent/writers/hermes.ts";
|
||||
import codex from "../src/commands/config/agent/writers/codex.ts";
|
||||
import yaml from "yaml";
|
||||
|
||||
/**
|
||||
* Agent writer 单元测试:直接调用 writer,用临时 HOME 隔离文件系统。
|
||||
* writer 是纯文件 I/O,在进程内测试比 e2e 子进程更快、覆盖更全。
|
||||
*/
|
||||
|
||||
const OAI_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
const ANTHROPIC_URL = "https://dashscope.aliyuncs.com/apps/anthropic";
|
||||
|
||||
let home = "";
|
||||
let prevHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "bl-agent-writer-"));
|
||||
prevHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
// homedir() 在 POSIX 读 $HOME;断言隔离生效。
|
||||
expect(homedir()).toBe(home);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function readJsonAt(...segments: string[]): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(home, ...segments), "utf8"));
|
||||
}
|
||||
|
||||
describe("config agent writers", () => {
|
||||
test("claude-code 写入 env 与 onboarding,并合并已有 env", () => {
|
||||
// 预置一个无关 env 键,验证合并保留
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".claude", "settings.json"),
|
||||
JSON.stringify({ env: { KEEP_ME: "1" }, other: true }),
|
||||
);
|
||||
|
||||
const summary = claudeCode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-a",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
expect(summary.paths).toHaveLength(2);
|
||||
|
||||
const settings = readJsonAt(".claude", "settings.json");
|
||||
const env = settings.env as Record<string, string>;
|
||||
expect(env.KEEP_ME).toBe("1");
|
||||
expect(settings.other).toBe(true);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe(ANTHROPIC_URL);
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-a");
|
||||
expect(env.ANTHROPIC_MODEL).toBe("qwen3-max");
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3-max");
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3-max");
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("qwen3-max");
|
||||
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3-max");
|
||||
|
||||
expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
test("qwen-code compatible-mode 走 openai 协议", () => {
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" });
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
const security = settings.security as { auth: Record<string, string> };
|
||||
expect(security.auth.selectedType).toBe("openai");
|
||||
expect(security.auth.apiKey).toBe("sk-q");
|
||||
expect(security.auth.baseUrl).toBe(OAI_URL);
|
||||
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
|
||||
expect((settings.model as Record<string, string>).name).toBe("qwen3-coder-plus");
|
||||
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
|
||||
expect(providers.openai[0]).toMatchObject({
|
||||
id: "qwen3-coder-plus",
|
||||
name: "bailian-cli",
|
||||
baseUrl: OAI_URL,
|
||||
envKey: "BAILIAN_CLI_API_KEY",
|
||||
});
|
||||
});
|
||||
|
||||
test("qwen-code anthropic 端点走 anthropic 协议", () => {
|
||||
qwenCode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-q", model: "qwen3-max" });
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
expect((settings.security as { auth: { selectedType: string } }).auth.selectedType).toBe(
|
||||
"anthropic",
|
||||
);
|
||||
const providers = settings.modelProviders as Record<string, unknown>;
|
||||
expect(Array.isArray(providers.anthropic)).toBe(true);
|
||||
expect(providers.openai).toBeUndefined();
|
||||
});
|
||||
|
||||
test("qwen-code 对相同 id+baseUrl 的 provider 项做 upsert 而非追加", () => {
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-1", model: "qwen3-coder-plus" });
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-2", model: "qwen3-coder-plus" });
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>).openai;
|
||||
expect(openaiEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("opencode 按端点选 npm,含 setCacheKey,合并保留其它 provider", () => {
|
||||
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".config", "opencode", "opencode.json"),
|
||||
JSON.stringify({ provider: { other: { name: "Other" } } }),
|
||||
);
|
||||
|
||||
opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
const config = readJsonAt(".config", "opencode", "opencode.json");
|
||||
const provider = config.provider as Record<string, Record<string, unknown>>;
|
||||
expect(provider.other).toBeDefined();
|
||||
expect(provider["bailian-cli"].npm).toBe("@ai-sdk/anthropic");
|
||||
const options = provider["bailian-cli"].options as Record<string, unknown>;
|
||||
expect(options.baseURL).toBe(ANTHROPIC_URL);
|
||||
expect(options.apiKey).toBe("sk-o");
|
||||
expect(options.setCacheKey).toBe(true);
|
||||
expect((provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"]).toBeDefined();
|
||||
|
||||
// 非 anthropic 端点用 openai-compatible
|
||||
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
expect(
|
||||
(
|
||||
readJsonAt(".config", "opencode", "opencode.json").provider as Record<
|
||||
string,
|
||||
{ npm: string }
|
||||
>
|
||||
)["bailian-cli"].npm,
|
||||
).toBe("@ai-sdk/openai-compatible");
|
||||
});
|
||||
|
||||
test("openclaw 写入 provider、api 与 primary", () => {
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
|
||||
const config = readJsonAt(".openclaw", "openclaw.json");
|
||||
const models = config.models as Record<string, unknown>;
|
||||
expect(models.mode).toBe("merge");
|
||||
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
|
||||
expect(bailian.api).toBe("openai-completions");
|
||||
expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus");
|
||||
const agents = config.agents as { defaults: { model: { primary: string } } };
|
||||
expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus");
|
||||
|
||||
// anthropic 端点用 anthropic-messages
|
||||
openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" });
|
||||
const config2 = readJsonAt(".openclaw", "openclaw.json");
|
||||
expect(
|
||||
((config2.models as Record<string, unknown>).providers as Record<string, { api: string }>)[
|
||||
"bailian-cli"
|
||||
].api,
|
||||
).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => {
|
||||
mkdirSync(join(home, ".hermes"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".hermes", "config.yaml"),
|
||||
yaml.stringify({ custom_providers: [{ name: "other", base_url: "https://x" }] }),
|
||||
);
|
||||
|
||||
hermes.write({ baseUrl: OAI_URL, apiKey: "sk-h", model: "qwen3-coder-plus" });
|
||||
const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
expect(config.model).toEqual({ default: "qwen3-coder-plus", provider: "bailian-cli" });
|
||||
const names = (config.custom_providers as Array<{ name: string }>).map((p) => p.name);
|
||||
expect(names).toContain("other");
|
||||
const entry = (config.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry.base_url).toBe(OAI_URL);
|
||||
expect(entry.api_key).toBe("sk-h");
|
||||
expect(entry.api_mode).toBe("chat_completions");
|
||||
|
||||
// anthropic 端点用 anthropic_messages
|
||||
hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" });
|
||||
const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
const entry2 = (config2.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry2.api_mode).toBe("anthropic_messages");
|
||||
// upsert:bailian-cli 项不重复
|
||||
expect(
|
||||
(config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("codex 写入 config.toml 与 auth.json(cc-switch 对齐结构,合并保留)", () => {
|
||||
// 预置 config.toml 无关顶层键与另一个 provider,验证非破坏性合并
|
||||
mkdirSync(join(home, ".codex"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".codex", "config.toml"),
|
||||
[
|
||||
'approval_policy = "on-request"',
|
||||
"",
|
||||
"[model_providers.other]",
|
||||
'name = "other"',
|
||||
'base_url = "https://other.example.com/v1"',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
// 预置 auth.json 无关键,验证合并保留
|
||||
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
|
||||
|
||||
codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" });
|
||||
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml).toContain('model_provider = "bailian-cli"');
|
||||
expect(toml).toContain('model = "qwen3-coder-plus"');
|
||||
expect(toml).toContain('model_reasoning_effort = "high"');
|
||||
expect(toml).toContain("disable_response_storage = true");
|
||||
expect(toml).toContain("[model_providers.bailian-cli]");
|
||||
expect(toml).toContain(`base_url = "${OAI_URL}"`);
|
||||
expect(toml).toContain('wire_api = "responses"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
// 合并:保留用户已有的无关配置
|
||||
expect(toml).toContain('approval_policy = "on-request"');
|
||||
expect(toml).toContain("[model_providers.other]");
|
||||
|
||||
const auth = readJsonAt(".codex", "auth.json");
|
||||
expect(auth.OPENAI_API_KEY).toBe("sk-x");
|
||||
expect(auth.EXISTING).toBe("keep");
|
||||
});
|
||||
|
||||
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
|
||||
mkdirSync(join(home, ".openclaw"), { recursive: true });
|
||||
writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 }));
|
||||
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" });
|
||||
const backups = readdirSync(join(home, ".openclaw")).filter((name) =>
|
||||
name.startsWith("openclaw.json.bak."),
|
||||
);
|
||||
expect(backups).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import http from "node:http";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import {
|
||||
activateConfigProfile,
|
||||
getConfigPath,
|
||||
makeConfigStore,
|
||||
writeConfigFile,
|
||||
readConfigFile,
|
||||
readConfigProfiles,
|
||||
} from "bailian-cli-core";
|
||||
import { createConfigUiServer } from "../src/commands/config/ui.ts";
|
||||
|
||||
const TOKEN = "test-token";
|
||||
|
||||
interface HttpResult {
|
||||
status: number;
|
||||
json: any;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function httpJson(
|
||||
port: number,
|
||||
method: string,
|
||||
path: string,
|
||||
opts?: { body?: unknown; headers?: Record<string, string> },
|
||||
): Promise<HttpResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = opts?.body !== undefined ? JSON.stringify(opts.body) : undefined;
|
||||
const headers: Record<string, string> = { ...opts?.headers };
|
||||
if (payload) headers["Content-Type"] = "application/json";
|
||||
const req = http.request({ host: "127.0.0.1", port, method, path, headers }, (res) => {
|
||||
let d = "";
|
||||
res.on("data", (c) => (d += c));
|
||||
res.on("end", () => {
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = d ? JSON.parse(d) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
resolve({ status: res.statusCode ?? 0, json, text: d });
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** 隔离临时配置目录 + 启动 UI server,跑完清理。 */
|
||||
async function withServer(fn: (port: number) => Promise<void>): Promise<void> {
|
||||
const saved = process.env.BAILIAN_CONFIG_DIR;
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-ui-"));
|
||||
process.env.BAILIAN_CONFIG_DIR = dir;
|
||||
const server = createConfigUiServer(TOKEN, makeConfigStore());
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
||||
const addr = server.address();
|
||||
const port = addr && typeof addr === "object" ? addr.port : 0;
|
||||
try {
|
||||
await fn(port);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR;
|
||||
else process.env.BAILIAN_CONFIG_DIR = saved;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", async () => {
|
||||
await withServer(async (port) => {
|
||||
await writeConfigFile({ api_key: "sk-default", output: "json" });
|
||||
await writeConfigFile({ api_key: "sk-dev", access_token: "tok-dev" }, "dev");
|
||||
await activateConfigProfile("dev");
|
||||
|
||||
const res = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.json.activeProfile).toBe("dev");
|
||||
expect(res.json.default).toMatchObject({ api_key: "sk-default", output: "json" });
|
||||
expect(res.json.named.dev).toMatchObject({ api_key: "sk-dev", access_token: "tok-dev" });
|
||||
expect(res.json.secretKeys).toContain("api_key");
|
||||
expect(res.json.keys).toContain("default_image_to_video_model");
|
||||
expect(res.json.keys).toContain("default_reference_to_video_model");
|
||||
});
|
||||
});
|
||||
|
||||
test("鉴权:错误 token 401、非 loopback Host 403", async () => {
|
||||
await withServer(async (port) => {
|
||||
const bad = await httpJson(port, "GET", `/api/config?token=wrong`);
|
||||
expect(bad.status).toBe(401);
|
||||
|
||||
const badHost = await httpJson(port, "GET", `/api/config?token=${TOKEN}`, {
|
||||
headers: { Host: "evil.com" },
|
||||
});
|
||||
expect(badHost.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/profile 写命名 profile(timeout 强制为 number),空串清除键", async () => {
|
||||
await withServer(async (port) => {
|
||||
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: {
|
||||
name: "stage",
|
||||
data: {
|
||||
api_key: "sk-stage",
|
||||
timeout: "90",
|
||||
base_url: "https://proxy.example.com/team/compatible-mode/v1/?x=1#fragment",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
expect(readConfigFile("stage")).toMatchObject({
|
||||
api_key: "sk-stage",
|
||||
timeout: 90,
|
||||
base_url: "https://proxy.example.com",
|
||||
});
|
||||
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
|
||||
expect(rawConfig.stage.base_url).toBe("https://proxy.example.com");
|
||||
|
||||
// 空串清除 api_key(整块替换)
|
||||
const clear = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { api_key: "", timeout: "120" } },
|
||||
});
|
||||
expect(clear.status).toBe(200);
|
||||
const after = readConfigFile("stage");
|
||||
expect(after.api_key).toBeUndefined();
|
||||
expect(after.timeout).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/profile 保留 UI 未管理字段,同时替换 UI 管理字段", async () => {
|
||||
await withServer(async (port) => {
|
||||
await writeConfigFile(
|
||||
{
|
||||
api_key: "sk-old",
|
||||
output: "json",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
},
|
||||
"stage",
|
||||
);
|
||||
|
||||
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { api_key: "sk-new" } },
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
|
||||
const profile = readConfigFile("stage");
|
||||
expect(profile).toMatchObject({
|
||||
api_key: "sk-new",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
});
|
||||
expect(profile.output).toBeUndefined();
|
||||
|
||||
const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8"));
|
||||
expect(rawConfig.stage).toMatchObject({
|
||||
api_key: "sk-new",
|
||||
console_site: "international",
|
||||
console_region: "ap-southeast-1",
|
||||
console_switch_agent: 42,
|
||||
telemetry: false,
|
||||
});
|
||||
expect(rawConfig.stage.output).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("New profile 立即保存空 Profile,其他配置读取可以看到", async () => {
|
||||
await withServer(async (port) => {
|
||||
const create = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "new-profile", data: {} },
|
||||
});
|
||||
expect(create.status).toBe(200);
|
||||
expect(create.json.saved).toEqual({});
|
||||
expect(readConfigProfiles().named["new-profile"]).toEqual({});
|
||||
|
||||
const list = await httpJson(port, "GET", `/api/config?token=${TOKEN}`);
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.json.named["new-profile"]).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/profile 非法 key 返回 400", async () => {
|
||||
await withServer(async (port) => {
|
||||
const res = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { not_a_key: "x" } },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.json.error)).toMatch(/Invalid config key/);
|
||||
});
|
||||
});
|
||||
|
||||
test("DELETE /api/profile 删命名 profile;缺 name 返回 400", async () => {
|
||||
await withServer(async (port) => {
|
||||
await writeConfigFile({ api_key: "sk-stage" }, "stage");
|
||||
const del = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.json.deleted).toBe(true);
|
||||
expect(readConfigProfiles().named.stage).toBeUndefined();
|
||||
|
||||
const noName = await httpJson(port, "DELETE", `/api/profile?token=${TOKEN}`);
|
||||
expect(noName.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test("Save & Activate 创建并激活 Profile;删除激活项后切回 default", async () => {
|
||||
await withServer(async (port) => {
|
||||
const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, {
|
||||
body: { name: "stage", data: { api_key: "sk-stage" } },
|
||||
});
|
||||
expect(save.status).toBe(200);
|
||||
|
||||
const activate = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
|
||||
body: { name: "stage" },
|
||||
});
|
||||
expect(activate.status).toBe(200);
|
||||
expect(activate.json.activeProfile).toBe("stage");
|
||||
expect(readConfigProfiles().active).toBe("stage");
|
||||
|
||||
const missing = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, {
|
||||
body: { name: "missing" },
|
||||
});
|
||||
expect(missing.status).toBe(400);
|
||||
expect(readConfigProfiles().active).toBe("stage");
|
||||
|
||||
const deleted = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`);
|
||||
expect(deleted.status).toBe(200);
|
||||
expect(deleted.json.activeProfile).toBe("default");
|
||||
expect(readConfigProfiles().active).toBe("default");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,62 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
isDashScopeE2EReady,
|
||||
isOpenApiE2EReady,
|
||||
makeE2eOutputDir,
|
||||
parseStdoutJson,
|
||||
runCommandE2e,
|
||||
} from "./helpers.ts";
|
||||
import { AUTH_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
/**
|
||||
* Auth 相关 E2E:只验证 CLI 进程能正常解析参数并退出。
|
||||
*/
|
||||
interface ValidationServer {
|
||||
baseUrl: string;
|
||||
requests: Array<{
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
authorization?: string;
|
||||
sourceConfig?: string;
|
||||
}>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function startValidationServer(statusCode = 200): Promise<ValidationServer> {
|
||||
const requests: ValidationServer["requests"] = [];
|
||||
const server = http.createServer((request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
requests.push({
|
||||
path: request.url ?? "",
|
||||
body: rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : {},
|
||||
authorization: request.headers.authorization,
|
||||
sourceConfig: request.headers["x-dashscope-source-config"] as string | undefined,
|
||||
});
|
||||
response.writeHead(statusCode, { "Content-Type": "application/json" });
|
||||
if (statusCode >= 400) {
|
||||
response.end(JSON.stringify({ code: "InvalidApiKey", message: "invalid key" }));
|
||||
return;
|
||||
}
|
||||
response.end(
|
||||
JSON.stringify({ choices: [{ message: { role: "assistant", content: "ok" } }] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address() as AddressInfo;
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
/** Auth E2E:本地参数/持久化契约默认执行;真实鉴权请求按对应 readiness gate 执行。 */
|
||||
|
||||
describe("e2e: auth", () => {
|
||||
test("auth login --help 正常退出", async () => {
|
||||
@@ -78,6 +123,35 @@ describe("e2e: auth", () => {
|
||||
expect(stderr).toMatch(/Provide --access-key-id and --access-key-secret with --open-api/);
|
||||
});
|
||||
|
||||
test("auth login --open-api --dry-run 使用 placeholder 时不请求服务端、不写配置", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-auth-openapi-dry-run-"));
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"--open-api",
|
||||
"--access-key-id",
|
||||
"LTAI-e2e-placeholder",
|
||||
"--access-key-secret",
|
||||
"secret-e2e-placeholder",
|
||||
"--dry-run",
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stdout).toContain("Would save OpenAPI AK/SK credentials");
|
||||
expect(existsSync(join(configDir, "config.json"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("auth logout --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
@@ -108,6 +182,247 @@ describe("e2e: auth", () => {
|
||||
expect(stdout).toContain("Would validate and save API key.");
|
||||
});
|
||||
|
||||
test("auth login --dry-run 仍校验显式 Base URL", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
"login",
|
||||
"--dry-run",
|
||||
"--api-key",
|
||||
"sk-e2e-dry-run-placeholder",
|
||||
"--base-url",
|
||||
"ftp://example.com/models",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid model base URL/);
|
||||
});
|
||||
|
||||
test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => {
|
||||
const validationServer = await startValidationServer();
|
||||
const configDir = makeE2eOutputDir("auth-api-key-login");
|
||||
const sdkBaseUrl = `${validationServer.baseUrl}/compatible-mode/v1/?source=login#fragment`;
|
||||
try {
|
||||
const login = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
["auth", "login", "--api-key", "sk-e2e-placeholder", "--base-url", sdkBaseUrl],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
expect(login.exitCode, login.stderr).toBe(0);
|
||||
expect(validationServer.requests).toHaveLength(1);
|
||||
expect(validationServer.requests[0]).toMatchObject({
|
||||
path: "/compatible-mode/v1/chat/completions",
|
||||
authorization: "Bearer sk-e2e-placeholder",
|
||||
sourceConfig: expect.any(String),
|
||||
body: {
|
||||
model: "qwen3.7-max",
|
||||
stream: false,
|
||||
enable_thinking: false,
|
||||
},
|
||||
});
|
||||
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(config.api_key).toBe("sk-e2e-placeholder");
|
||||
expect(config.base_url).toBe(validationServer.baseUrl);
|
||||
} finally {
|
||||
await validationServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth login --config token-plan 接受 Anthropic SDK Base URL", async () => {
|
||||
const validationServer = await startValidationServer();
|
||||
const configDir = makeE2eOutputDir("auth-token-plan-anthropic-base-url");
|
||||
try {
|
||||
const login = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"--config",
|
||||
"token-plan",
|
||||
"--api-key",
|
||||
"sk-sp-e2e-placeholder",
|
||||
"--base-url",
|
||||
`${validationServer.baseUrl}/apps/anthropic?source=sdk#fragment`,
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
expect(login.exitCode, login.stderr).toBe(0);
|
||||
expect(validationServer.requests).toHaveLength(1);
|
||||
expect(validationServer.requests[0].path).toBe("/compatible-mode/v1/chat/completions");
|
||||
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(config["token-plan"]).toMatchObject({
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: validationServer.baseUrl,
|
||||
default_text_model: "qwen3.8-max-preview",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_image_to_video_model: "happyhorse-1.1-i2v",
|
||||
default_reference_to_video_model: "happyhorse-1.1-r2v",
|
||||
default_image_model: "wan2.7-image",
|
||||
});
|
||||
} finally {
|
||||
await validationServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth login --config token-plan 物化并重置内置预设", async () => {
|
||||
const validationServer = await startValidationServer();
|
||||
const configDir = makeE2eOutputDir("auth-token-plan-preset-login");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"token-plan": {
|
||||
default_text_model: "custom-text-model",
|
||||
default_video_model: "custom-video-model",
|
||||
default_image_to_video_model: "custom-image-to-video-model",
|
||||
default_reference_to_video_model: "custom-reference-to-video-model",
|
||||
default_image_model: "custom-image-model",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
try {
|
||||
const login = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
["auth", "login", "--config", "token-plan", "--api-key", "sk-sp-e2e-placeholder"],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "sk-env-must-not-be-persisted",
|
||||
DASHSCOPE_BASE_URL: validationServer.baseUrl,
|
||||
},
|
||||
);
|
||||
expect(login.exitCode, login.stderr).toBe(0);
|
||||
expect(validationServer.requests).toHaveLength(1);
|
||||
expect(validationServer.requests[0]).toMatchObject({
|
||||
path: "/compatible-mode/v1/chat/completions",
|
||||
authorization: "Bearer sk-sp-e2e-placeholder",
|
||||
sourceConfig: expect.any(String),
|
||||
body: {
|
||||
model: "qwen3.8-max-preview",
|
||||
stream: false,
|
||||
enable_thinking: true,
|
||||
},
|
||||
});
|
||||
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(config.api_key).toBeUndefined();
|
||||
expect(config.active_config).toBe("token-plan");
|
||||
expect(config["token-plan"]).toMatchObject({
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_text_model: "qwen3.8-max-preview",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_image_to_video_model: "happyhorse-1.1-i2v",
|
||||
default_reference_to_video_model: "happyhorse-1.1-r2v",
|
||||
default_image_model: "wan2.7-image",
|
||||
});
|
||||
expect((config["token-plan"] as Record<string, unknown>).base_url).not.toBe(
|
||||
validationServer.baseUrl,
|
||||
);
|
||||
expect((config["token-plan"] as Record<string, unknown>).api_key).not.toBe(
|
||||
"sk-env-must-not-be-persisted",
|
||||
);
|
||||
} finally {
|
||||
await validationServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth login 未传 --config 时写当前激活 Config", async () => {
|
||||
const validationServer = await startValidationServer();
|
||||
const configDir = makeE2eOutputDir("auth-active-profile-login");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
active_config: "dev",
|
||||
dev: { base_url: validationServer.baseUrl },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
const env = {
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
};
|
||||
try {
|
||||
const activeLogin = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
["auth", "login", "--api-key", "sk-active-placeholder"],
|
||||
env,
|
||||
);
|
||||
expect(activeLogin.exitCode, activeLogin.stderr).toBe(0);
|
||||
|
||||
expect(validationServer.requests).toHaveLength(1);
|
||||
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(config.api_key).toBeUndefined();
|
||||
expect(config.active_config).toBe("dev");
|
||||
expect(config.dev).toMatchObject({
|
||||
api_key: "sk-active-placeholder",
|
||||
base_url: validationServer.baseUrl,
|
||||
});
|
||||
} finally {
|
||||
await validationServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth login --api-key 验证失败不留下半配置", async () => {
|
||||
const validationServer = await startValidationServer(400);
|
||||
const configDir = makeE2eOutputDir("auth-api-key-login-failure");
|
||||
try {
|
||||
const login = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"--config",
|
||||
"failed-profile",
|
||||
"--api-key",
|
||||
"sk-invalid",
|
||||
"--base-url",
|
||||
validationServer.baseUrl,
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
expect(login.exitCode).not.toBe(0);
|
||||
expect(login.stderr).toMatch(/invalid key/);
|
||||
expect(login.stderr).not.toMatch(/API key validation failed|Invalid API key/);
|
||||
expect(existsSync(join(configDir, "config.json"))).toBe(false);
|
||||
} finally {
|
||||
await validationServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("auth login --dry-run 覆盖全局参数 --output json --timeout", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
@@ -172,6 +487,38 @@ describe("e2e: auth", () => {
|
||||
expect(stderr).not.toContain("Cleared api_key");
|
||||
});
|
||||
|
||||
test("auth logout 清除当前 Config 的全部凭证和 Base URL,保留普通配置", async () => {
|
||||
const configDir = makeE2eOutputDir("auth-logout-all");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
api_key: "sk-e2e-placeholder",
|
||||
base_url: "https://model.example.com",
|
||||
access_token: "console-token-placeholder",
|
||||
access_key_id: "LTAI-e2e-placeholder",
|
||||
access_key_secret: "secret-e2e-placeholder",
|
||||
security_token: "sts-e2e-placeholder",
|
||||
output: "json",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
const { stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, ["auth", "logout"], {
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
});
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toContain("api_key / base_url / access_token");
|
||||
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(config).toEqual({ output: "json" });
|
||||
});
|
||||
|
||||
test.skipIf(!isDashScopeE2EReady())("auth status 文本输出", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(AUTH_ROUTES, [
|
||||
"auth",
|
||||
@@ -251,57 +598,73 @@ describe("e2e: auth", () => {
|
||||
expect(denied.stderr).toMatch(/Unknown flag.*--access-key-id/);
|
||||
});
|
||||
|
||||
test("auth login --open-api 持久化 OpenAPI AK/SK 并支持单独 logout", async () => {
|
||||
const configDir = makeE2eOutputDir("auth-openapi-login");
|
||||
const env = {
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
};
|
||||
test.skipIf(!isOpenApiE2EReady())(
|
||||
"auth login --open-api 使用环境中的真实 AK/SK,持久化后支持单独 logout",
|
||||
async () => {
|
||||
const accessKeyId = process.env.ALIBABA_CLOUD_ACCESS_KEY_ID!.trim();
|
||||
const accessKeySecret = process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET!.trim();
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-auth-openapi-login-"));
|
||||
const env = {
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID: "",
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "",
|
||||
};
|
||||
|
||||
const login = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"--open-api",
|
||||
"--access-key-id",
|
||||
"LTAI-e2e-login-placeholder",
|
||||
"--access-key-secret",
|
||||
"secret-e2e-login-placeholder",
|
||||
],
|
||||
env,
|
||||
);
|
||||
expect(login.exitCode, login.stderr).toBe(0);
|
||||
expect(login.stderr).toMatch(/OpenAPI credentials saved/);
|
||||
try {
|
||||
const login = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
[
|
||||
"auth",
|
||||
"login",
|
||||
"--open-api",
|
||||
"--access-key-id",
|
||||
accessKeyId,
|
||||
"--access-key-secret",
|
||||
accessKeySecret,
|
||||
],
|
||||
env,
|
||||
);
|
||||
expect(login.exitCode, login.stderr).toBe(0);
|
||||
expect(login.stderr).toMatch(/OpenAPI credentials saved/);
|
||||
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(config.access_key_id).toBe("LTAI-e2e-login-placeholder");
|
||||
expect(config.access_key_secret).toBe("secret-e2e-login-placeholder");
|
||||
expect(config.openapi_access_key_id).toBeUndefined();
|
||||
expect(config.openapi_access_key_secret).toBeUndefined();
|
||||
const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// 只断言布尔结果,避免失败 diff 把真实凭证打印到测试日志。
|
||||
expect(config.access_key_id === accessKeyId).toBe(true);
|
||||
expect(config.access_key_secret === accessKeySecret).toBe(true);
|
||||
expect(config.openapi_access_key_id).toBeUndefined();
|
||||
expect(config.openapi_access_key_secret).toBeUndefined();
|
||||
|
||||
const status = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
|
||||
expect(status.exitCode, status.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
authenticated?: boolean;
|
||||
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
|
||||
}>(status.stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.openapi?.source).toBe("config");
|
||||
expect(data.openapi?.access_key_id).not.toBe("LTAI-e2e-login-placeholder");
|
||||
expect(data.openapi?.access_key_secret).not.toBe("secret-e2e-login-placeholder");
|
||||
const status = await runCommandE2e(
|
||||
AUTH_ROUTES,
|
||||
["auth", "status", "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(status.exitCode, status.stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
authenticated?: boolean;
|
||||
openapi?: { source?: string; access_key_id?: string; access_key_secret?: string };
|
||||
}>(status.stdout);
|
||||
expect(data.authenticated).toBe(true);
|
||||
expect(data.openapi?.source).toBe("config");
|
||||
expect(data.openapi?.access_key_id === accessKeyId).toBe(false);
|
||||
expect(data.openapi?.access_key_secret === accessKeySecret).toBe(false);
|
||||
|
||||
const logout = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--open-api"], env);
|
||||
expect(logout.exitCode, logout.stderr).toBe(0);
|
||||
expect(logout.stderr).toMatch(/Cleared access_key_id/);
|
||||
const logout = await runCommandE2e(AUTH_ROUTES, ["auth", "logout", "--open-api"], env);
|
||||
expect(logout.exitCode, logout.stderr).toBe(0);
|
||||
expect(logout.stderr).toMatch(/Cleared access_key_id/);
|
||||
|
||||
const after = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
|
||||
expect(after.exitCode, after.stderr).toBe(0);
|
||||
const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(after.stdout);
|
||||
expect(afterData.openapi).toBeUndefined();
|
||||
});
|
||||
const after = await runCommandE2e(AUTH_ROUTES, ["auth", "status", "--output", "json"], env);
|
||||
expect(after.exitCode, after.stderr).toBe(0);
|
||||
const afterData = parseStdoutJson<{ authenticated?: boolean; openapi?: unknown }>(
|
||||
after.stdout,
|
||||
);
|
||||
expect(afterData.openapi).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { CONFIG_ROUTES } from "./topic-routes.ts";
|
||||
@@ -19,6 +22,36 @@ describe("e2e: config", () => {
|
||||
expect(stderr).toMatch(/set|--key|--value/i);
|
||||
});
|
||||
|
||||
test("config list/use --help 正常退出", async () => {
|
||||
const listResult = await runCommandE2e(CONFIG_ROUTES, ["config", "list", "--help"]);
|
||||
expect(listResult.exitCode, listResult.stderr).toBe(0);
|
||||
expect(listResult.stderr).toMatch(/list|active|profile/i);
|
||||
|
||||
const useResult = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--help"]);
|
||||
expect(useResult.exitCode, useResult.stderr).toBe(0);
|
||||
expect(useResult.stderr).toMatch(/use|--name|active/i);
|
||||
});
|
||||
|
||||
test("config ui --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "ui", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/ui|--port|--no-open|web/i);
|
||||
});
|
||||
|
||||
test("config ui --dry-run 打印计划不起服务", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"ui",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ host?: string; routes?: string[] }>(stdout);
|
||||
expect(data.host).toBe("127.0.0.1");
|
||||
expect(Array.isArray(data.routes)).toBe(true);
|
||||
});
|
||||
|
||||
test("config show --output json", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
@@ -48,12 +81,114 @@ describe("e2e: config", () => {
|
||||
expect(stdout).toMatch(/config_file|timeout|base_url/i);
|
||||
});
|
||||
|
||||
test("config show 脱敏 security_token", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-show-secret-"));
|
||||
try {
|
||||
const securityToken = "sts-sensitive-token";
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify({ security_token: securityToken }, null, 2) + "\n",
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "show", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ security_token?: string }>(stdout);
|
||||
expect(data.security_token).toBe("sts-...oken");
|
||||
expect(stdout).not.toContain(securityToken);
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/--key|--value|Usage:/i);
|
||||
});
|
||||
|
||||
test("config use 缺少 --name 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--quiet"]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/--name|Usage:/i);
|
||||
});
|
||||
|
||||
test("config use 持久化激活项,config list 展示激活状态", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
|
||||
const env = { BAILIAN_CONFIG_DIR: configDir };
|
||||
|
||||
const useResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "dev", "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(useResult.exitCode, useResult.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe(
|
||||
"dev",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev");
|
||||
|
||||
const listResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "list", "--output", "json"],
|
||||
env,
|
||||
);
|
||||
expect(listResult.exitCode, listResult.stderr).toBe(0);
|
||||
const listData = parseStdoutJson<{
|
||||
active_config?: string;
|
||||
profiles?: string[];
|
||||
}>(listResult.stdout);
|
||||
expect(listData.active_config).toBe("dev");
|
||||
expect(listData.profiles).toEqual(["default", "dev"]);
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config use --dry-run 校验目标但不修改激活项", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-dry-run-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
|
||||
const result = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "dev", "--dry-run", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ would_activate?: string }>(result.stdout).would_activate).toBe(
|
||||
"dev",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config use 拒绝不存在的 Profile 且不写入状态", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-missing-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ output: "text" }, null, 2) + "\n");
|
||||
const result = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "missing", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toMatch(/does not exist/);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config set 非法 key 时退出为用法错误", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
@@ -93,6 +228,43 @@ describe("e2e: config", () => {
|
||||
expect(stderr).toMatch(/Invalid timeout|positive/i);
|
||||
});
|
||||
|
||||
test("config set 归一化 Base URL 并拒绝非法协议", async () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-base-url-"));
|
||||
try {
|
||||
const setResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"base_url",
|
||||
"--value",
|
||||
"https://proxy.example.com/bailian/compatible-mode/v1/?x=1#fragment",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(setResult.exitCode, setResult.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe(
|
||||
"https://proxy.example.com",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe(
|
||||
"https://proxy.example.com",
|
||||
);
|
||||
|
||||
const invalidResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "set", "--key", "base_url", "--value", "ftp://example.com/models"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(invalidResult.exitCode).toBe(2);
|
||||
expect(invalidResult.stderr).toMatch(/Invalid model base URL/);
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config set --dry-run 不落盘(仅输出 would_set)", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
@@ -127,6 +299,61 @@ describe("e2e: config", () => {
|
||||
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持图生视频默认模型别名", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"default-image-to-video-model",
|
||||
"--value",
|
||||
"happyhorse-1.1-i2v",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
would_set?: { default_image_to_video_model?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_set?.default_image_to_video_model).toBe("happyhorse-1.1-i2v");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持参考生视频默认模型别名", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"default-reference-to-video-model",
|
||||
"--value",
|
||||
"happyhorse-1.1-r2v",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
would_set?: { default_reference_to_video_model?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_set?.default_reference_to_video_model).toBe("happyhorse-1.1-r2v");
|
||||
});
|
||||
|
||||
test("config set --dry-run 展示归一化后的 Base URL", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--dry-run",
|
||||
"--key",
|
||||
"base-url",
|
||||
"--value",
|
||||
"https://proxy.example.com/apps/anthropic/?x=1#fragment",
|
||||
"--output",
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { base_url?: string } }>(stdout);
|
||||
expect(data.would_set?.base_url).toBe("https://proxy.example.com");
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持 AccessKey 短字段别名", async () => {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
@@ -156,4 +383,141 @@ describe("e2e: config", () => {
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/Invalid config key|openapi_access_key_id/);
|
||||
});
|
||||
|
||||
test("config agent --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "agent", "--help"]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/agent|--base-url|--model/i);
|
||||
});
|
||||
|
||||
test("config agent 缺少 --api-key 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"claude-code",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/--api-key|Usage:/i);
|
||||
});
|
||||
|
||||
test("config agent 非法 --agent 时退出为用法错误 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"not-an-agent",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"--api-key",
|
||||
"sk-placeholder",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
]);
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stderr).toMatch(/not-an-agent|claude-code|agent/i);
|
||||
});
|
||||
|
||||
test("config agent --dry-run 输出脱敏信息且不写盘", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-dry-"));
|
||||
try {
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"claude-code",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
"--api-key",
|
||||
"sk-secret-placeholder",
|
||||
"--model",
|
||||
"qwen3-max",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
agent?: string;
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
api_key?: string;
|
||||
}>(stdout);
|
||||
expect(data.agent).toBe("claude-code");
|
||||
expect(data.base_url).toBe("https://dashscope.aliyuncs.com/apps/anthropic");
|
||||
expect(data.model).toBe("qwen3-max");
|
||||
expect(stdout).not.toContain("sk-secret-placeholder");
|
||||
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent codex 写入 config.toml 与 auth.json(cc-switch 对齐结构)", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-codex-"));
|
||||
try {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"codex",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"--api-key",
|
||||
"sk-codex-placeholder",
|
||||
"--model",
|
||||
"qwen3-coder-plus",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml).toContain('model_provider = "bailian-cli"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
expect(toml).toContain('wire_api = "responses"');
|
||||
const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8"));
|
||||
expect(auth.OPENAI_API_KEY).toBe("sk-codex-placeholder");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent hermes 写入 custom_providers 结构", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-hermes-"));
|
||||
try {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
[
|
||||
"config",
|
||||
"agent",
|
||||
"--agent",
|
||||
"hermes",
|
||||
"--base-url",
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"--api-key",
|
||||
"sk-hermes-placeholder",
|
||||
"--model",
|
||||
"qwen3-coder-plus",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8");
|
||||
expect(yamlText).toContain("custom_providers");
|
||||
expect(yamlText).toContain("bailian-cli");
|
||||
expect(yamlText).toContain("api_mode: chat_completions");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
isChatE2EReady,
|
||||
isConsoleE2EReady,
|
||||
isDashScopeE2EReady,
|
||||
isOpenApiE2EReady,
|
||||
isSearchE2EReady,
|
||||
} from "e2e/gating";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { join } from "path";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
e2eFixturesDir,
|
||||
e2eLabelFromMetaUrl,
|
||||
@@ -46,6 +47,55 @@ describe("e2e: image edit", () => {
|
||||
expect(data.mode).toBe("async");
|
||||
expect(data.request?.input?.messages?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Token Plan 使用 Base64 传入 wan2.7-image 本地图片", async () => {
|
||||
const configDir = makeE2eOutputDir("image-edit-token-plan-local-image");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify({
|
||||
"token-plan": {
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_image_model: "wan2.7-image",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
IMAGE_ROUTES,
|
||||
[
|
||||
"image",
|
||||
"edit",
|
||||
"--config",
|
||||
"token-plan",
|
||||
"--image",
|
||||
join(e2eFixturesDir, ".smoke-32.png"),
|
||||
"--prompt",
|
||||
"改成蓝色",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
mode?: string;
|
||||
request?: {
|
||||
model?: string;
|
||||
input?: { messages?: Array<{ content?: Array<{ image?: string }> }> };
|
||||
};
|
||||
}>(stdout);
|
||||
expect(data.mode).toBe("sync");
|
||||
expect(data.request?.model).toBe("wan2.7-image");
|
||||
expect(data.request?.input?.messages?.[0]?.content?.[0]?.image).toBe(
|
||||
"data:image/png;base64,<omitted>",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())("e2e: image edit", () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
e2eLabelFromMetaUrl,
|
||||
isBailianE2EMediaEnabled,
|
||||
@@ -21,6 +23,44 @@ describe("e2e: image generate", () => {
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/generate|--prompt|--model/i);
|
||||
});
|
||||
|
||||
test("Token Plan 默认使用 wan2.7-image 同步接口", async () => {
|
||||
const configDir = makeE2eOutputDir("image-generate-token-plan-default");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify({
|
||||
"token-plan": {
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_image_model: "wan2.7-image",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
IMAGE_ROUTES,
|
||||
[
|
||||
"image",
|
||||
"generate",
|
||||
"--config",
|
||||
"token-plan",
|
||||
"--prompt",
|
||||
"一只猫",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ mode?: string; request?: { model?: string } }>(stdout);
|
||||
expect(data.mode).toBe("sync");
|
||||
expect(data.request?.model).toBe("wan2.7-image");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EMediaEnabled() || !isDashScopeE2EReady())(
|
||||
|
||||
@@ -15,6 +15,10 @@ export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" };
|
||||
export const CONFIG_ROUTES: E2eRouteExports = {
|
||||
"config show": "configShow",
|
||||
"config set": "configSet",
|
||||
"config list": "configList",
|
||||
"config use": "configUse",
|
||||
"config ui": "configUi",
|
||||
"config agent": "configAgent",
|
||||
};
|
||||
|
||||
export const MEMORY_ROUTES: E2eRouteExports = {
|
||||
@@ -55,6 +59,8 @@ export const VIDEO_ROUTES: E2eRouteExports = {
|
||||
"video download": "videoDownload",
|
||||
};
|
||||
|
||||
export const VISION_ROUTES: E2eRouteExports = { "vision describe": "visionDescribe" };
|
||||
|
||||
export const SPEECH_ROUTES: E2eRouteExports = {
|
||||
"speech synthesize": "speechSynthesize",
|
||||
"speech recognize": "speechRecognize",
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
runCommandE2e,
|
||||
} from "./helpers.ts";
|
||||
import { USAGE_ROUTES } from "./topic-routes.ts";
|
||||
import { readConfigFile } from "bailian-cli-core";
|
||||
import { buildSources } from "bailian-cli-core";
|
||||
|
||||
function getStaticWorkspaceId(): string | undefined {
|
||||
if (process.env.BAILIAN_WORKSPACE_ID?.trim()) return process.env.BAILIAN_WORKSPACE_ID.trim();
|
||||
try {
|
||||
const config = readConfigFile();
|
||||
const config = buildSources({}).file;
|
||||
if (config.workspace_id) return config.workspace_id;
|
||||
} catch {}
|
||||
return undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
@@ -21,6 +22,96 @@ describe("e2e: video generate (i2v)", () => {
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/generate|--prompt|--image|model/i);
|
||||
});
|
||||
|
||||
test("Token Plan 使用独立的图生视频默认模型", async () => {
|
||||
const configDir = makeE2eOutputDir("video-i2v-token-plan-default");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"token-plan": {
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_video_model: "happyhorse-1.1-t2v",
|
||||
default_image_to_video_model: "custom-image-to-video-model",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
VIDEO_ROUTES,
|
||||
[
|
||||
"video",
|
||||
"generate",
|
||||
"--config",
|
||||
"token-plan",
|
||||
"--dry-run",
|
||||
"--image",
|
||||
"https://example.com/placeholder.png",
|
||||
"--prompt",
|
||||
"干跑校验",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
request?: { model?: string; input?: { media?: Array<{ type?: string }> } };
|
||||
}>(stdout);
|
||||
expect(data.request?.model).toBe("custom-image-to-video-model");
|
||||
expect(data.request?.input?.media?.[0]?.type).toBe("first_frame");
|
||||
});
|
||||
|
||||
test("Token Plan 图生视频将本地首帧转换为 Base64", async () => {
|
||||
const configDir = makeE2eOutputDir("video-i2v-token-plan-local-image");
|
||||
const imagePath = join(configDir, "first-frame.png");
|
||||
writeFileSync(imagePath, Buffer.from([1, 2, 3]));
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify({
|
||||
"token-plan": {
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_image_to_video_model: "happyhorse-1.1-i2v",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
VIDEO_ROUTES,
|
||||
[
|
||||
"video",
|
||||
"generate",
|
||||
"--config",
|
||||
"token-plan",
|
||||
"--dry-run",
|
||||
"--image",
|
||||
imagePath,
|
||||
"--prompt",
|
||||
"让画面动起来",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
request?: { input?: { media?: Array<{ url?: string }> } };
|
||||
}>(stdout);
|
||||
expect(data.request?.input?.media?.[0]?.url).toBe("data:image/png;base64,<omitted>");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
@@ -43,6 +44,92 @@ describe("e2e: video ref (r2v)", () => {
|
||||
);
|
||||
expect(data.request?.input?.media?.[0]?.url).toBe("https://example.com/person.png");
|
||||
});
|
||||
|
||||
test("Token Plan 使用独立的参考生视频默认模型", async () => {
|
||||
const configDir = makeE2eOutputDir("video-r2v-token-plan-default");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
"token-plan": {
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_reference_to_video_model: "custom-reference-to-video-model",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
VIDEO_ROUTES,
|
||||
[
|
||||
"video",
|
||||
"ref",
|
||||
"--config",
|
||||
"token-plan",
|
||||
"--dry-run",
|
||||
"--prompt",
|
||||
"Image 1 waves",
|
||||
"--image",
|
||||
"https://example.com/person.png",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ request?: { model?: string } }>(stdout);
|
||||
expect(data.request?.model).toBe("custom-reference-to-video-model");
|
||||
});
|
||||
|
||||
test("Token Plan 参考生视频将本地参考图转换为 Base64", async () => {
|
||||
const configDir = makeE2eOutputDir("video-r2v-token-plan-local-image");
|
||||
const imagePath = join(configDir, "reference.png");
|
||||
writeFileSync(imagePath, Buffer.from([1, 2, 3]));
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify({
|
||||
"token-plan": {
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_reference_to_video_model: "happyhorse-1.1-r2v",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
VIDEO_ROUTES,
|
||||
[
|
||||
"video",
|
||||
"ref",
|
||||
"--config",
|
||||
"token-plan",
|
||||
"--dry-run",
|
||||
"--image",
|
||||
imagePath,
|
||||
"--prompt",
|
||||
"Image 1 waves",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{
|
||||
request?: { input?: { media?: Array<{ url?: string }> } };
|
||||
}>(stdout);
|
||||
expect(data.request?.input?.media?.[0]?.url).toBe("data:image/png;base64,<omitted>");
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!isBailianE2EVideoEnabled() || !isDashScopeE2EReady())(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { makeE2eOutputDir, parseStdoutJson, runCommandE2e } from "./helpers.ts";
|
||||
import { VISION_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: vision describe", () => {
|
||||
test("Token Plan 默认使用支持视觉理解的文本模型", async () => {
|
||||
const configDir = makeE2eOutputDir("vision-describe-token-plan-default");
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify({
|
||||
"token-plan": {
|
||||
api_key: "sk-sp-e2e-placeholder",
|
||||
base_url: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
default_text_model: "qwen3.8-max-preview",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const { stdout, stderr, exitCode } = await runCommandE2e(
|
||||
VISION_ROUTES,
|
||||
[
|
||||
"vision",
|
||||
"describe",
|
||||
"--config",
|
||||
"token-plan",
|
||||
"--image",
|
||||
"https://example.com/image.png",
|
||||
"--dry-run",
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
BAILIAN_CONFIG_DIR: configDir,
|
||||
DASHSCOPE_API_KEY: "",
|
||||
DASHSCOPE_BASE_URL: "",
|
||||
},
|
||||
);
|
||||
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ request?: { model?: string } }>(stdout);
|
||||
expect(data.request?.model).toBe("qwen3.8-max-preview");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-core",
|
||||
"version": "1.8.2",
|
||||
"version": "1.10.1",
|
||||
"description": "Core SDK for bailian-cli. See https://www.npmjs.com/package/bailian-cli for usage.",
|
||||
"homepage": "https://bailian.console.aliyun.com/cli",
|
||||
"bugs": {
|
||||
@@ -51,6 +51,6 @@
|
||||
"vite-plus": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,4 @@ export type {
|
||||
AuthState,
|
||||
CredentialSource,
|
||||
} from "./types.ts";
|
||||
export { generateCLIAccessToken, refreshAccessToken } from "./refresh-token.ts";
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { REGIONS, type Region } from "../config/schema.ts";
|
||||
import type { Identity, Settings } from "../config/schema.ts";
|
||||
import { readConfigFile, writeConfigFile } from "../config/loader.ts";
|
||||
import { Client } from "../client/client.ts";
|
||||
|
||||
const API_VERSION = "2026-02-10";
|
||||
const API_ACTION = "GenerateCLIAccessToken";
|
||||
const API_PATH = "/modelstudio/cli/generateAccessToken";
|
||||
|
||||
const MODEL_STUDIO_HOSTS: Partial<Record<Region, string>> = {
|
||||
cn: "modelstudio.cn-beijing.aliyuncs.com",
|
||||
intl: "modelstudio.ap-southeast-1.aliyuncs.com",
|
||||
};
|
||||
|
||||
function resolveRegion(baseUrl: string): Region {
|
||||
for (const [region, url] of Object.entries(REGIONS) as Array<[Region, string]>) {
|
||||
if (baseUrl === url || baseUrl.startsWith(`${url}/`)) return region;
|
||||
}
|
||||
return "cn";
|
||||
}
|
||||
|
||||
function modelStudioHost(baseUrl: string): string {
|
||||
const region = resolveRegion(baseUrl);
|
||||
return MODEL_STUDIO_HOSTS[region] ?? MODEL_STUDIO_HOSTS.cn!;
|
||||
}
|
||||
|
||||
export async function generateCLIAccessToken(opts: {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
baseUrl: string;
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
securityToken?: string;
|
||||
}): Promise<any> {
|
||||
const { identity, settings, baseUrl, accessKeyId, accessKeySecret, securityToken } = opts;
|
||||
|
||||
const client = new Client({
|
||||
identity,
|
||||
settings,
|
||||
baseUrl,
|
||||
openApiCred: { accessKeyId, accessKeySecret, securityToken, source: "flag" },
|
||||
});
|
||||
|
||||
const host = modelStudioHost(baseUrl);
|
||||
|
||||
return client.openApiQueryJson({
|
||||
host,
|
||||
path: API_PATH,
|
||||
action: API_ACTION,
|
||||
version: API_VERSION,
|
||||
method: "POST",
|
||||
queryParams: {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to refresh the console access_token using stored AK/SK.
|
||||
* Returns the new token on success, or null if AK/SK are not available.
|
||||
*/
|
||||
export async function refreshAccessToken(opts: {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
baseUrl: string;
|
||||
}): Promise<string | null> {
|
||||
const configName = opts.settings.configName;
|
||||
const config = readConfigFile(configName);
|
||||
const accessKeyId = config.access_key_id;
|
||||
const accessKeySecret = config.access_key_secret;
|
||||
if (!accessKeyId || !accessKeySecret) return null;
|
||||
|
||||
if (opts.settings.verbose) {
|
||||
process.stderr.write("Refreshing access token...\n");
|
||||
}
|
||||
|
||||
const resp = await generateCLIAccessToken({
|
||||
identity: opts.identity,
|
||||
settings: opts.settings,
|
||||
baseUrl: opts.baseUrl,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
});
|
||||
|
||||
const token: string | undefined = resp.cliAccessToken;
|
||||
if (!token) return null;
|
||||
|
||||
const existing = readConfigFile(configName) as Record<string, unknown>;
|
||||
existing.access_token = token;
|
||||
await writeConfigFile(existing, configName);
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { REGIONS } from "../config/schema.ts";
|
||||
import { normalizeModelBaseUrl } from "../config/model-base-url.ts";
|
||||
import type { ResolutionSources } from "../config/loader.ts";
|
||||
import type { ApiKeyCredential, ConsoleCredential, OpenApiCredential, AuthState } from "./types.ts";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
@@ -7,9 +8,11 @@ import { ExitCode } from "../errors/codes.ts";
|
||||
// Resolve the credential for a command's declared domain (model = api-key,
|
||||
// console = access-token), by priority, or throw. Read only from sources.
|
||||
|
||||
/** Model-domain baseUrl(flag > env > file > cn)——无需 key 也可解析;login 验证等用。 */
|
||||
export function resolveModelBaseUrl(s: ResolutionSources): string {
|
||||
return s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || REGIONS.cn;
|
||||
/** Model-domain baseUrl(flag > env > config file > fallback);无需 key 也可解析。 */
|
||||
export function resolveModelBaseUrl(s: ResolutionSources, fallback: string = REGIONS.cn): string {
|
||||
return normalizeModelBaseUrl(
|
||||
s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +58,7 @@ export function resolveOpenApi(s: ResolutionSources): OpenApiCredential {
|
||||
s.flags.accessKeyId,
|
||||
s.flags.accessKeySecret,
|
||||
s.flags.accessKeyId !== undefined || s.flags.accessKeySecret !== undefined,
|
||||
s.flags.securityToken,
|
||||
);
|
||||
if (flagCred) return flagCred;
|
||||
|
||||
@@ -66,6 +70,7 @@ export function resolveOpenApi(s: ResolutionSources): OpenApiCredential {
|
||||
trimNonEmpty(s.env.ALIBABA_CLOUD_ACCESS_KEY_ID) ||
|
||||
trimNonEmpty(s.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET),
|
||||
),
|
||||
s.env.ALIBABA_CLOUD_SECURITY_TOKEN,
|
||||
);
|
||||
if (envCred) return envCred;
|
||||
|
||||
@@ -74,6 +79,7 @@ export function resolveOpenApi(s: ResolutionSources): OpenApiCredential {
|
||||
s.file.access_key_id,
|
||||
s.file.access_key_secret,
|
||||
Boolean(s.file.access_key_id || s.file.access_key_secret),
|
||||
s.file.security_token,
|
||||
);
|
||||
if (configCred) return configCred;
|
||||
|
||||
@@ -89,6 +95,7 @@ function resolveOpenApiPair(
|
||||
rawAccessKeyId: string | undefined,
|
||||
rawAccessKeySecret: string | undefined,
|
||||
provided: boolean,
|
||||
rawSecurityToken?: string,
|
||||
): OpenApiCredential | undefined {
|
||||
if (!provided) return undefined;
|
||||
|
||||
@@ -103,7 +110,8 @@ function resolveOpenApiPair(
|
||||
);
|
||||
}
|
||||
|
||||
return { accessKeyId, accessKeySecret, source };
|
||||
const securityToken = trimNonEmpty(rawSecurityToken);
|
||||
return { accessKeyId, accessKeySecret, securityToken, source };
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | undefined): string | undefined {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import type { ConfigFile } from "../config/schema.ts";
|
||||
import type { ResolutionSources } from "../config/loader.ts";
|
||||
import { readConfigFile, writeConfigFile } from "../config/loader.ts";
|
||||
import { getConfigPath } from "../config/paths.ts";
|
||||
import { normalizeModelBaseUrl } from "../config/model-base-url.ts";
|
||||
import type { AuthState } from "./types.ts";
|
||||
import { describeAuthState, resolveModelBaseUrl } from "./resolver.ts";
|
||||
|
||||
const LOGOUT_KEYS = {
|
||||
console: ["access_token"],
|
||||
openapi: ["access_key_id", "access_key_secret"],
|
||||
all: ["api_key", "access_token", "access_key_id", "access_key_secret"],
|
||||
openapi: ["access_key_id", "access_key_secret", "security_token"],
|
||||
all: [
|
||||
"api_key",
|
||||
"base_url",
|
||||
"access_token",
|
||||
"access_key_id",
|
||||
"access_key_secret",
|
||||
"security_token",
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** 登录允许落盘的键:凭证本体 + 登录回调携带的连接/作用域字段。 */
|
||||
@@ -17,11 +26,17 @@ export type AuthPersistPatch = Pick<
|
||||
| "access_token"
|
||||
| "access_key_id"
|
||||
| "access_key_secret"
|
||||
| "security_token"
|
||||
| "base_url"
|
||||
| "console_site"
|
||||
| "console_region"
|
||||
| "console_switch_agent"
|
||||
| "workspace_id"
|
||||
| "default_text_model"
|
||||
| "default_video_model"
|
||||
| "default_image_to_video_model"
|
||||
| "default_reference_to_video_model"
|
||||
| "default_image_model"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -31,43 +46,53 @@ export type AuthPersistPatch = Pick<
|
||||
export interface AuthStore {
|
||||
/** 各域"将会解析出"的凭证快照(auth status 用)。 */
|
||||
describe(): AuthState;
|
||||
/** 磁盘上当前是否存有各域凭证(区别于 describe:只看 file,不含 flag/env 源)。 */
|
||||
stored(): { apiKey: boolean; console: boolean; openapi: boolean };
|
||||
/** model 域 baseUrl 链(flag > env > file > 默认);验证 API key 等无凭证场景用。 */
|
||||
resolveBaseUrl(): string;
|
||||
/** 登录落盘:合并写入,undefined 键忽略。 */
|
||||
/** 磁盘上当前是否存有各域凭证及 model baseUrl(区别于 describe:只看 file,不含 flag/env 源)。 */
|
||||
stored(): { apiKey: boolean; console: boolean; openapi: boolean; baseUrl?: string };
|
||||
/** model 域 baseUrl 链(flag > env > config file > fallback)。 */
|
||||
resolveBaseUrl(fallback?: string): string;
|
||||
/** 登录落盘:合并写入,undefined 键忽略;显式 --config 成功后同时激活目标 Profile。 */
|
||||
login(patch: AuthPersistPatch): Promise<void>;
|
||||
/** 清凭证:console/openapi 只删对应域;all 清全部登录凭证。返回是否有变更。 */
|
||||
/** 清凭证:console/openapi 只删对应域;all 清全部登录凭证和 model baseUrl。返回是否有变更。 */
|
||||
logout(scope: "console" | "openapi" | "all"): Promise<boolean>;
|
||||
/** 实际写入的 config.json 路径(不受命名配置影响,一直是同一个文件)。 */
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function makeAuthStore(sources: ResolutionSources): AuthStore {
|
||||
const configName = sources.configName;
|
||||
const activateAfterLogin = sources.flags.config !== undefined;
|
||||
return {
|
||||
describe: () => describeAuthState(sources),
|
||||
stored() {
|
||||
const file = readConfigFile();
|
||||
const file = readConfigFile(configName);
|
||||
return {
|
||||
apiKey: !!file.api_key,
|
||||
console: !!file.access_token,
|
||||
openapi: !!(file.access_key_id || file.access_key_secret),
|
||||
openapi: !!(file.access_key_id || file.access_key_secret || file.security_token),
|
||||
baseUrl: file.base_url,
|
||||
};
|
||||
},
|
||||
resolveBaseUrl: () => resolveModelBaseUrl(sources),
|
||||
resolveBaseUrl: (fallback) => resolveModelBaseUrl(sources, fallback),
|
||||
async login(patch) {
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
const existing = readConfigFile(configName) as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value !== undefined) existing[key] = value;
|
||||
if (value !== undefined) {
|
||||
existing[key] = key === "base_url" ? normalizeModelBaseUrl(String(value)) : value;
|
||||
}
|
||||
}
|
||||
await writeConfigFile(existing);
|
||||
await writeConfigFile(existing, configName, { activate: activateAfterLogin });
|
||||
},
|
||||
async logout(scope) {
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
const existing = readConfigFile(configName) as Record<string, unknown>;
|
||||
const keys = LOGOUT_KEYS[scope];
|
||||
const had = keys.some((key) => existing[key] !== undefined);
|
||||
if (!had) return false;
|
||||
for (const key of keys) delete existing[key];
|
||||
await writeConfigFile(existing);
|
||||
await writeConfigFile(existing, configName);
|
||||
return true;
|
||||
},
|
||||
get path() {
|
||||
return sources.configPath ?? getConfigPath();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ConsoleCredential {
|
||||
export interface OpenApiCredential {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
securityToken?: string;
|
||||
source: CredentialSource;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { createHmac, createHash, randomUUID } from "crypto";
|
||||
|
||||
export type AcsQueryParams = Record<string, string | string[] | undefined>;
|
||||
export type AcsQueryParams = Record<string, string | string[] | undefined | number>;
|
||||
|
||||
export interface AcsSignConfig {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
securityToken?: string;
|
||||
action: string;
|
||||
version: string;
|
||||
body: string;
|
||||
@@ -17,7 +18,7 @@ export interface AcsSignConfig {
|
||||
|
||||
/** Build ACS3 canonical query string from OpenAPI query parameters. */
|
||||
export function buildAcsCanonicalQuery(params: AcsQueryParams): string {
|
||||
const pairs: Array<[string, string]> = [];
|
||||
const pairs: Array<[string, string | number | undefined]> = [];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === "") continue;
|
||||
if (Array.isArray(value)) {
|
||||
@@ -30,7 +31,9 @@ export function buildAcsCanonicalQuery(params: AcsQueryParams): string {
|
||||
}
|
||||
}
|
||||
pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&");
|
||||
return pairs
|
||||
.map(([key, value]) => `${encodeRFC3986(key)}=${encodeRFC3986(String(value))}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
export function signAcsRequest(cfg: AcsSignConfig): Record<string, string> {
|
||||
@@ -49,6 +52,7 @@ export function signAcsRequest(cfg: AcsSignConfig): Record<string, string> {
|
||||
"x-acs-content-sha256": hashedBody,
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (cfg.securityToken) headers["x-acs-security-token"] = cfg.securityToken;
|
||||
|
||||
const signedHeaderKeys = Object.keys(headers)
|
||||
.filter((k) => k === "host" || k === "content-type" || k.startsWith("x-acs-"))
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Identity, Settings } from "../config/schema.ts";
|
||||
import { Client, type OpenApiResponse } from "./client.ts";
|
||||
|
||||
const VERSION = "2024-08-16";
|
||||
// BailianControl is a ROA-style product: each API has its own pathname
|
||||
// (e.g. GetApiKey -> /bailianControl/apiKey/getApiKey), not the RPC `/`.
|
||||
const CREATE_USER_ACTION = "CreateUser";
|
||||
const CREATE_USER_PATH = "/bailianControl/User/createUser";
|
||||
const LIST_WORKSPACES_ACTION = "ListWorkspaces";
|
||||
const LIST_WORKSPACES_PATH = "/bailianControl/workspaces";
|
||||
const RESET_POLICIES_ACTION = "ChangeUserPermissions";
|
||||
const RESET_POLICIES_PATH = "/bailianControl/serviserAuthorityPolicy/resetPolicies4Agent";
|
||||
|
||||
function bailianControlHost(regionId: string): string {
|
||||
return `bailiancontrol.${regionId}.aliyuncs.com`;
|
||||
}
|
||||
|
||||
/** Shared inputs for every BailianControl OpenAPI call (AK/SK passed explicitly). */
|
||||
export interface BailianControlAuth {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
baseUrl: string;
|
||||
regionId: string;
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
securityToken?: string;
|
||||
}
|
||||
|
||||
function bailianControlClient(auth: BailianControlAuth): Client {
|
||||
return new Client({
|
||||
identity: auth.identity,
|
||||
settings: auth.settings,
|
||||
baseUrl: auth.baseUrl,
|
||||
openApiCred: {
|
||||
accessKeyId: auth.accessKeyId,
|
||||
accessKeySecret: auth.accessKeySecret,
|
||||
securityToken: auth.securityToken,
|
||||
source: "flag",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateUserReqDTO {
|
||||
outerKey: string;
|
||||
nickName: string;
|
||||
userName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Bailian console user via the BailianControl OpenAPI (`CreateUser`),
|
||||
* signed with Alibaba Cloud AK/SK. Mirrors {@link generateCLIAccessToken}: the
|
||||
* caller passes AK/SK explicitly, so this needs no stored credential.
|
||||
*/
|
||||
export async function createBailianControlUser(
|
||||
opts: BailianControlAuth & { reqDTO: CreateUserReqDTO },
|
||||
): Promise<OpenApiResponse> {
|
||||
const client = bailianControlClient(opts);
|
||||
// The CreateUser request carries a single `data` param whose value is the
|
||||
// console payload re-encoded as a JSON string: {"data":"{\"reqDTO\":{...}}"}.
|
||||
return client.openApiJson({
|
||||
host: bailianControlHost(opts.regionId),
|
||||
path: CREATE_USER_PATH,
|
||||
action: CREATE_USER_ACTION,
|
||||
version: VERSION,
|
||||
method: "POST",
|
||||
body: { data: JSON.stringify({ reqDTO: opts.reqDTO }) },
|
||||
});
|
||||
}
|
||||
|
||||
/** List workspaces (used to resolve the agent id for permission changes). */
|
||||
export async function listBailianControlWorkspaces(
|
||||
opts: BailianControlAuth,
|
||||
): Promise<OpenApiResponse> {
|
||||
const client = bailianControlClient(opts);
|
||||
// GET carries the console payload as a `data` query param, re-encoded as a
|
||||
// JSON string: ?data={"reqDTO":{}}.
|
||||
return client.openApiJson({
|
||||
host: bailianControlHost(opts.regionId),
|
||||
path: LIST_WORKSPACES_PATH,
|
||||
action: LIST_WORKSPACES_ACTION,
|
||||
version: VERSION,
|
||||
method: "GET",
|
||||
queryParams: {
|
||||
data: JSON.stringify({ reqDTO: {}, cornerstoneParam: {} }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a user's servicer permissions via `ResetPolicies4Agent`. The single
|
||||
* `data` param carries the console payload re-encoded as a JSON string.
|
||||
*/
|
||||
export async function resetBailianControlPolicies4Agent(
|
||||
opts: BailianControlAuth & {
|
||||
outerKey: string;
|
||||
agentId: number;
|
||||
policyIndexList?: number[];
|
||||
},
|
||||
): Promise<OpenApiResponse> {
|
||||
const client = bailianControlClient(opts);
|
||||
return client.openApiJson({
|
||||
host: bailianControlHost(opts.regionId),
|
||||
path: RESET_POLICIES_PATH,
|
||||
action: RESET_POLICIES_ACTION,
|
||||
version: VERSION,
|
||||
method: "POST",
|
||||
body: {
|
||||
data: JSON.stringify({
|
||||
cornerstoneParam: {},
|
||||
outerKey: opts.outerKey,
|
||||
policyIndexList: opts.policyIndexList ?? [1],
|
||||
agentId: opts.agentId,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import { request, requestJson, type HttpDeps, type RequestOpts } from "./http.ts";
|
||||
import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./acs.ts";
|
||||
import { isLocalFile, resolveFileUrl } from "../files/upload.ts";
|
||||
import { imageFileToDataUri, isLocalFile, resolveFileUrl } from "../files/upload.ts";
|
||||
import { McpClient } from "./mcp.ts";
|
||||
import { callConsoleGateway } from "../console/gateway.ts";
|
||||
import { refreshAccessToken } from "../auth/refresh-token.ts";
|
||||
import { maskToken } from "../utils/token.ts";
|
||||
import { trackingHeaders } from "./headers.ts";
|
||||
|
||||
@@ -35,6 +36,17 @@ export interface ClientOpenApiQueryOpts {
|
||||
queryParams: AcsQueryParams;
|
||||
}
|
||||
|
||||
export interface ClientOpenApiJsonOpts {
|
||||
host: string;
|
||||
path: string;
|
||||
action: string;
|
||||
version: string;
|
||||
method: "GET" | "POST";
|
||||
/** JSON request body; omit for query-only calls (signed as an empty body). */
|
||||
body?: unknown;
|
||||
queryParams?: AcsQueryParams;
|
||||
}
|
||||
|
||||
export interface OpenApiResponse {
|
||||
Success?: boolean;
|
||||
Code?: string;
|
||||
@@ -106,33 +118,90 @@ export class Client {
|
||||
return resolveFileUrl(source, this.requireApi().token, model, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an image input while keeping Token Plan's upload limitation isolated.
|
||||
* Token Plan local images are sent as Data URIs; every other connection keeps
|
||||
* the established temporary OSS upload flow. URLs and existing Data URIs pass through.
|
||||
*/
|
||||
resolveImageInput(
|
||||
source: string,
|
||||
model: string,
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
): Promise<string> {
|
||||
if (!isLocalFile(source)) return Promise.resolve(source);
|
||||
if (this.usesTokenPlanEndpoint()) {
|
||||
return Promise.resolve(imageFileToDataUri(source));
|
||||
}
|
||||
return this.uploadFile(source, model, { signal: opts.signal });
|
||||
}
|
||||
|
||||
usesTokenPlanEndpoint(): boolean {
|
||||
if (this.deps.settings.configName === "token-plan") return true;
|
||||
try {
|
||||
return /^token-plan\.[a-z0-9-]+\.maas\.aliyuncs\.com$/i.test(new URL(this.baseUrl).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Open an MCP client. Accepts a path (prepended with the model baseUrl) or an absolute URL. */
|
||||
mcp(pathOrUrl: string): McpClient {
|
||||
const url = /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : this.requireApi().baseUrl + pathOrUrl;
|
||||
return new McpClient(this.http, url, this.deps.apiCred?.token);
|
||||
}
|
||||
|
||||
console<T>(api: string, data: Record<string, unknown>): Promise<T> {
|
||||
async console<T>(api: string, data: Record<string, unknown>): Promise<T> {
|
||||
if (!this.deps.consoleCred) {
|
||||
throw new BailianError("This command needs a console access token.", ExitCode.AUTH);
|
||||
}
|
||||
// region / site / switchAgent 已解析在 consoleCred 里,gateway 不再回读 config。
|
||||
return callConsoleGateway(this.deps.consoleCred, this.deps.settings.timeout, {
|
||||
api,
|
||||
data,
|
||||
}) as Promise<T>;
|
||||
const gwOpts = { api, data };
|
||||
const { timeout } = this.deps.settings;
|
||||
try {
|
||||
return (await callConsoleGateway(
|
||||
this.deps.consoleCred,
|
||||
timeout,
|
||||
gwOpts,
|
||||
this.deps.settings,
|
||||
)) as T;
|
||||
} catch (err) {
|
||||
if (
|
||||
!(err instanceof BailianError) ||
|
||||
err.exitCode !== ExitCode.AUTH ||
|
||||
!err.message.includes("not logged in")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
const newToken = await refreshAccessToken({
|
||||
identity: this.deps.identity,
|
||||
settings: this.deps.settings,
|
||||
baseUrl: this.deps.baseUrl,
|
||||
});
|
||||
if (!newToken) throw err;
|
||||
return (await callConsoleGateway(
|
||||
{ ...this.deps.consoleCred, token: newToken },
|
||||
timeout,
|
||||
gwOpts,
|
||||
this.deps.settings,
|
||||
)) as T;
|
||||
}
|
||||
}
|
||||
|
||||
async openApiQueryJson<T extends OpenApiResponse>(opts: ClientOpenApiQueryOpts): Promise<T> {
|
||||
openApiQueryJson<T extends OpenApiResponse>(opts: ClientOpenApiQueryOpts): Promise<T> {
|
||||
return this.openApiJson<T>(opts);
|
||||
}
|
||||
|
||||
async openApiJson<T extends OpenApiResponse>(opts: ClientOpenApiJsonOpts): Promise<T> {
|
||||
const cred = this.requireOpenApi();
|
||||
const queryString = buildAcsCanonicalQuery(opts.queryParams);
|
||||
const bodyStr = opts.body === undefined ? "" : JSON.stringify(opts.body);
|
||||
const queryString = opts.queryParams ? buildAcsCanonicalQuery(opts.queryParams) : "";
|
||||
const endpoint = `https://${opts.host}${opts.path}${queryString ? `?${queryString}` : ""}`;
|
||||
const headers = signAcsRequest({
|
||||
accessKeyId: cred.accessKeyId,
|
||||
accessKeySecret: cred.accessKeySecret,
|
||||
securityToken: cred.securityToken,
|
||||
action: opts.action,
|
||||
version: opts.version,
|
||||
body: "",
|
||||
body: bodyStr,
|
||||
host: opts.host,
|
||||
pathname: opts.path,
|
||||
method: opts.method,
|
||||
@@ -141,21 +210,38 @@ export class Client {
|
||||
|
||||
if (this.deps.settings.verbose) {
|
||||
process.stderr.write(`> ${opts.method} ${endpoint}\n`);
|
||||
process.stderr.write(`> x-acs-action: ${opts.action} (version ${opts.version})\n`);
|
||||
process.stderr.write(`> AK: ${maskToken(cred.accessKeyId)}\n`);
|
||||
if (cred.securityToken) {
|
||||
process.stderr.write(`> STS token: ${maskToken(cred.securityToken)}\n`);
|
||||
}
|
||||
if (queryString) process.stderr.write(`> query: ${queryString}\n`);
|
||||
if (bodyStr) process.stderr.write(`> body: ${bodyStr}\n`);
|
||||
}
|
||||
|
||||
const timeoutMs = this.deps.settings.timeout * 1000;
|
||||
const res = await fetch(endpoint, {
|
||||
method: opts.method,
|
||||
headers: { ...headers, ...trackingHeaders() },
|
||||
body: bodyStr || undefined,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const rawText = await res.text();
|
||||
if (this.deps.settings.verbose) {
|
||||
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
|
||||
process.stderr.write(`< ${rawText}\n`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as T;
|
||||
let data: T;
|
||||
try {
|
||||
data = JSON.parse(rawText) as T;
|
||||
} catch {
|
||||
throw new BailianError(
|
||||
`${res.status} ${res.statusText} - ${rawText.slice(0, 500)}`,
|
||||
ExitCode.GENERAL,
|
||||
);
|
||||
}
|
||||
if (!res.ok || data.Success === false) {
|
||||
throw new BailianError(
|
||||
`${data.Code || res.status} - ${data.Message || res.statusText}`,
|
||||
|
||||
@@ -21,7 +21,19 @@ export {
|
||||
export { CHANNEL, SOURCE_CONFIG, TAGS, trackingHeaders } from "./headers.ts";
|
||||
export type { RequestOpts } from "./http.ts";
|
||||
export { request, requestJson } from "./http.ts";
|
||||
export { Client, type ClientRequestOpts, type ClientOpenApiQueryOpts } from "./client.ts";
|
||||
export {
|
||||
Client,
|
||||
type ClientRequestOpts,
|
||||
type ClientOpenApiQueryOpts,
|
||||
type ClientOpenApiJsonOpts,
|
||||
} from "./client.ts";
|
||||
export {
|
||||
createBailianControlUser,
|
||||
listBailianControlWorkspaces,
|
||||
resetBailianControlPolicies4Agent,
|
||||
type BailianControlAuth,
|
||||
type CreateUserReqDTO,
|
||||
} from "./bailian-control.ts";
|
||||
export {
|
||||
buildAcsCanonicalQuery,
|
||||
signAcsRequest,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
export type { ConfigFile, Region, Identity, Settings } from "./schema.ts";
|
||||
export { BAILIAN_HOST, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts";
|
||||
export { readConfigFile, writeConfigFile } from "./loader.ts";
|
||||
export { BAILIAN_HOST, CONFIG_FILE_KEYS, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts";
|
||||
export { normalizeConfigName, readConfigFile, writeConfigFile } from "./loader.ts";
|
||||
export {
|
||||
activateConfigProfile,
|
||||
validateConfigProfileActivation,
|
||||
readConfigProfiles,
|
||||
deleteConfigProfile,
|
||||
type ConfigProfiles,
|
||||
} from "./loader.ts";
|
||||
export { buildSources, buildSettings, type ResolutionSources } from "./loader.ts";
|
||||
export { makeConfigStore, type ConfigStore } from "./store.ts";
|
||||
export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from "./paths.ts";
|
||||
export { getModelProfilePreset } from "./profile-presets.ts";
|
||||
export { normalizeModelBaseUrl } from "./model-base-url.ts";
|
||||
|
||||
@@ -1,16 +1,50 @@
|
||||
import { readFileSync, writeFileSync, renameSync, existsSync } from "fs";
|
||||
import { parseConfigFile, type ConfigFile, type Settings } from "./schema.ts";
|
||||
import { CONFIG_FILE_KEYS, parseConfigFile, type ConfigFile, type Settings } from "./schema.ts";
|
||||
import { ensureConfigDir, getConfigPath } from "./paths.ts";
|
||||
import { detectOutputFormat } from "../output/formatter.ts";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import type { SourceFlags } from "../types/command.ts";
|
||||
|
||||
export function readConfigFile(): ConfigFile {
|
||||
const CONFIG_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
||||
const ACTIVE_CONFIG_KEY = "active_config";
|
||||
|
||||
function isConfigBlock(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化 `--config <name>`:`undefined`/""/"default" 都视为未指定(等价顶层默认配置)。
|
||||
* 合法命名只允许字母、数字、`-`/`_`,且不能与 `ConfigFile` 顶层字段同名(避免写入时与默认配置字段歧义)。
|
||||
*/
|
||||
export function normalizeConfigName(name?: unknown): string | undefined {
|
||||
if (name === undefined || name === "" || name === "default") return undefined;
|
||||
if (typeof name !== "string" || !CONFIG_NAME_PATTERN.test(name)) {
|
||||
const display = typeof name === "string" ? name : JSON.stringify(name);
|
||||
throw new BailianError(
|
||||
`Invalid config name "${display}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use letters, numbers, '-' or '_', starting with a letter or number.",
|
||||
);
|
||||
}
|
||||
if ((CONFIG_FILE_KEYS as readonly string[]).includes(name) || name === ACTIVE_CONFIG_KEY) {
|
||||
throw new BailianError(
|
||||
`Invalid config name "${name}". It conflicts with a config key.`,
|
||||
ExitCode.USAGE,
|
||||
);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** 读完整 config.json 原始对象(不经过 `parseConfigFile` 过滤),保留其他命名配置 block。 */
|
||||
function readRawConfigObject(): Record<string, unknown> {
|
||||
const path = getConfigPath();
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return parseConfigFile(JSON.parse(readFileSync(path, "utf-8")));
|
||||
const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
||||
return raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? (raw as Record<string, unknown>)
|
||||
: {};
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
if (e instanceof SyntaxError || e.message.includes("JSON")) {
|
||||
@@ -20,14 +54,133 @@ export function readConfigFile(): ConfigFile {
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeConfigFile(data: Record<string, unknown>): Promise<void> {
|
||||
/** 读取顶层激活元数据;按需校验命名 Profile 必须实际存在。 */
|
||||
function readStoredActiveConfigName(
|
||||
raw: Record<string, unknown>,
|
||||
requireExisting: boolean,
|
||||
): string | undefined {
|
||||
const activeConfigName = normalizeConfigName(raw[ACTIVE_CONFIG_KEY]);
|
||||
if (activeConfigName && requireExisting && !isConfigBlock(raw[activeConfigName])) {
|
||||
throw new BailianError(
|
||||
`Active config "${activeConfigName}" does not exist.`,
|
||||
ExitCode.USAGE,
|
||||
"Use --config default to select the default config, then activate an existing profile.",
|
||||
);
|
||||
}
|
||||
return activeConfigName;
|
||||
}
|
||||
|
||||
function readRawConfigBlock(raw: Record<string, unknown>, configName?: string): unknown {
|
||||
if (!configName) return raw;
|
||||
const block = raw[configName];
|
||||
return isConfigBlock(block) ? block : {};
|
||||
}
|
||||
|
||||
export function readConfigFile(configName?: string): ConfigFile {
|
||||
const raw = readRawConfigObject();
|
||||
return parseConfigFile(readRawConfigBlock(raw, configName));
|
||||
}
|
||||
|
||||
/** 写入所选 Profile;登录流程可在同一次原子写入中将显式 Profile 设为激活项。 */
|
||||
export async function writeConfigFile(
|
||||
data: Record<string, unknown>,
|
||||
configName?: string,
|
||||
options: { activate?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const raw = readRawConfigObject();
|
||||
if (configName) {
|
||||
raw[configName] = data;
|
||||
} else {
|
||||
for (const key of Object.keys(raw)) {
|
||||
if ((CONFIG_FILE_KEYS as readonly string[]).includes(key)) delete raw[key];
|
||||
}
|
||||
Object.assign(raw, data);
|
||||
}
|
||||
if (options.activate) {
|
||||
raw[ACTIVE_CONFIG_KEY] = configName ?? "default";
|
||||
}
|
||||
await writeRawConfigObject(raw);
|
||||
}
|
||||
|
||||
async function writeRawConfigObject(raw: Record<string, unknown>): Promise<void> {
|
||||
await ensureConfigDir();
|
||||
const path = getConfigPath();
|
||||
const tmp = path + ".tmp";
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
||||
writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** 全量配置快照:顶层默认配置 + 各命名 profile。 */
|
||||
export interface ConfigProfiles {
|
||||
/** 顶层默认配置(parseConfigFile 过滤后)。 */
|
||||
default: ConfigFile;
|
||||
/** 命名配置 name -> 配置。 */
|
||||
named: Record<string, ConfigFile>;
|
||||
/** 当前持久化激活项;default 表示顶层配置。 */
|
||||
active: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取全部 profile:顶层默认配置与各命名 block。
|
||||
* 命名 block = raw 中不属于 `CONFIG_FILE_KEYS`、且值为普通对象的项。
|
||||
*/
|
||||
export function readConfigProfiles(): ConfigProfiles {
|
||||
const raw = readRawConfigObject();
|
||||
const named: Record<string, ConfigFile> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if ((CONFIG_FILE_KEYS as readonly string[]).includes(key) || key === ACTIVE_CONFIG_KEY)
|
||||
continue;
|
||||
if (isConfigBlock(value)) {
|
||||
named[key] = parseConfigFile(value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
default: parseConfigFile(raw),
|
||||
named,
|
||||
active: readStoredActiveConfigName(raw, true) ?? "default",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfigProfileActivation(raw: Record<string, unknown>, name?: unknown): string {
|
||||
const configName = normalizeConfigName(name);
|
||||
if (configName && !isConfigBlock(raw[configName])) {
|
||||
throw new BailianError(
|
||||
`Config "${configName}" does not exist.`,
|
||||
ExitCode.USAGE,
|
||||
"Create or log in to the profile before activating it.",
|
||||
);
|
||||
}
|
||||
return configName ?? "default";
|
||||
}
|
||||
|
||||
/** 校验激活目标并返回规范化展示名;不写配置。 */
|
||||
export function validateConfigProfileActivation(name?: unknown): string {
|
||||
return resolveConfigProfileActivation(readRawConfigObject(), name);
|
||||
}
|
||||
|
||||
/** 将已存在的命名 Profile(或 default)设为持久化激活项。 */
|
||||
export async function activateConfigProfile(name?: unknown): Promise<string> {
|
||||
const raw = readRawConfigObject();
|
||||
const active = resolveConfigProfileActivation(raw, name);
|
||||
raw[ACTIVE_CONFIG_KEY] = active;
|
||||
await writeRawConfigObject(raw);
|
||||
return active;
|
||||
}
|
||||
|
||||
/** 删除一个命名 profile block;存在才删并回写,返回是否有变更。 */
|
||||
export async function deleteConfigProfile(name?: unknown): Promise<boolean> {
|
||||
const configName = normalizeConfigName(name);
|
||||
if (!configName) {
|
||||
throw new BailianError("Cannot delete the default profile.", ExitCode.USAGE);
|
||||
}
|
||||
const raw = readRawConfigObject();
|
||||
if (!isConfigBlock(raw[configName])) return false;
|
||||
delete raw[configName];
|
||||
if (readStoredActiveConfigName(raw, false) === configName) raw[ACTIVE_CONFIG_KEY] = "default";
|
||||
await writeRawConfigObject(raw);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析的三个来源,dispatch 边界一次构建。flags 收 Partial:ParsedFlags 里 switch 是
|
||||
* 必填 boolean,收 Partial 让 pipeline 等无 flag 场景传 {} 即可。
|
||||
@@ -36,10 +189,24 @@ export interface ResolutionSources {
|
||||
flags: Partial<SourceFlags>;
|
||||
file: ConfigFile;
|
||||
env: NodeJS.ProcessEnv;
|
||||
/** 当前命名配置名(`--config <name>` 解析后);未指定或 `default` 时为 undefined。 */
|
||||
configName?: string;
|
||||
/** 实际 config.json 路径(不受 configName 影响,一直是同一个文件)。 */
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export function buildSources(flags: Partial<SourceFlags>): ResolutionSources {
|
||||
return { flags, file: readConfigFile(), env: process.env };
|
||||
const raw = readRawConfigObject();
|
||||
const configExplicit = flags.config !== undefined;
|
||||
const activeConfigName = readStoredActiveConfigName(raw, !configExplicit);
|
||||
const configName = configExplicit ? normalizeConfigName(flags.config) : activeConfigName;
|
||||
return {
|
||||
flags,
|
||||
file: parseConfigFile(readRawConfigBlock(raw, configName)),
|
||||
env: process.env,
|
||||
configName,
|
||||
configPath: getConfigPath(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,13 +227,16 @@ export function buildSettings(s: ResolutionSources): Settings {
|
||||
}
|
||||
|
||||
return {
|
||||
configPath: getConfigPath(),
|
||||
configPath: s.configPath ?? getConfigPath(),
|
||||
configName: s.configName,
|
||||
output: detectOutputFormat(flags.output || env.DASHSCOPE_OUTPUT || file.output),
|
||||
outputExplicit: Boolean(flags.output || env.DASHSCOPE_OUTPUT || file.output),
|
||||
outputDir: file.output_dir || undefined,
|
||||
timeout,
|
||||
defaultTextModel: file.default_text_model,
|
||||
defaultVideoModel: file.default_video_model,
|
||||
defaultImageToVideoModel: file.default_image_to_video_model,
|
||||
defaultReferenceToVideoModel: file.default_reference_to_video_model,
|
||||
defaultImageModel: file.default_image_model,
|
||||
defaultSpeechModel: file.default_speech_model,
|
||||
defaultOmniModel: file.default_omni_model,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
|
||||
/**
|
||||
* Normalize a model-service base URL to its origin.
|
||||
* CLI endpoints append their own API paths, so user-provided paths, query
|
||||
* parameters, and fragments must not remain in the stored or resolved base URL.
|
||||
*/
|
||||
export function normalizeModelBaseUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw invalidModelBaseUrl(input);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw invalidModelBaseUrl(input);
|
||||
}
|
||||
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
function invalidModelBaseUrl(input: string): BailianError {
|
||||
return new BailianError(
|
||||
`Invalid model base URL "${input}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use an absolute http(s) URL.",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
interface ModelProfilePreset {
|
||||
baseUrl: string;
|
||||
defaultTextModel: string;
|
||||
defaultVideoModel: string;
|
||||
defaultImageToVideoModel: string;
|
||||
defaultReferenceToVideoModel: string;
|
||||
defaultImageModel: string;
|
||||
}
|
||||
|
||||
const MODEL_PROFILE_PRESETS: Readonly<Record<string, ModelProfilePreset>> = {
|
||||
"token-plan": {
|
||||
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
defaultTextModel: "qwen3.8-max-preview",
|
||||
defaultVideoModel: "happyhorse-1.1-t2v",
|
||||
defaultImageToVideoModel: "happyhorse-1.1-i2v",
|
||||
defaultReferenceToVideoModel: "happyhorse-1.1-r2v",
|
||||
defaultImageModel: "wan2.7-image",
|
||||
},
|
||||
};
|
||||
|
||||
/** Defaults materialized when logging into a well-known model profile. */
|
||||
export function getModelProfilePreset(configName?: string): ModelProfilePreset | undefined {
|
||||
return configName ? MODEL_PROFILE_PRESETS[configName] : undefined;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeModelBaseUrl } from "./model-base-url.ts";
|
||||
|
||||
export const REGIONS = {
|
||||
cn: "https://dashscope.aliyuncs.com",
|
||||
us: "https://dashscope-us.aliyuncs.com",
|
||||
@@ -22,12 +24,16 @@ export interface ConfigFile {
|
||||
access_key_id?: string;
|
||||
/** Alibaba Cloud OpenAPI AccessKey secret from `bl auth login --open-api`. */
|
||||
access_key_secret?: string;
|
||||
/** Alibaba Cloud STS Security Token (optional, for temporary credentials). */
|
||||
security_token?: string;
|
||||
base_url?: string;
|
||||
output?: "text" | "json";
|
||||
output_dir?: string;
|
||||
timeout?: number;
|
||||
default_text_model?: string;
|
||||
default_video_model?: string;
|
||||
default_image_to_video_model?: string;
|
||||
default_reference_to_video_model?: string;
|
||||
default_image_model?: string;
|
||||
default_speech_model?: string;
|
||||
default_omni_model?: string;
|
||||
@@ -38,6 +44,30 @@ export interface ConfigFile {
|
||||
telemetry?: boolean;
|
||||
}
|
||||
|
||||
export const CONFIG_FILE_KEYS = [
|
||||
"api_key",
|
||||
"access_token",
|
||||
"access_key_id",
|
||||
"access_key_secret",
|
||||
"security_token",
|
||||
"base_url",
|
||||
"output",
|
||||
"output_dir",
|
||||
"timeout",
|
||||
"default_text_model",
|
||||
"default_video_model",
|
||||
"default_image_to_video_model",
|
||||
"default_reference_to_video_model",
|
||||
"default_image_model",
|
||||
"default_speech_model",
|
||||
"default_omni_model",
|
||||
"workspace_id",
|
||||
"console_site",
|
||||
"console_region",
|
||||
"console_switch_agent",
|
||||
"telemetry",
|
||||
] as const satisfies readonly (keyof ConfigFile)[];
|
||||
|
||||
const VALID_OUTPUTS = new Set<string>(["text", "json"]);
|
||||
const VALID_CONSOLE_SITES = new Set<string>(["domestic", "international"]);
|
||||
|
||||
@@ -47,12 +77,11 @@ const VALID_CONSOLE_SITES = new Set<string>(["domestic", "international"]);
|
||||
* sends the Bearer token to these origins, so a bare `startsWith("http")` check
|
||||
* (which also accepts e.g. "httpfoo://…") is too loose.
|
||||
*/
|
||||
function isHttpUrl(value: string): boolean {
|
||||
function parseModelBaseUrl(value: string): string | undefined {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
return normalizeModelBaseUrl(value);
|
||||
} catch {
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +106,12 @@ export function parseConfigFile(raw: unknown): ConfigFile {
|
||||
obj.openapi_access_key_secret.length > 0
|
||||
)
|
||||
out.access_key_secret = obj.openapi_access_key_secret;
|
||||
if (typeof obj.base_url === "string" && isHttpUrl(obj.base_url)) out.base_url = obj.base_url;
|
||||
if (typeof obj.security_token === "string" && obj.security_token.length > 0)
|
||||
out.security_token = obj.security_token;
|
||||
if (typeof obj.base_url === "string") {
|
||||
const baseUrl = parseModelBaseUrl(obj.base_url);
|
||||
if (baseUrl) out.base_url = baseUrl;
|
||||
}
|
||||
if (typeof obj.output === "string" && VALID_OUTPUTS.has(obj.output))
|
||||
out.output = obj.output as ConfigFile["output"];
|
||||
if (typeof obj.output_dir === "string" && obj.output_dir.length > 0)
|
||||
@@ -87,6 +121,16 @@ export function parseConfigFile(raw: unknown): ConfigFile {
|
||||
out.default_text_model = obj.default_text_model;
|
||||
if (typeof obj.default_video_model === "string" && obj.default_video_model.length > 0)
|
||||
out.default_video_model = obj.default_video_model;
|
||||
if (
|
||||
typeof obj.default_image_to_video_model === "string" &&
|
||||
obj.default_image_to_video_model.length > 0
|
||||
)
|
||||
out.default_image_to_video_model = obj.default_image_to_video_model;
|
||||
if (
|
||||
typeof obj.default_reference_to_video_model === "string" &&
|
||||
obj.default_reference_to_video_model.length > 0
|
||||
)
|
||||
out.default_reference_to_video_model = obj.default_reference_to_video_model;
|
||||
if (typeof obj.default_image_model === "string" && obj.default_image_model.length > 0)
|
||||
out.default_image_model = obj.default_image_model;
|
||||
if (typeof obj.default_speech_model === "string" && obj.default_speech_model.length > 0)
|
||||
@@ -124,6 +168,7 @@ export interface Identity {
|
||||
*/
|
||||
export interface Settings {
|
||||
configPath?: string;
|
||||
configName?: string;
|
||||
output: "text" | "json";
|
||||
/**
|
||||
* Whether `output` came from an explicit source (flag/env/file) rather than
|
||||
@@ -135,6 +180,8 @@ export interface Settings {
|
||||
timeout: number;
|
||||
defaultTextModel?: string;
|
||||
defaultVideoModel?: string;
|
||||
defaultImageToVideoModel?: string;
|
||||
defaultReferenceToVideoModel?: string;
|
||||
defaultImageModel?: string;
|
||||
defaultSpeechModel?: string;
|
||||
defaultOmniModel?: string;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { ConfigFile } from "./schema.ts";
|
||||
import { readConfigFile, writeConfigFile } from "./loader.ts";
|
||||
import {
|
||||
activateConfigProfile,
|
||||
readConfigFile,
|
||||
readConfigProfiles,
|
||||
validateConfigProfileActivation,
|
||||
writeConfigFile,
|
||||
type ConfigProfiles,
|
||||
} from "./loader.ts";
|
||||
import { getConfigPath } from "./paths.ts";
|
||||
import { normalizeModelBaseUrl } from "./model-base-url.ts";
|
||||
|
||||
/**
|
||||
* config 命令族的持久化能力面(lint 限定 commands/config/** 使用)。
|
||||
@@ -12,25 +20,34 @@ export interface ConfigStore {
|
||||
write(patch: Partial<ConfigFile>): Promise<void>;
|
||||
/** 删除指定键。 */
|
||||
unset(keys: (keyof ConfigFile)[]): Promise<void>;
|
||||
/** 读取所有 Profile 与持久化激活项。 */
|
||||
profiles(): ConfigProfiles;
|
||||
/** 激活已存在的命名 Profile;undefined/default 激活顶层配置。 */
|
||||
activate(name?: unknown): Promise<string>;
|
||||
/** 校验激活目标并返回规范化展示名,不落盘。 */
|
||||
validateActivation(name?: unknown): string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function makeConfigStore(): ConfigStore {
|
||||
export function makeConfigStore(configName?: string): ConfigStore {
|
||||
return {
|
||||
read: () => readConfigFile(),
|
||||
read: () => readConfigFile(configName),
|
||||
async write(patch) {
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
const existing = readConfigFile(configName) as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) delete existing[key];
|
||||
else existing[key] = value;
|
||||
else existing[key] = key === "base_url" ? normalizeModelBaseUrl(String(value)) : value;
|
||||
}
|
||||
await writeConfigFile(existing);
|
||||
await writeConfigFile(existing, configName);
|
||||
},
|
||||
async unset(keys) {
|
||||
const existing = readConfigFile() as Record<string, unknown>;
|
||||
const existing = readConfigFile(configName) as Record<string, unknown>;
|
||||
for (const key of keys) delete existing[key];
|
||||
await writeConfigFile(existing);
|
||||
await writeConfigFile(existing, configName);
|
||||
},
|
||||
profiles: () => readConfigProfiles(),
|
||||
activate: (name) => activateConfigProfile(name),
|
||||
validateActivation: (name) => validateConfigProfileActivation(name),
|
||||
get path() {
|
||||
return getConfigPath();
|
||||
},
|
||||
|
||||
@@ -13,7 +13,10 @@ interface ConsoleGatewayInfo {
|
||||
|
||||
const REGION_GATEWAYS: Record<string, Record<ConsoleSite, ConsoleGatewayInfo>> = {
|
||||
"cn-beijing": {
|
||||
domestic: { csGateway: "bailian-cs.console.aliyun.com", action: "BroadScopeAspnGateway" },
|
||||
domestic: {
|
||||
csGateway: "bailian-cs.console.aliyun.com",
|
||||
action: "BroadScopeAspnGateway",
|
||||
},
|
||||
international: {
|
||||
csGateway: "bailian-cs.console.alibabacloud.com",
|
||||
action: "BroadScopeAspnGateway",
|
||||
@@ -74,6 +77,7 @@ function buildGatewayParams(
|
||||
protocol: "V2",
|
||||
console: "ONE_CONSOLE",
|
||||
productCode: "p_efm",
|
||||
switchUserType: 3,
|
||||
consoleSite: "BAILIAN_ALIYUN",
|
||||
...(switchAgent != null ? { switchAgent } : {}),
|
||||
...(typeof data.cornerstoneParam === "object" && data.cornerstoneParam !== null
|
||||
@@ -100,6 +104,7 @@ export async function callConsoleGateway(
|
||||
target: ConsoleGatewayTarget,
|
||||
timeoutSec: number,
|
||||
{ api, data }: ConsoleGatewayRequest,
|
||||
settings?: Pick<Settings, "verbose">,
|
||||
): Promise<unknown> {
|
||||
const resolved = resolveGateway(target.region, target.site);
|
||||
const gatewayBase = `https://${resolved.csGateway}`;
|
||||
@@ -115,15 +120,24 @@ export async function callConsoleGateway(
|
||||
};
|
||||
if (target.token) headers.Authorization = `Bearer ${target.token}`;
|
||||
|
||||
const res = await fetch(
|
||||
`${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
},
|
||||
);
|
||||
const endpoint = `${gatewayBase}/cli/api.json?action=${action}&product=${GATEWAY_PRODUCT}&api=${encodeURIComponent(api)}`;
|
||||
if (settings?.verbose) {
|
||||
process.stderr.write(`> POST ${endpoint}\n`);
|
||||
process.stderr.write(
|
||||
`> payload ${JSON.stringify({ params: JSON.parse(params), region: target.region }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
if (settings?.verbose) {
|
||||
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
@@ -138,11 +152,11 @@ export async function callConsoleGateway(
|
||||
|
||||
const innerData = json.data as Record<string, unknown> | undefined;
|
||||
if (innerData?.success === false && innerData.errorCode) {
|
||||
const rawResponse = JSON.stringify(json);
|
||||
const rawErrorCode = innerData.errorCode;
|
||||
const errorCode =
|
||||
typeof rawErrorCode === "string" ? rawErrorCode : JSON.stringify(rawErrorCode);
|
||||
const notLogined = errorCode.includes("NotLogined");
|
||||
const errorMsg = typeof innerData.errorMsg === "string" ? innerData.errorMsg : undefined;
|
||||
throw new BailianError(
|
||||
notLogined
|
||||
? "Console session is not logged in or has expired."
|
||||
@@ -150,9 +164,8 @@ export async function callConsoleGateway(
|
||||
notLogined ? ExitCode.AUTH : ExitCode.GENERAL,
|
||||
notLogined
|
||||
? "Run `bl auth login --console` to sign in or refresh your console session."
|
||||
: errorMsg && errorMsg !== errorCode
|
||||
? errorMsg
|
||||
: undefined,
|
||||
: undefined,
|
||||
{ rawResponse },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { ConsoleGatewayRequest, ConsoleSite } from "./gateway.ts";
|
||||
export type { ConsoleGatewayRequest, ConsoleGatewayTarget, ConsoleSite } from "./gateway.ts";
|
||||
export { callConsoleGateway, effectiveConsoleGatewayConfig } from "./gateway.ts";
|
||||
export type {
|
||||
ModelListParams,
|
||||
|
||||
@@ -9,12 +9,14 @@ export interface ApiErrorContext {
|
||||
export interface BailianErrorOptions {
|
||||
cause?: unknown;
|
||||
api?: ApiErrorContext;
|
||||
rawResponse?: string;
|
||||
}
|
||||
|
||||
export class BailianError extends Error {
|
||||
readonly exitCode: ExitCode;
|
||||
readonly hint?: string;
|
||||
readonly api?: ApiErrorContext;
|
||||
readonly rawResponse?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -27,6 +29,7 @@ export class BailianError extends Error {
|
||||
this.exitCode = exitCode;
|
||||
this.hint = hint;
|
||||
this.api = options?.api;
|
||||
this.rawResponse = options?.rawResponse;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export { uploadFile, isLocalFile, resolveFileUrl } from "./upload.ts";
|
||||
export {
|
||||
uploadFile,
|
||||
isLocalFile,
|
||||
resolveFileUrl,
|
||||
imageFileToDataUri,
|
||||
redactDataUri,
|
||||
} from "./upload.ts";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* X-DashScope-OssResourceResolve: enable
|
||||
*/
|
||||
import { existsSync, readFileSync, statSync } from "fs";
|
||||
import { basename } from "path";
|
||||
import { basename, extname } from "path";
|
||||
import { BailianError } from "../errors/base.ts";
|
||||
import { ExitCode } from "../errors/codes.ts";
|
||||
import { trackingHeaders } from "../client/headers.ts";
|
||||
@@ -112,6 +112,49 @@ export interface UploadOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const IMAGE_MIME_TYPES: Readonly<Record<string, string>> = {
|
||||
".bmp": "image/bmp",
|
||||
".heic": "image/heic",
|
||||
".jpe": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".webp": "image/webp",
|
||||
};
|
||||
|
||||
/** Encode a local image as a Data URI. */
|
||||
export function imageFileToDataUri(filePath: string): string {
|
||||
if (!existsSync(filePath)) {
|
||||
throw new BailianError(`File not found: ${filePath}`, ExitCode.USAGE);
|
||||
}
|
||||
|
||||
const stat = statSync(filePath);
|
||||
if (!stat.isFile()) {
|
||||
throw new BailianError(`Not a file: ${filePath}`, ExitCode.USAGE);
|
||||
}
|
||||
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
const mimeType = IMAGE_MIME_TYPES[extension];
|
||||
if (!mimeType) {
|
||||
throw new BailianError(
|
||||
`Unsupported image format "${extension || "unknown"}".`,
|
||||
ExitCode.USAGE,
|
||||
"Use an image file with a recognized extension.",
|
||||
);
|
||||
}
|
||||
|
||||
const encoded = readFileSync(filePath).toString("base64");
|
||||
return `data:${mimeType};base64,${encoded}`;
|
||||
}
|
||||
|
||||
/** Keep dry-run output readable and avoid echoing the complete inline image. */
|
||||
export function redactDataUri(input: string): string {
|
||||
const match = /^data:([^;,]+);base64,/i.exec(input);
|
||||
return match ? `data:${match[1]};base64,<omitted>` : input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local file to DashScope temporary storage and return the oss:// URL.
|
||||
* The URL is valid for 48 hours.
|
||||
|
||||
@@ -72,6 +72,11 @@ export const GLOBAL_FLAGS = {
|
||||
quiet: { type: "switch", description: "Suppress non-essential output" },
|
||||
verbose: { type: "switch", description: "Print HTTP request/response details" },
|
||||
dryRun: { type: "switch", description: "Dry run mode" },
|
||||
config: {
|
||||
type: "string",
|
||||
valueHint: "<name>",
|
||||
description: "Use a config profile for this command",
|
||||
},
|
||||
help: { type: "switch", description: "Show help" },
|
||||
version: { type: "switch", description: "Print version" },
|
||||
} satisfies FlagsDef;
|
||||
@@ -132,6 +137,11 @@ export const OPENAPI_AUTH_FLAGS = {
|
||||
valueHint: "<key>",
|
||||
description: "Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET)",
|
||||
},
|
||||
securityToken: {
|
||||
type: "string",
|
||||
valueHint: "<token>",
|
||||
description: "Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN)",
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
/** sources 里可能出现的全部 flag(全局 + 凭证域)。 */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const STDIN_FILE_DESCRIPTOR = 0;
|
||||
|
||||
export function readTextFromPathOrStdin(path: string): string {
|
||||
return readFileSync(path === "-" ? "/dev/stdin" : path, "utf-8");
|
||||
return readFileSync(path === "-" ? STDIN_FILE_DESCRIPTOR : path, "utf-8");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export { generateFilename } from "./filename.ts";
|
||||
export { resolveOutputDir } from "./output-dir.ts";
|
||||
export { maskToken } from "./token.ts";
|
||||
export { stripUndefined } from "./object.ts";
|
||||
export { readTextFromPathOrStdin } from "./fs.ts";
|
||||
export {
|
||||
parseBooleanValue,
|
||||
parseOptionalBooleanValue,
|
||||
|
||||
@@ -7,31 +7,99 @@ import {
|
||||
resolveModelBaseUrl,
|
||||
resolveOpenApi,
|
||||
} from "../src/auth/resolver.ts";
|
||||
import { getModelProfilePreset } from "../src/config/profile-presets.ts";
|
||||
|
||||
// 行为锁定:锁住各字段的 flag/env/file 优先级链,统一为 flag>env>file>默认
|
||||
// (baseUrl 原为 flag>file>env,2026-07 前置 commit 翻转)。buildSettings 与
|
||||
// resolver 都是纯函数,sources 直接构造,无需环境隔离。
|
||||
// 行为锁定:所有配置字段统一保持 flag>env>selected file>默认。`--config` 只选择
|
||||
// file block,不提升该 block 的字段优先级。Profile 预设只在登录写入阶段使用。
|
||||
// buildSettings 与 resolver 都是纯函数,sources 直接构造,无需环境隔离。
|
||||
|
||||
function src(s: {
|
||||
flags?: ResolutionSources["flags"];
|
||||
env?: Record<string, string>;
|
||||
file?: ConfigFile;
|
||||
configName?: string;
|
||||
}): ResolutionSources {
|
||||
return { flags: s.flags ?? {}, file: s.file ?? {}, env: s.env ?? {} };
|
||||
return {
|
||||
flags: s.flags ?? {},
|
||||
file: s.file ?? {},
|
||||
env: s.env ?? {},
|
||||
configName: s.configName,
|
||||
};
|
||||
}
|
||||
|
||||
const resolve = (s: Parameters<typeof src>[0]): Settings => buildSettings(src(s));
|
||||
|
||||
test("baseUrl:flag > env > file > 默认(原为 flag>file>env,已归一)", () => {
|
||||
const flags = { baseUrl: "https://flag.example.com" };
|
||||
const env = { DASHSCOPE_BASE_URL: "https://env.example.com" };
|
||||
const file: ConfigFile = { base_url: "https://file.example.com" };
|
||||
test("token-plan Profile 预设保持固定", () => {
|
||||
expect(getModelProfilePreset("token-plan")).toEqual({
|
||||
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com",
|
||||
defaultTextModel: "qwen3.8-max-preview",
|
||||
defaultVideoModel: "happyhorse-1.1-t2v",
|
||||
defaultImageToVideoModel: "happyhorse-1.1-i2v",
|
||||
defaultReferenceToVideoModel: "happyhorse-1.1-r2v",
|
||||
defaultImageModel: "wan2.7-image",
|
||||
});
|
||||
});
|
||||
|
||||
test("baseUrl:flag > env > file > 默认,所有来源统一归一化", () => {
|
||||
const flags = { baseUrl: "https://flag.example.com/compatible-mode/v1?source=flag" };
|
||||
const env = { DASHSCOPE_BASE_URL: "https://env.example.com/apps/anthropic#env" };
|
||||
const file: ConfigFile = { base_url: "https://file.example.com/gateway/" };
|
||||
expect(resolveModelBaseUrl(src({ flags, env, file }))).toBe("https://flag.example.com");
|
||||
expect(resolveModelBaseUrl(src({ env, file }))).toBe("https://env.example.com");
|
||||
expect(resolveModelBaseUrl(src({ file }))).toBe("https://file.example.com");
|
||||
expect(resolveModelBaseUrl(src({}))).toBe("https://dashscope.aliyuncs.com");
|
||||
});
|
||||
|
||||
test("baseUrl:非法 flag/env 在 resolver 边界报 usage error", () => {
|
||||
expect(() => resolveModelBaseUrl(src({ flags: { baseUrl: "not-a-url" } }))).toThrow(
|
||||
/Invalid model base URL/,
|
||||
);
|
||||
expect(() =>
|
||||
resolveModelBaseUrl(src({ env: { DASHSCOPE_BASE_URL: "file:///tmp/model" } })),
|
||||
).toThrow(/Invalid model base URL/);
|
||||
});
|
||||
|
||||
test("命名 config 仍保持 flag > env > selected file", () => {
|
||||
const env = {
|
||||
DASHSCOPE_BASE_URL: "https://env.example.com",
|
||||
DASHSCOPE_API_KEY: "sk-env",
|
||||
};
|
||||
const sources = src({
|
||||
configName: "token-plan",
|
||||
env,
|
||||
file: {
|
||||
api_key: "sk-token-plan",
|
||||
base_url: "https://profile.example.com",
|
||||
default_text_model: "custom-text",
|
||||
default_image_model: "custom-image",
|
||||
},
|
||||
});
|
||||
expect(resolveModelBaseUrl(sources)).toBe("https://env.example.com");
|
||||
expect(resolveApiKey(sources)).toMatchObject({
|
||||
token: "sk-env",
|
||||
baseUrl: "https://env.example.com",
|
||||
source: "env",
|
||||
});
|
||||
expect(buildSettings(sources)).toMatchObject({
|
||||
defaultTextModel: "custom-text",
|
||||
defaultImageModel: "custom-image",
|
||||
});
|
||||
expect(
|
||||
resolveApiKey(
|
||||
src({
|
||||
configName: "token-plan",
|
||||
flags: { apiKey: "sk-flag", baseUrl: "https://flag.example.com" },
|
||||
env,
|
||||
file: sources.file,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
token: "sk-flag",
|
||||
baseUrl: "https://flag.example.com",
|
||||
source: "flag",
|
||||
});
|
||||
});
|
||||
|
||||
test("output:flag > env > file > text", () => {
|
||||
const env = { DASHSCOPE_OUTPUT: "json" };
|
||||
const file: ConfigFile = { output: "json" };
|
||||
@@ -251,10 +319,18 @@ test("openapi 凭证:低优先级来源缺字段时不影响更高优先级成
|
||||
|
||||
test("default*Model / outputDir:仅 file 源", () => {
|
||||
const c = resolve({
|
||||
file: { default_text_model: "qwen-max", default_video_model: "wan-x", output_dir: "/tmp/out" },
|
||||
file: parseConfigFile({
|
||||
default_text_model: "qwen-max",
|
||||
default_video_model: "wan-t2v",
|
||||
default_image_to_video_model: "wan-i2v",
|
||||
default_reference_to_video_model: "wan-r2v",
|
||||
output_dir: "/tmp/out",
|
||||
}),
|
||||
});
|
||||
expect(c.defaultTextModel).toBe("qwen-max");
|
||||
expect(c.defaultVideoModel).toBe("wan-x");
|
||||
expect(c.defaultVideoModel).toBe("wan-t2v");
|
||||
expect(c.defaultImageToVideoModel).toBe("wan-i2v");
|
||||
expect(c.defaultReferenceToVideoModel).toBe("wan-r2v");
|
||||
expect(c.outputDir).toBe("/tmp/out");
|
||||
expect(resolve({}).defaultTextModel).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { makeConfigStore } from "../src/config/store.ts";
|
||||
import { makeAuthStore } from "../src/auth/store.ts";
|
||||
import { refreshAccessToken } from "../src/auth/refresh-token.ts";
|
||||
import {
|
||||
buildSettings,
|
||||
buildSources,
|
||||
normalizeConfigName,
|
||||
readConfigFile,
|
||||
writeConfigFile,
|
||||
readConfigProfiles,
|
||||
activateConfigProfile,
|
||||
deleteConfigProfile,
|
||||
} from "../src/config/loader.ts";
|
||||
import { getConfigPath } from "../src/config/paths.ts";
|
||||
|
||||
/** 在隔离的临时配置目录里执行,结束后恢复环境。 */
|
||||
async function inTempConfigDir(fn: () => Promise<void>): Promise<void> {
|
||||
@@ -36,17 +48,42 @@ test("ConfigStore:write 合并写入,undefined 键删除,unset 删键", async ()
|
||||
});
|
||||
});
|
||||
|
||||
test("ConfigStore/AuthStore 写入前归一化 model Base URL", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const configStore = makeConfigStore();
|
||||
await configStore.write({
|
||||
base_url: "https://proxy.example.com/bailian/compatible-mode/v1/?query=one#fragment",
|
||||
});
|
||||
expect(readConfigFile().base_url).toBe("https://proxy.example.com");
|
||||
expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).base_url).toBe(
|
||||
"https://proxy.example.com",
|
||||
);
|
||||
|
||||
const authStore = makeAuthStore(buildSources({}));
|
||||
await authStore.login({ base_url: "https://token.example.com/apps/anthropic/" });
|
||||
expect(readConfigFile().base_url).toBe("https://token.example.com");
|
||||
expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).base_url).toBe(
|
||||
"https://token.example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
const store = makeAuthStore({ flags: {}, file: {}, env: {} });
|
||||
await store.login({
|
||||
api_key: "sk-1",
|
||||
base_url: "https://model.example.com/compatible-mode/v1",
|
||||
access_token: "tok-1",
|
||||
access_key_id: "ak-1",
|
||||
access_key_secret: "secret-1",
|
||||
security_token: "sts-1",
|
||||
workspace_id: "ws-1",
|
||||
console_site: "international",
|
||||
});
|
||||
expect(makeConfigStore().read()).toMatchObject({
|
||||
api_key: "sk-1",
|
||||
base_url: "https://model.example.com",
|
||||
access_token: "tok-1",
|
||||
workspace_id: "ws-1",
|
||||
console_site: "international",
|
||||
@@ -55,12 +92,275 @@ test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async ()
|
||||
expect(await store.logout("console")).toBe(true);
|
||||
expect(makeConfigStore().read().access_token).toBeUndefined();
|
||||
expect(makeConfigStore().read().api_key).toBe("sk-1");
|
||||
expect(makeConfigStore().read().base_url).toBe("https://model.example.com");
|
||||
|
||||
expect(await store.logout("openapi")).toBe(true);
|
||||
expect(makeConfigStore().read()).toMatchObject({ api_key: "sk-1" });
|
||||
expect(makeConfigStore().read().base_url).toBe("https://model.example.com");
|
||||
expect(makeConfigStore().read().access_key_id).toBeUndefined();
|
||||
expect(makeConfigStore().read().access_key_secret).toBeUndefined();
|
||||
expect(makeConfigStore().read().security_token).toBeUndefined();
|
||||
|
||||
expect(await store.logout("all")).toBe(true);
|
||||
expect(makeConfigStore().read().api_key).toBeUndefined();
|
||||
expect(makeConfigStore().read().base_url).toBeUndefined();
|
||||
expect(await store.logout("all")).toBe(false);
|
||||
|
||||
// 非凭证键不受 logout 影响
|
||||
expect(makeConfigStore().read().workspace_id).toBe("ws-1");
|
||||
});
|
||||
});
|
||||
|
||||
test("AuthStore:未传 --config 时写当前激活项,显式配置在登录成功后创建并激活", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ api_key: "sk-default" });
|
||||
await writeConfigFile({ access_token: "tok-dev" }, "dev");
|
||||
await activateConfigProfile("dev");
|
||||
|
||||
const activeStore = makeAuthStore(buildSources({}));
|
||||
await activeStore.login({ access_token: "tok-dev-updated", workspace_id: "ws-dev" });
|
||||
expect(readConfigFile("dev")).toMatchObject({
|
||||
access_token: "tok-dev-updated",
|
||||
workspace_id: "ws-dev",
|
||||
});
|
||||
expect(readConfigFile().api_key).toBe("sk-default");
|
||||
expect(readConfigFile().access_token).toBeUndefined();
|
||||
|
||||
const newStore = makeAuthStore(buildSources({ config: "new-profile" }));
|
||||
await newStore.login({ access_token: "tok-new" });
|
||||
expect(readConfigFile("new-profile").access_token).toBe("tok-new");
|
||||
expect(readConfigProfiles().active).toBe("new-profile");
|
||||
|
||||
const defaultStore = makeAuthStore(buildSources({ config: "default" }));
|
||||
await defaultStore.login({ api_key: "sk-default-updated" });
|
||||
expect(readConfigFile().api_key).toBe("sk-default-updated");
|
||||
expect(readConfigProfiles().active).toBe("default");
|
||||
|
||||
expect(await activeStore.logout("console")).toBe(true);
|
||||
expect(readConfigFile("dev").access_token).toBeUndefined();
|
||||
expect(readConfigFile("new-profile").access_token).toBe("tok-new");
|
||||
});
|
||||
});
|
||||
|
||||
test("Console access token 自动刷新只读取当前选中 Config 的 AK/SK", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({
|
||||
access_key_id: "ak-default",
|
||||
access_key_secret: "secret-default",
|
||||
});
|
||||
await writeConfigFile({ access_token: "expired-dev" }, "dev");
|
||||
await activateConfigProfile("dev");
|
||||
|
||||
const sources = buildSources({});
|
||||
const refreshed = await refreshAccessToken({
|
||||
identity: {
|
||||
binName: "bl",
|
||||
version: "0.0.0-test",
|
||||
npmPackage: "bailian-cli",
|
||||
clientName: "bailian-cli-test",
|
||||
},
|
||||
settings: buildSettings(sources),
|
||||
baseUrl: "https://dashscope.aliyuncs.com",
|
||||
});
|
||||
|
||||
expect(refreshed).toBeNull();
|
||||
expect(readConfigFile("dev").access_token).toBe("expired-dev");
|
||||
});
|
||||
});
|
||||
|
||||
test("Console access token 自动刷新写回当前选中 Config", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ access_token: "tok-default" });
|
||||
await writeConfigFile(
|
||||
{
|
||||
access_token: "expired-dev",
|
||||
access_key_id: "ak-dev",
|
||||
access_key_secret: "secret-dev",
|
||||
},
|
||||
"dev",
|
||||
);
|
||||
await activateConfigProfile("dev");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ cliAccessToken: "refreshed-dev" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
try {
|
||||
const sources = buildSources({});
|
||||
const refreshed = await refreshAccessToken({
|
||||
identity: {
|
||||
binName: "bl",
|
||||
version: "0.0.0-test",
|
||||
npmPackage: "bailian-cli",
|
||||
clientName: "bailian-cli-test",
|
||||
},
|
||||
settings: buildSettings(sources),
|
||||
baseUrl: "https://dashscope.aliyuncs.com",
|
||||
});
|
||||
|
||||
expect(refreshed).toBe("refreshed-dev");
|
||||
expect(readConfigFile("dev").access_token).toBe("refreshed-dev");
|
||||
expect(readConfigFile().access_token).toBe("tok-default");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("ConfigStore:命名 config 与默认配置隔离且写入保留其它 block", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ api_key: "sk-default", output: "json" });
|
||||
await writeConfigFile({ api_key: "sk-prod", output: "text" }, "prod");
|
||||
|
||||
const dev = makeConfigStore("dev");
|
||||
await dev.write({ api_key: "sk-dev", timeout: 120 });
|
||||
|
||||
expect(makeConfigStore().read()).toMatchObject({ api_key: "sk-default", output: "json" });
|
||||
expect(dev.read()).toMatchObject({ api_key: "sk-dev", timeout: 120 });
|
||||
expect(makeConfigStore("prod").read()).toMatchObject({ api_key: "sk-prod", output: "text" });
|
||||
expect(readConfigFile("dev")).not.toMatchObject({ output: "json" });
|
||||
expect(dev.path).toBe(getConfigPath());
|
||||
});
|
||||
});
|
||||
|
||||
test("AuthStore:login/logout 只影响当前命名 config", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ api_key: "sk-default", access_token: "tok-default" });
|
||||
const sources = buildSources({ config: "dev" });
|
||||
const store = makeAuthStore(sources);
|
||||
|
||||
await store.login({ api_key: "sk-dev", access_token: "tok-dev", workspace_id: "ws-dev" });
|
||||
expect(makeConfigStore().read()).toMatchObject({
|
||||
api_key: "sk-default",
|
||||
access_token: "tok-default",
|
||||
});
|
||||
expect(makeConfigStore("dev").read()).toMatchObject({
|
||||
api_key: "sk-dev",
|
||||
access_token: "tok-dev",
|
||||
workspace_id: "ws-dev",
|
||||
});
|
||||
|
||||
expect(await store.logout("console")).toBe(true);
|
||||
expect(makeConfigStore("dev").read().access_token).toBeUndefined();
|
||||
expect(makeConfigStore().read().access_token).toBe("tok-default");
|
||||
});
|
||||
});
|
||||
|
||||
test("config name 校验拒绝路径穿越和 ConfigFile 字段冲突", () => {
|
||||
expect(normalizeConfigName("dev_1")).toBe("dev_1");
|
||||
expect(normalizeConfigName("default")).toBeUndefined();
|
||||
expect(() => normalizeConfigName("../evil")).toThrow(/Invalid config name/);
|
||||
expect(() => normalizeConfigName("api_key")).toThrow(/conflicts with a config key/);
|
||||
expect(() => normalizeConfigName("active_config")).toThrow(/conflicts with a config key/);
|
||||
});
|
||||
|
||||
test("readConfigProfiles 分离 default 与 named,deleteConfigProfile 只删指定 block", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ api_key: "sk-default", output: "json" });
|
||||
await writeConfigFile({ api_key: "sk-prod" }, "prod");
|
||||
await writeConfigFile({ access_token: "tok-dev" }, "dev");
|
||||
|
||||
const profiles = readConfigProfiles();
|
||||
expect(profiles.active).toBe("default");
|
||||
expect(profiles.default).toMatchObject({ api_key: "sk-default", output: "json" });
|
||||
expect(Object.keys(profiles.named).sort()).toEqual(["dev", "prod"]);
|
||||
expect(profiles.named.prod).toMatchObject({ api_key: "sk-prod" });
|
||||
expect(profiles.named.dev).toMatchObject({ access_token: "tok-dev" });
|
||||
|
||||
expect(await deleteConfigProfile("prod")).toBe(true);
|
||||
const after = readConfigProfiles();
|
||||
expect(after.named.prod).toBeUndefined();
|
||||
expect(after.named.dev).toMatchObject({ access_token: "tok-dev" });
|
||||
expect(after.default).toMatchObject({ api_key: "sk-default" });
|
||||
// 再次删除不存在的 block 返回 false
|
||||
expect(await deleteConfigProfile("prod")).toBe(false);
|
||||
await expect(deleteConfigProfile("default")).rejects.toThrow(/Cannot delete the default/);
|
||||
await expect(deleteConfigProfile("api_key")).rejects.toThrow(/conflicts with a config key/);
|
||||
expect(readConfigFile().api_key).toBe("sk-default");
|
||||
});
|
||||
});
|
||||
|
||||
test("active_config:未配置时使用 default,激活命名 Profile 后无 flag 自动选择", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ api_key: "sk-default" });
|
||||
await writeConfigFile({ api_key: "sk-token", default_text_model: "qwen3.7-max" }, "token-plan");
|
||||
|
||||
expect(buildSources({}).configName).toBeUndefined();
|
||||
expect(await activateConfigProfile("token-plan")).toBe("token-plan");
|
||||
|
||||
const activeSources = buildSources({});
|
||||
expect(activeSources.configName).toBe("token-plan");
|
||||
expect(activeSources.file.api_key).toBe("sk-token");
|
||||
expect(readConfigProfiles().active).toBe("token-plan");
|
||||
});
|
||||
});
|
||||
|
||||
test("显式 --config 优先于 active_config,--config default 可绕过激活项", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ api_key: "sk-default" });
|
||||
await writeConfigFile({ api_key: "sk-active" }, "active");
|
||||
await writeConfigFile({ api_key: "sk-other" }, "other");
|
||||
await activateConfigProfile("active");
|
||||
|
||||
const explicitDefault = buildSources({ config: "default" });
|
||||
expect(explicitDefault.configName).toBeUndefined();
|
||||
expect(explicitDefault.file.api_key).toBe("sk-default");
|
||||
|
||||
const explicitActive = buildSources({ config: "active" });
|
||||
expect(explicitActive.configName).toBe("active");
|
||||
|
||||
const explicitOther = buildSources({ config: "other" });
|
||||
expect(explicitOther.configName).toBe("other");
|
||||
expect(explicitOther.file.api_key).toBe("sk-other");
|
||||
expect(readConfigProfiles().active).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
test("激活不存在 Profile 不写盘;悬空 active_config 不静默回退", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ api_key: "sk-default" });
|
||||
await expect(activateConfigProfile("missing")).rejects.toThrow(/does not exist/);
|
||||
expect(readConfigProfiles().active).toBe("default");
|
||||
|
||||
const configPath = getConfigPath();
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ api_key: "sk-default", active_config: "missing" }, null, 2) + "\n",
|
||||
);
|
||||
expect(() => buildSources({})).toThrow(/Active config "missing" does not exist/);
|
||||
|
||||
const explicitDefault = buildSources({ config: "default" });
|
||||
expect(explicitDefault.file.api_key).toBe("sk-default");
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("missing");
|
||||
});
|
||||
});
|
||||
|
||||
test("删除当前激活 Profile 时原子切回 default", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ api_key: "sk-dev" }, "dev");
|
||||
await activateConfigProfile("dev");
|
||||
|
||||
expect(await deleteConfigProfile("dev")).toBe(true);
|
||||
expect(readConfigProfiles()).toMatchObject({ active: "default", named: {} });
|
||||
expect(buildSources({}).configName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSources 暴露命名 config 且 default 等价顶层", async () => {
|
||||
await inTempConfigDir(async () => {
|
||||
await writeConfigFile({ api_key: "sk-default", output: "json" });
|
||||
await writeConfigFile({ access_token: "tok-dev" }, "dev");
|
||||
|
||||
const defaultSources = buildSources({ config: "default" });
|
||||
expect(defaultSources.configName).toBeUndefined();
|
||||
expect(defaultSources.file.api_key).toBe("sk-default");
|
||||
|
||||
const devSources = buildSources({ config: "dev" });
|
||||
expect(devSources.configName).toBe("dev");
|
||||
expect(devSources.configPath).toBe(getConfigPath());
|
||||
expect(devSources.file.access_token).toBe("tok-dev");
|
||||
expect(devSources.file.api_key).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vite-plus/test";
|
||||
import { Client } from "../src/client/client.ts";
|
||||
import { imageFileToDataUri, redactDataUri } from "../src/files/upload.ts";
|
||||
import type { Settings } from "../src/config/schema.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeImage(extension = ".png", content = Buffer.from([1, 2, 3, 4])): string {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "bailian-image-input-"));
|
||||
tempDirs.push(tempDir);
|
||||
const filePath = join(tempDir, `input${extension}`);
|
||||
writeFileSync(filePath, content);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function makeSettings(configName?: string): Settings {
|
||||
return {
|
||||
configName,
|
||||
output: "json",
|
||||
outputExplicit: false,
|
||||
timeout: 30,
|
||||
verbose: false,
|
||||
quiet: true,
|
||||
dryRun: false,
|
||||
telemetry: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeClient(baseUrl: string, configName?: string): Client {
|
||||
return new Client({
|
||||
identity: {
|
||||
binName: "bl",
|
||||
version: "test",
|
||||
npmPackage: "bailian-cli",
|
||||
clientName: "bailian-cli-test",
|
||||
},
|
||||
settings: makeSettings(configName),
|
||||
baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Token Plan image input compatibility", () => {
|
||||
test("encodes supported local images and redacts previews", () => {
|
||||
const imagePath = makeImage(".png");
|
||||
const dataUri = imageFileToDataUri(imagePath);
|
||||
|
||||
expect(dataUri).toBe("data:image/png;base64,AQIDBA==");
|
||||
expect(redactDataUri(dataUri)).toBe("data:image/png;base64,<omitted>");
|
||||
});
|
||||
|
||||
test("rejects files whose image MIME type cannot be inferred", () => {
|
||||
const imagePath = makeImage(".unknown");
|
||||
expect(() => imageFileToDataUri(imagePath)).toThrow(/Unsupported image format/);
|
||||
});
|
||||
|
||||
test("uses Data URI for the token-plan profile even through a custom proxy", async () => {
|
||||
const imagePath = makeImage(".webp");
|
||||
const client = makeClient("https://proxy.example.com/bailian", "token-plan");
|
||||
|
||||
await expect(client.resolveImageInput(imagePath, "happyhorse-1.1-i2v")).resolves.toMatch(
|
||||
/^data:image\/webp;base64,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses Data URI for an official Token Plan endpoint under any profile name", async () => {
|
||||
const imagePath = makeImage(".jpg");
|
||||
const client = makeClient("https://token-plan.ap-southeast-1.maas.aliyuncs.com", "custom-plan");
|
||||
|
||||
await expect(client.resolveImageInput(imagePath, "wan2.7-image")).resolves.toMatch(
|
||||
/^data:image\/jpeg;base64,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary endpoints retain the existing upload path", () => {
|
||||
const imagePath = makeImage(".png");
|
||||
const client = makeClient("https://dashscope.aliyuncs.com", "default");
|
||||
|
||||
expect(() => client.resolveImageInput(imagePath, "wan2.7-image")).toThrow(
|
||||
/model-domain API key/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import type { Identity, Settings } from "../src/index.ts";
|
||||
import { BailianError, ExitCode, McpClient, mapApiError, request } from "../src/index.ts";
|
||||
import {
|
||||
BailianError,
|
||||
ExitCode,
|
||||
McpClient,
|
||||
callConsoleGateway,
|
||||
mapApiError,
|
||||
request,
|
||||
} from "../src/index.ts";
|
||||
import { parseConfigFile } from "../src/config/schema.ts";
|
||||
import {
|
||||
parseBooleanValue,
|
||||
@@ -8,7 +15,10 @@ import {
|
||||
resolveWatermark,
|
||||
} from "../src/utils/boolean-flag.ts";
|
||||
|
||||
function testDeps(identity: Partial<Identity> = {}): { identity: Identity; settings: Settings } {
|
||||
function testDeps(identity: Partial<Identity> = {}): {
|
||||
identity: Identity;
|
||||
settings: Settings;
|
||||
} {
|
||||
return {
|
||||
identity: {
|
||||
binName: "bl",
|
||||
@@ -83,7 +93,10 @@ test("BailianError propagates cause via options-bag and exposes it in toJSON", (
|
||||
|
||||
test("toJSON splits service-error metadata into structured fields", () => {
|
||||
const err = mapApiError(404, {
|
||||
error: { message: "The model `qwen3.7` does not exist", type: "invalid_request_error" },
|
||||
error: {
|
||||
message: "The model `qwen3.7` does not exist",
|
||||
type: "invalid_request_error",
|
||||
},
|
||||
request_id: "c55e1acc",
|
||||
});
|
||||
expect(err.toJSON()).toEqual({
|
||||
@@ -97,6 +110,96 @@ test("toJSON splits service-error metadata into structured fields", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("callConsoleGateway verbose prints structured request payload", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
let stderr = "";
|
||||
let requestBody: string | undefined;
|
||||
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
requestBody = init?.body as string | undefined;
|
||||
return new Response(JSON.stringify({ data: { success: true, value: "response-body" } }), {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
stderr += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
try {
|
||||
await callConsoleGateway(
|
||||
{
|
||||
region: "ap-southeast-1",
|
||||
site: "international",
|
||||
switchAgent: 123,
|
||||
token: "token",
|
||||
},
|
||||
30,
|
||||
{
|
||||
api: "test.api",
|
||||
data: { workspaceId: "ws-1", cornerstoneParam: { custom: "value" } },
|
||||
},
|
||||
{ verbose: true },
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
|
||||
expect(requestBody).toBeDefined();
|
||||
expect(stderr).toContain('> payload {\n "params": {');
|
||||
expect(stderr).toContain(' "region": "ap-southeast-1"');
|
||||
expect(stderr).toContain(' "Api": "test.api"');
|
||||
expect(stderr).toContain(' "workspaceId": "ws-1"');
|
||||
expect(stderr).toContain(' "switchUserType": 3');
|
||||
expect(stderr).toContain(' "switchAgent": 123');
|
||||
expect(stderr).toContain(' "custom": "value"');
|
||||
expect(stderr).toContain("< 200 OK");
|
||||
expect(stderr).not.toContain("response-body");
|
||||
});
|
||||
|
||||
test("callConsoleGateway keeps readable message and raw gateway response separately", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
const responseBody = {
|
||||
data: {
|
||||
success: false,
|
||||
errorCode: "BailianGateway.Team.NotAuthorised",
|
||||
errorMsg: "team not authorised",
|
||||
},
|
||||
};
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify(responseBody), {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
process.stderr.write = (() => true) as typeof process.stderr.write;
|
||||
|
||||
try {
|
||||
await expect(
|
||||
callConsoleGateway(
|
||||
{ region: "cn-beijing", site: "domestic", token: "token" },
|
||||
30,
|
||||
{ api: "test.api", data: {} },
|
||||
{ verbose: true },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Console gateway error: BailianGateway.Team.NotAuthorised",
|
||||
rawResponse: JSON.stringify(responseBody),
|
||||
exitCode: ExitCode.GENERAL,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
test("request uses injected client identity for User-Agent", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let userAgent: string | undefined;
|
||||
@@ -160,7 +263,9 @@ test("McpClient uses injected client identity for initialize and User-Agent", as
|
||||
userAgents.push(headers?.["User-Agent"] ?? "");
|
||||
const body = init?.body;
|
||||
if (typeof body === "string") bodies.push(JSON.parse(body));
|
||||
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }), { status: 200 });
|
||||
return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: {} }), {
|
||||
status: 200,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -213,6 +318,10 @@ test("parseConfigFile accepts only well-formed http(s) base_url", () => {
|
||||
expect(parseConfigFile({ base_url: "http://localhost:8080" }).base_url).toBe(
|
||||
"http://localhost:8080",
|
||||
);
|
||||
expect(
|
||||
parseConfigFile({ base_url: "https://proxy.example.com/team/compatible-mode/v1?x=1#y" })
|
||||
.base_url,
|
||||
).toBe("https://proxy.example.com");
|
||||
// Previously accepted because the value merely "starts with http".
|
||||
expect(parseConfigFile({ base_url: "httpfoo://evil" }).base_url).toBeUndefined();
|
||||
expect(parseConfigFile({ base_url: "not a url" }).base_url).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { BailianError } from "../src/errors/base.ts";
|
||||
import { normalizeModelBaseUrl } from "../src/config/model-base-url.ts";
|
||||
|
||||
test("normalizeModelBaseUrl keeps only the URL origin", () => {
|
||||
expect(normalizeModelBaseUrl(" https://dashscope.aliyuncs.com/?region=cn#docs ")).toBe(
|
||||
"https://dashscope.aliyuncs.com",
|
||||
);
|
||||
expect(
|
||||
normalizeModelBaseUrl(
|
||||
"https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
),
|
||||
).toBe("https://token-plan.cn-beijing.maas.aliyuncs.com");
|
||||
expect(normalizeModelBaseUrl("https://example.com/api/v1/agentstudio")).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(
|
||||
normalizeModelBaseUrl(
|
||||
"https://example.com/api/v1/services/aigc/image-generation/generation?model=qwen#docs",
|
||||
),
|
||||
).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("normalizeModelBaseUrl preserves explicit ports while removing paths", () => {
|
||||
expect(normalizeModelBaseUrl("http://localhost:8080/bailian/")).toBe("http://localhost:8080");
|
||||
});
|
||||
|
||||
test("normalizeModelBaseUrl rejects non-http and malformed URLs", () => {
|
||||
expect(() => normalizeModelBaseUrl("not a url")).toThrow(BailianError);
|
||||
expect(() => normalizeModelBaseUrl("ftp://example.com/path")).toThrow(/Invalid model base URL/);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readConfigFile } from "bailian-cli-core";
|
||||
import { buildSources } from "bailian-cli-core";
|
||||
|
||||
/** 显式开启后才跑真实网络 E2E */
|
||||
export function isBailianE2EEnabled(): boolean {
|
||||
@@ -10,8 +10,8 @@ export function isDashScopeE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
if (process.env.DASHSCOPE_API_KEY?.trim()) return true;
|
||||
try {
|
||||
const f = readConfigFile();
|
||||
return typeof f.api_key === "string" && f.api_key.length > 0;
|
||||
const config = buildSources({}).file;
|
||||
return typeof config.api_key === "string" && config.api_key.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -21,13 +21,22 @@ export function isDashScopeE2EReady(): boolean {
|
||||
export function isConsoleE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
try {
|
||||
const config = readConfigFile();
|
||||
const config = buildSources({}).file;
|
||||
return typeof config.access_token === "string" && config.access_token.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenAPI AK/SK 真实 E2E 就绪检查:只使用 `.env` / 进程环境中的完整凭证对。 */
|
||||
export function isOpenApiE2EReady(): boolean {
|
||||
if (!isBailianE2EEnabled()) return false;
|
||||
return Boolean(
|
||||
process.env.ALIBABA_CLOUD_ACCESS_KEY_ID?.trim() &&
|
||||
process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/** 语音与图像(可设 `BAILIAN_E2E_MEDIA=0` 跳过) */
|
||||
export function isBailianE2EMediaEnabled(): boolean {
|
||||
if (process.env.BAILIAN_E2E_MEDIA === "0") return false;
|
||||
|
||||
@@ -29,6 +29,9 @@ BAILIAN_E2E_VIDEO=1
|
||||
DASHSCOPE_BASE_URL=
|
||||
# DashScope API Key
|
||||
DASHSCOPE_API_KEY=
|
||||
# Alibaba Cloud OpenAPI AccessKey
|
||||
ALIBABA_CLOUD_ACCESS_KEY_ID=
|
||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET=
|
||||
# -------------------------------
|
||||
BAILIAN_E2E_VIDEO_TASK_ID=b499a8cb-1fc4-4d43-9495-e23c7f78ae0d
|
||||
# -------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.**
|
||||
|
||||
[](https://www.npmjs.com/package/knowledge-studio-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
npm install -g knowledge-studio-cli
|
||||
```
|
||||
|
||||
> Requires Node.js >= 22.12.
|
||||
> Requires Node.js >= 18.17.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
**阿里云 Model Studio 轻量级 RAG 命令行工具 — 专注知识库检索。**
|
||||
|
||||
[](https://www.npmjs.com/package/knowledge-studio-cli)
|
||||
[](https://nodejs.org)
|
||||
[](https://nodejs.org)
|
||||
[](https://www.typescriptlang.org)
|
||||
[](LICENSE)
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
npm install -g knowledge-studio-cli
|
||||
```
|
||||
|
||||
> 需要 Node.js >= 22.12。
|
||||
> 需要 Node.js >= 18.17。
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "knowledge-studio-cli",
|
||||
"version": "1.8.2",
|
||||
"version": "1.10.1",
|
||||
"description": "Lightweight RAG CLI for Aliyun Model Studio — focused on knowledge-base retrieval.",
|
||||
"keywords": [
|
||||
"alibaba-cloud",
|
||||
@@ -66,6 +66,6 @@
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bailian-cli-runtime",
|
||||
"version": "1.8.2",
|
||||
"version": "1.10.1",
|
||||
"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": {
|
||||
@@ -56,7 +56,7 @@
|
||||
"yaml": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=18.17.0"
|
||||
},
|
||||
"inlinedDependencies": {
|
||||
"ajv": "8.20.0",
|
||||
|
||||
@@ -183,7 +183,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
|
||||
flags: ownFlags,
|
||||
settings,
|
||||
sources,
|
||||
configStore: makeConfigStore(),
|
||||
configStore: makeConfigStore(sources.configName),
|
||||
authStore: makeAuthStore(sources),
|
||||
commandPacks: commandPackManager,
|
||||
client: new Client({ identity, settings, baseUrl: resolveModelBaseUrl(sources) }),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user