diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index fde6101..72ca85f 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -44,6 +44,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `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` +- `--config` 只选择 config 文件 block,不提升该 block 的字段优先级;内置套餐 Profile(当前为 `token-plan`)的预设仅在登录时物化写入,运行时继续走统一的 flag > env > selected config file > 默认值 - `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 使用的只读快照 diff --git a/docs/token-plan-profile-integration.md b/docs/token-plan-profile-integration.md new file mode 100644 index 0000000..a504383 --- /dev/null +++ b/docs/token-plan-profile-integration.md @@ -0,0 +1,549 @@ +# Token Plan Profile 与激活配置接入方案 + +> 状态:Token Plan 模型消费 MVP 已实现;Config 激活状态和通用 Base URL 归一化待实现。 +> +> 目标分支:`feat/cli-access-token`。 + +## 结论摘要 + +Token Plan 的模型消费能力继续使用现有 `apiKey` 鉴权域和模型 Client,不新增 Token Plan 鉴权模式或专用 Client。 + +本次接入拆为三类相互独立的能力,并按业务紧急度而不是最终调用链顺序交付: + +1. 优先完成 `token-plan` 内置 Profile 预设、登录和文本/图片消费。 +2. 然后完成 Config 激活状态,允许用户选择未传 `--config` 时默认使用的命名配置。 +3. 最后以独立 commit 完成通用模型 Base URL 归一化,覆盖所有输入来源,不只服务 Token Plan。 + +`token-plan` 是有默认值的内置 Profile 名,不是 `active_auth_mode`,也不是新的 `AuthRequirement`。 + +## 背景与边界 + +当前分支已经包含以下 Token Plan 管控命令: + +```text +token-plan list-seats +token-plan create-key +token-plan assign-seats +token-plan add-member +``` + +这些命令属于管理面,继续使用 OpenAPI AK/SK。本方案增加的是模型消费面:用户把 `create-key` 获得的 `PlainApiKey` 保存到 Profile,然后通过现有文本和图片命令调用模型。 + +```text +OpenAPI AK/SK + -> token-plan create-key + -> PlainApiKey + -> auth login --config token-plan + -> text/image model command +``` + +### 目标 + +- 将 Token Plan 模型 API Key 作为普通 `apiKey` credential 使用。 +- 将 `token-plan` 作为内置命名 Profile 管理。 +- 支持 Config 激活状态和默认切换。 +- 复用现有文本、图片命令与 Client。 +- 对所有来源的模型 Base URL 做统一归一化。 +- 登录验证成功后原子保存 API Key 和 Base URL。 +- 服务端错误保持原消息,不在 CLI 内翻译。 + +### 非目标 + +- 不重写现有 Token Plan 管控命令。 +- 不把模型消费 API Key 合并到 OpenAPI AK/SK 鉴权域。 +- 不新增 Token Plan 专用 Client。 +- 基础阶段不承诺视频、语音和音频模型消费。 +- 暂不维护会阻断请求的本地模型白名单。 +- 暂不把服务端错误翻译成 CLI 自定义错误。 + +## 用户交互 + +### 1. 配置 Token Plan + +`token-plan` 提供默认 Base URL,因此推荐登录命令不要求用户输入地址: + +```sh +bl auth login \ + --config token-plan \ + --api-key sk-sp-xxx +``` + +CLI 应解析并保存以下配置: + +```json +{ + "token-plan": { + "api_key": "", + "base_url": "https://token-plan.cn-beijing.maas.aliyuncs.com", + "default_text_model": "qwen3.7-max", + "default_image_model": "qwen-image-2.0" + } +} +``` + +用户仍可显式覆盖 Base URL,用于代理、测试或未来新增地域: + +```sh +bl auth login \ + --config token-plan \ + --api-key sk-sp-xxx \ + --base-url https://proxy.example.com/bailian/compatible-mode/v1 +``` + +显式地址归一化后应保存为: + +```text +https://proxy.example.com/bailian +``` + +紧急交付阶段以“不传 `--base-url`”的推荐登录路径为准,直接使用 `token-plan` 预设中的 canonical 根地址。完整的 SDK Base URL、自定义代理前缀和其他输入来源归一化在独立的通用 Base URL commit 中完成。在该 commit 合入前,如需显式覆盖,用户必须传入已经规范化的根地址,不能传 `/compatible-mode/v1` 或 `/apps/anthropic` 后缀。 + +### 2. 单次选择 Config + +`--config` 只影响当前命令,不修改激活状态: + +```sh +bl text chat --config token-plan --message "你好" +bl image generate --config token-plan --prompt "一只猫" +``` + +### 3. 激活 Config + +新增命令: + +```sh +bl config use --name token-plan +``` + +激活后,未传 `--config` 的命令默认使用 `token-plan`: + +```sh +bl text chat --message "你好" +bl image generate --prompt "一只猫" +``` + +切回顶层默认配置: + +```sh +bl config use --name default +``` + +单次绕过当前激活项、临时使用其他 Profile: + +```sh +bl text chat --config staging --message "你好" +``` + +单次显式使用顶层默认配置: + +```sh +bl text chat --config default --message "你好" +``` + +上述两种单次覆盖都不得改变持久化的激活状态。 + +### 4. 查看 Config + +新增列表能力,用于展示所有 Profile 和当前激活项: + +```sh +bl config list +``` + +示例输出: + +```text +NAME ACTIVE +default +staging +token-plan * +``` + +`config show` 和 `auth status` 的行为: + +- 未传 `--config`:展示当前激活的 Config。 +- 传 `--config `:展示指定 Config,不改变激活状态。 +- 输出中包含 `config`、`active` 和 `config_file`。 + +`config ui` 应展示当前激活项,并提供激活操作。 + +## Config 激活状态设计 + +### 存储形状 + +激活状态保存在 `~/.bailian/config.json` 顶层元数据中: + +```json +{ + "active_config": "token-plan", + "api_key": "", + "token-plan": { + "api_key": "", + "base_url": "https://token-plan.cn-beijing.maas.aliyuncs.com" + } +} +``` + +`active_config` 只允许出现在顶层,不属于单个 Profile 的业务字段。允许值为: + +- `default`:顶层默认配置。 +- 一个实际存在的命名 Profile。 + +旧配置没有 `active_config` 时等价于: + +```json +{ + "active_config": "default" +} +``` + +因此该能力对现有用户向后兼容。 + +### 选择优先级 + +Config block 的选择顺序为: + +```text +显式 --config + > active_config + > default +``` + +需要保留“参数是否出现”的信息: + +- 未传 `--config`:读取 `active_config`。 +- `--config default`:明确选择顶层配置,不能被 `active_config` 替换。 +- `--config `:明确选择该命名 Profile。 + +当前 `normalizeConfigName("default")` 会返回 `undefined`,实现时不能只根据归一化结果判断参数是否出现。 + +Config 激活只改变配置文件 block 的选择,`--config` 本身不提升所选 block 的字段优先级。运行时和 Base URL 登录验证保持“具体字段 flag > 环境变量 > selected config file > Profile 预设或系统默认值”。环境变量只影响本次有效值,不复制进 Profile;登录成功时,如果 Token Plan Profile 尚未保存 `base_url`,仍物化写入官方预设地址。Token Plan 默认模型是例外:每次登录都重置为内置版本。`config show` / `auth status` 应展示最终生效来源,避免用户误判套餐流量去向。 + +### 异常状态 + +- 激活不存在的 Profile:`config use` 返回 usage error,不写入状态。 +- 配置文件中的 `active_config` 指向不存在的 Profile:命令失败并提示切回 `default`,不得静默使用其他凭证。 +- 删除当前激活的 Profile:删除操作同时切回 `default`,或者要求用户先切换;不能保留悬空引用。 +- `config use --name token-plan` 只切换状态,不创建 Profile,也不执行登录。 +- `auth login --config token-plan` 只写入指定 Profile,不自动激活,避免登录命令产生隐藏的全局状态变化。 + +## `token-plan` 内置 Profile 预设 + +`token-plan` 是允许用户选择的内置 Profile 名,不应加入非法名称列表。它提供以下默认值: + +```text +base_url: https://token-plan.cn-beijing.maas.aliyuncs.com +default_text_model: qwen3.7-max +default_image_model: qwen-image-2.0 +``` + +Token Plan Base URL 预设只在登录写入阶段提供最低优先级的缺省值: + +```text +显式命令参数 + > 环境变量 + > 已保存的 Profile 字段 + > token-plan 预设值 +``` + +登录成功时应把显式 Base URL 或缺失的预设 Base URL,以及默认模型写入 Profile,使 `config show --config token-plan` 能看到完整配置。环境变量不复制进 Profile。运行时不再合并预设;如果手工删除字段,则按统一的环境变量、配置文件和系统默认值链继续解析。 + +默认模型采用更简单的固定策略:每次执行 `auth login --config token-plan`,都将 `default_text_model` 重置为 `qwen3.7-max`,将 `default_image_model` 重置为 `qwen-image-2.0`。登录不保留用户之前写入的其他 Profile 默认模型;用户需要临时调用其他 Token Plan 模型时,通过具体模型命令的 `--model` 覆盖,不修改这两个内置默认值。 + +预设建议通过集中 registry 表达,不在 resolver、命令和 Client 中散落名称判断: + +```ts +const MODEL_PROFILE_PRESETS = { + "token-plan": { + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", + defaultTextModel: "qwen3.7-max", + defaultImageModel: "qwen-image-2.0", + }, +}; +``` + +Profile 预设不改变命令协议: + +```text +Selected Profile + -> API Key Credential + -> Client + -> Command Endpoint +``` + +## 通用模型 Base URL 归一化 + +Base URL 归一化是独立的通用能力,必须在 Token Plan 接入前完成,不能只针对 Token Plan hostname 实现。 + +### 语义 + +CLI 中 `base_url` 表示模型服务根地址或自定义网关前缀,不包含 CLI 已知的 SDK/API Base 后缀。 + +建议新增统一函数: + +```text +normalizeModelBaseUrl(input) -> canonical base URL +``` + +通用规则: + +1. 去除首尾空白。 +2. 使用 `URL` 解析,只接受 `http:` 和 `https:`。 +3. 去除 query 和 fragment。 +4. 去除末尾 `/`。 +5. 保留协议、hostname、端口和自定义代理路径。 +6. 去除末尾已知 SDK/API Base 后缀,例如: + - `/compatible-mode/v1` + - `/apps/anthropic` +7. 不无条件返回 `url.origin`,避免破坏自定义代理路径。 + +示例: + +| 用户输入 | 归一化结果 | +| -------------------------------------------------------------------- | ------------------------------------------------- | +| `https://dashscope.aliyuncs.com/` | `https://dashscope.aliyuncs.com` | +| `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | `https://token-plan.cn-beijing.maas.aliyuncs.com` | +| `https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic` | `https://token-plan.cn-beijing.maas.aliyuncs.com` | +| `https://proxy.example.com/bailian/` | `https://proxy.example.com/bailian` | +| `https://proxy.example.com/bailian/compatible-mode/v1` | `https://proxy.example.com/bailian` | + +### 覆盖入口 + +所有模型 Base URL 来源都必须经过同一个函数: + +- 模型命令的 `--base-url`。 +- `DASHSCOPE_BASE_URL`。 +- `config.json` 中的 `base_url`。 +- `config set --key base_url`。 +- `config ui`。 +- `auth login --base-url`。 +- Console 登录回调返回的 `base_url`。 +- 手工修改的旧配置。 +- 内置默认地址和 Profile 预设地址。 + +归一化采用双层防线: + +- 写入前归一化,保证磁盘配置整洁。 +- `resolveModelBaseUrl()` 返回前防御性归一化,兼容旧配置和手工修改。 + +### URL 拼接 + +归一化后,命令继续拼接已有 endpoint: + +```text +text: /compatible-mode/v1/chat/completions +image: /api/v1/services/aigc/.../generation +``` + +最终 URL 中不得重复出现 `/compatible-mode/v1`。 + +## API Key 登录与原子保存 + +当前登录流程可能先写入 `base_url`,再验证 API Key。该顺序需要独立修复: + +```text +解析 Profile 和预设 + -> 归一化 Base URL + -> 使用最终 Base URL 验证 API Key + -> 验证成功后一次写入 api_key + base_url + 默认模型 +``` + +验证失败时,不得产生以下半配置状态: + +```json +{ + "token-plan": { + "base_url": "https://token-plan.cn-beijing.maas.aliyuncs.com" + } +} +``` + +登录验证使用的模型必须在目标 Profile 中可用。基础阶段 Token Plan 预设使用 `qwen3.7-max`;后续如不同订阅计划的模型集合分化,应将验证模型纳入 Profile 预设,而不是继续在登录函数里硬编码唯一模型。 + +## 模型消费范围 + +基础阶段承诺: + +| 能力 | 默认模型 | 调用方式 | +| -------------- | ---------------- | ---------------------------------- | +| 文本生成和推理 | `qwen3.7-max` | OpenAI Compatible Chat Completions | +| 图片生成和编辑 | `qwen-image-2.0` | DashScope 原生图片接口 | + +Token Plan 当前模型快照中还包含其他文本、视觉理解和图片模型,但该列表可能由后端调整。基础接入不维护阻断请求的本地白名单;用户可通过具体模型命令的 `--model` 临时覆盖本次请求,但再次登录时 Profile 默认模型仍重置为内置版本。 + +视频、语音和音频不作为本阶段支持承诺。现有命令仍保持通用实现,但 Token Plan Profile 的验收不包含这些模态。 + +## 错误处理 + +CLI 继续遵循“服务端错误消息原样透传”的规则。 + +例如服务端返回: + +```json +{ + "code": "InvalidParameter", + "message": "Model not exist." +} +``` + +CLI 保留 `Model not exist.`,不改写成“Token Plan 不支持该模态”,因为本地没有权威、实时的模型开放列表。 + +## Commit 拆分 + +以下 commit 按紧急度和必要依赖提交,每个 commit 都应能独立通过对应测试和静态检查。前三个 commit 组成可优先交付的 Token Plan 模型消费 MVP,后两个 commit 再补齐默认激活体验和通用 URL 输入兼容。 + +### Commit 1:Token Plan 内置 Profile 预设(已实现) + +建议提交信息: + +```text +feat(core): add token-plan model profile preset +``` + +完成内容: + +- 将 `token-plan` 注册为内置、可选择的 Profile 名。 +- 提供 canonical 默认 Base URL、文本模型和图片模型。 +- Base URL 登录验证遵循 flag > 环境变量 > 已保存 Profile > 预设;环境变量不复制进 Profile。 +- Profile 缺少 Base URL 时物化预设地址;每次 Token Plan 登录都重置并写入内置默认文本和图片模型。 +- 运行时 loader/resolver 不再合并预设。 +- 不新增 AuthRequirement,不修改 Token Plan 管控命令。 +- 补充预设值单元测试;不重复增加 Token Plan 专属消费 E2E。 +- 不依赖通用 Base URL 归一化;预设直接使用规范化后的根地址。 + +### Commit 2:Token Plan API Key 登录(已实现) + +建议提交信息: + +```text +feat(auth): support token-plan API key login +``` + +完成内容: + +- 支持 `bl auth login --config token-plan --api-key ...`。 +- 未传 `--base-url` 且没有更高优先级的环境变量或已保存地址时,使用 Token Plan Profile 预设地址。 +- 使用 Token Plan 预设文本模型验证 API Key。 +- 登录验证前不写配置。 +- 验证成功后一次写入 API Key、canonical Base URL 和默认模型。 +- 每次登录都将默认模型重置为 `qwen3.7-max` 和 `qwen-image-2.0`。 +- 验证失败不留下半配置。 +- 补充一个最小 Token Plan 登录 E2E,覆盖命名 Profile 落盘、环境变量不复制、预设 Base URL 物化和默认模型重置;通用 API Key 登录 E2E 继续覆盖成功原子保存和失败不写半配置。 +- 该 commit 暂不承诺自动归一化用户显式输入的 SDK Base URL。 + +### Commit 3:Token Plan 文本与图片消费验收(已实现) + +建议提交信息: + +```text +feat(cli): enable token-plan text and image consumption +``` + +完成内容: + +- Token Plan 消费复用现有 API Key、文本和图片调用链,不重复增加专属 E2E。 +- 发布前按需人工验证 `auth login --config token-plan --api-key ...`、文本和图片调用。 +- 更新 Token Plan 消费方案文档和 Skill reference。 +- 到该 commit 为止即可先交付显式 `--config token-plan` 的紧急消费能力。 + +### 运营文档 TODO + +- [ ] 由运营同事补充 `README.md` 和 `README.zh.md` 的 Token Plan 模型消费说明。 +- [ ] 区分 `sk-sp-...` 模型消费 API Key 与管控命令使用的 OpenAPI AK/SK。 +- [ ] 增加 `auth login --config token-plan --api-key ...`、文本消费和图片消费示例。 +- [ ] 与届时实际上线范围核对模型名称、服务地域、限制条件和用户措辞。 + +### Commit 4:Config 激活状态与切换命令(待实现) + +建议提交信息: + +```text +feat(config): add active profile selection +``` + +完成内容: + +- 增加顶层 `active_config` 元数据。 +- 实现 `--config > active_config > default` 的选择顺序。 +- 保证 `--config default` 能显式覆盖激活项。 +- 新增 `bl config list`。 +- 新增 `bl config use --name `。 +- `config show`、`auth status` 和 `config ui` 展示激活状态。 +- 删除激活 Profile 时处理状态一致性。 +- 验证激活 `token-plan` 后不传 `--config` 的文本和图片请求。 +- 验证临时 `--config default` 不改变激活状态。 +- 更新命令导出、`packages/cli/src/commands.ts`、E2E 和生成 reference。 + +### Commit 5:通用模型 Base URL 归一化(待实现) + +建议提交信息: + +```text +fix(core): normalize model base URLs across all sources +``` + +完成内容: + +- 新增 `normalizeModelBaseUrl()`。 +- 保留自定义网关路径,去除尾斜杠、query、fragment 和已知 API Base 后缀。 +- `resolveModelBaseUrl()` 对 flag、env、配置文件和默认值统一归一化。 +- `auth login`、Console callback、`config set`、`config ui` 写入前归一化。 +- 验证 Token Plan 显式输入 `/compatible-mode/v1` 和 `/apps/anthropic` 的兼容行为。 +- 补充通用 URL 单元测试和各来源解析测试。 +- 更新 README、中文 README、Skill reference 和本方案状态。 + +## 验证清单 + +### Base URL + +- 根地址和自定义路径正确保留。 +- 尾部 `/` 被移除。 +- `/compatible-mode/v1` 和 `/apps/anthropic` 后缀被移除。 +- query 和 fragment 不进入最终请求地址。 +- flag、env、配置文件和所有写入入口结果一致。 +- 最终文本 URL 只包含一次 `/compatible-mode/v1`。 + +### Config 激活 + +- 旧配置缺少 `active_config` 时继续使用 `default`。 +- `config use` 只能激活存在的 Profile。 +- 未传 `--config` 时使用激活项。 +- 显式 `--config` 优先且不修改激活项。 +- `--config default` 能绕过命名激活项。 +- 悬空激活项不会静默回退到其他凭证。 +- 删除激活项后状态保持一致。 +- `config list/show/ui` 正确标识激活项。 + +### Token Plan + +- `token-plan` 登录初始化时缺省写入官方根地址。 +- 显式 Base URL 覆盖预设并经过通用归一化。 +- 登录验证失败不写入任何 Token Plan 半配置。 +- 文本默认使用 `qwen3.7-max`。 +- 图片默认使用 `qwen-image-2.0`。 +- 文本和图片均复用现有 `apiKey` Client。 +- 管控命令继续使用 OpenAPI AK/SK,不受模型 Profile 影响。 + +## 完成后检查 + +```sh +pnpm run sync:skill-assets +vp check +vp test +``` + +完成改动后,应评估“Profile 预设与激活状态”是否需要沉淀为新的 `docs/agents/config-profile-change.md` 场景清单。 + +## 最终结论 + +Token Plan 模型消费最终表现为一个可激活的内置 Profile: + +```text +通用 Base URL 归一化 + -> Config 选择与激活 + -> 登录时物化 token-plan 预设 + -> 普通 apiKey Client + -> 文本/图片 endpoint +``` + +用户既可以通过 `--config token-plan` 单次使用,也可以通过 `bl config use --name token-plan` 将其设为默认激活配置。整个过程不引入 Token Plan 模式,也不复制现有模型调用实现。 diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index a7001ad..1fb3aa1 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -3,6 +3,7 @@ import { authLogin, authStatus, authLogout, + authGenerateAccessToken, textChat, textOmni, imageGenerate, @@ -15,6 +16,7 @@ import { visionDescribe, configShow, configSet, + configUi, update, appCall, appList, @@ -79,6 +81,7 @@ import { tokenPlanCreateKey, tokenPlanAssignSeats, tokenPlanAddMember, + bootstrap, pluginInstall, pluginLink, pluginList, @@ -94,6 +97,7 @@ export const commands: Record = { "auth login": authLogin, "auth status": authStatus, "auth logout": authLogout, + "auth generate-access-token": authGenerateAccessToken, "text chat": textChat, omni: textOmni, "image generate": imageGenerate, @@ -106,6 +110,7 @@ export const commands: Record = { "vision describe": visionDescribe, "config show": configShow, "config set": configSet, + "config ui": configUi, update, "app call": appCall, "app list": appList, @@ -170,6 +175,7 @@ export const commands: Record = { "token-plan create-key": tokenPlanCreateKey, "token-plan assign-seats": tokenPlanAssignSeats, "token-plan add-member": tokenPlanAddMember, + bootstrap: bootstrap, "plugin install": pluginInstall, "plugin link": pluginLink, "plugin list": pluginList, diff --git a/packages/cli/tests/e2e/config-profile.e2e.test.ts b/packages/cli/tests/e2e/config-profile.e2e.test.ts new file mode 100644 index 0000000..d9f996c --- /dev/null +++ b/packages/cli/tests/e2e/config-profile.e2e.test.ts @@ -0,0 +1,127 @@ +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(fn: (dir: string) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), "bl-config-profile-")); + return fn(dir).finally(() => { + rmSync(dir, { recursive: true, force: true }); + }); +} + +function writeConfig(dir: string, data: Record): 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 /); + }); + + 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).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>(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, + }); + expect(devStatus.exitCode, devStatus.stderr).toBe(0); + const devData = parseStdoutJson>(devStatus.stdout); + expect(devData.authenticated).toBe(false); + expect(devData.config).toBe("dev"); + + const defaultStatus = await runCli(["auth", "status", "--output", "json"], { + BAILIAN_CONFIG_DIR: dir, + }); + expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0); + const defaultData = parseStdoutJson>(defaultStatus.stdout); + expect(defaultData.authenticated).toBe(true); + expect(defaultData.config).toBe("default"); + }); + }); + + test("--config default 等价默认配置", async () => { + await withTempConfigDir(async (dir) => { + writeConfig(dir, { output: "json", api_key: "sk-default" }); + const { stdout, stderr, exitCode } = await runCli( + ["config", "show", "--config", "default", "--output", "json"], + { BAILIAN_CONFIG_DIR: dir }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson>(stdout); + expect(data.config).toBe("default"); + expect(data.api_key).toBeDefined(); + }); + }); + + test("非法 --config 名称报 usage error", async () => { + const { stderr, exitCode } = await runCli(["auth", "status", "--config", "../evil"]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Invalid config name/); + }); +}); diff --git a/packages/commands/src/commands/auth/generate-access-token.ts b/packages/commands/src/commands/auth/generate-access-token.ts new file mode 100644 index 0000000..0882556 --- /dev/null +++ b/packages/commands/src/commands/auth/generate-access-token.ts @@ -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: "", + description: "Alibaba Cloud Access Key ID", + required: true, + }, + accessKeySecret: { + type: "string", + valueHint: "", + description: "Alibaba Cloud Access Key Secret", + required: true, + }, + securityToken: { + type: "string", + valueHint: "", + 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 --access-key-secret --security-token ", + flags: FLAGS, + exampleArgs: ["--access-key-id LTAIxxxxx --access-key-secret xxxxx --security-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); + }, +}); diff --git a/packages/commands/src/commands/auth/login-api-key.ts b/packages/commands/src/commands/auth/login-api-key.ts new file mode 100644 index 0000000..f3dce6a --- /dev/null +++ b/packages/commands/src/commands/auth/login-api-key.ts @@ -0,0 +1,88 @@ +import { + BailianError, + ExitCode, + chatPath, + requestJson, + 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; + 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 { + process.stderr.write("Testing key... "); + const httpDeps = { identity: deps.identity, settings: deps.settings }; + const requestOpts = { + url: profile.baseUrl + chatPath(), + method: "POST", + headers: { Authorization: `Bearer ${key}` }, + timeout: Math.min(deps.settings.timeout, 30), + body: { + model: profile.defaultTextModel || "qwen3.7-max", + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + stream: false, + enable_thinking: false, + }, + }; + + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await requestJson(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: profile.persistBaseUrl, + default_text_model: profile.defaultTextModel, + default_image_model: profile.defaultImageModel, + }); +} diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index 2b26e9e..5b1a0f7 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -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 = { 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 { - 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 { - 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 { - 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(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) { + 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, { + baseUrl: testBaseUrl, + persistBaseUrl: baseUrl || undefined, + persistPatch: callbackPatch, + }); + process.stderr.write(`Config saved to ${deps.authStore.path}\n`); + } else if (hasConfig) { await deps.authStore.login({ - access_token: accessToken || undefined, + ...callbackPatch, 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`); - } - if (apiKey) { - const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl(); - await validateAndPersistApiKey(deps, apiKey, testBaseUrl); + process.stderr.write(`Config saved to ${deps.authStore.path}\n`); } } catch (err: unknown) { callbackError = err; diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index ebf9ac9..ea2a071 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -1,10 +1,7 @@ -import { defineCommand, getConfigPath } from "bailian-cli-core"; +import { defineCommand, generateCLIAccessToken, getModelProfilePreset } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; -import { - resolveConsoleOrigin, - runConsoleLogin, - validateAndPersistApiKey, -} from "./login-console.ts"; +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 +16,11 @@ export default defineCommand({ usageArgs: "--api-key | --console | --open-api --access-key-id --access-key-secret ", flags: { - apiKey: { type: "string", valueHint: "", description: "DashScope API key to store" }, + apiKey: { type: "string", valueHint: "", description: "Model API key to store" }, baseUrl: { type: "string", valueHint: "", - 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 +49,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", ], @@ -110,14 +108,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 +137,15 @@ 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, + defaultImageModel: profilePreset?.defaultImageModel, + }); }, }); diff --git a/packages/commands/src/commands/auth/logout.ts b/packages/commands/src/commands/auth/logout.ts index 0bfb79d..29430b5 100644 --- a/packages/commands/src/commands/auth/logout.ts +++ b/packages/commands/src/commands/auth/logout.ts @@ -1,4 +1,4 @@ -import { defineCommand, getConfigPath } from "bailian-cli-core"; +import { defineCommand } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -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,13 @@ 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 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 from ${store.path}\n`); if (stored.apiKey || stored.console) { process.stderr.write( "Other credentials are still configured and will be used for authentication.\n", @@ -69,7 +69,7 @@ export default defineCommand({ if (settings.dryRun) { if (hasKey) emitBare( - "Would clear api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json", + `Would clear api_key / access_token / access_key_id / access_key_secret from ${store.path}`, ); else emitBare("No credentials to clear."); emitBare("No changes made."); @@ -78,7 +78,7 @@ export default defineCommand({ 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 / access_token / access_key_id / access_key_secret from ${store.path}\n`, ); } else { process.stderr.write("No credentials to clear.\n"); diff --git a/packages/commands/src/commands/auth/status.ts b/packages/commands/src/commands/auth/status.ts index e0acbbc..99e5824 100644 --- a/packages/commands/src/commands/auth/status.ts +++ b/packages/commands/src/commands/auth/status.ts @@ -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 or DASHSCOPE_API_KEY`, @@ -54,10 +58,21 @@ 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} (${configFile})`); emitBare("Authentication Status:"); if (apiKey) { emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`); diff --git a/packages/commands/src/commands/bootstrap/index.ts b/packages/commands/src/commands/bootstrap/index.ts new file mode 100644 index 0000000..8bae9aa --- /dev/null +++ b/packages/commands/src/commands/bootstrap/index.ts @@ -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: "", + description: "Alibaba Cloud Access Key ID", + }, + accessKeySecret: { + type: "string", + valueHint: "", + description: "Alibaba Cloud Access Key Secret", + }, + securityToken: { + type: "string", + valueHint: "", + description: "Alibaba Cloud STS Security Token (optional)", + }, +} satisfies FlagsDef; + +const POLL_INTERVAL_MS = 1000; +const MAX_POLL_ATTEMPTS = 20; + +function sleep(ms: number): Promise { + 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 --access-key-secret [--security-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( + "bootstrap 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 = {}) => { + 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 bootstrap 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) => Promise; + +async function ensureCommoditiesActive(call: ApiCall, format: "text" | "json"): Promise { + 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 { + 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 { + 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); +} diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index 99b3e83..065b5cd 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -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 = { - "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: "", 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: "", 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]: value }, + 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); 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, + ); } }, }); diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts new file mode 100644 index 0000000..2ca0e08 --- /dev/null +++ b/packages/commands/src/commands/config/shared.ts @@ -0,0 +1,88 @@ +import { BailianError, ExitCode } 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_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([ + "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 = { + "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-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; + } + + return value; +} diff --git a/packages/commands/src/commands/config/show.ts b/packages/commands/src/commands/config/show.ts index 6119b1e..6031df7 100644 --- a/packages/commands/src/commands/config/show.ts +++ b/packages/commands/src/commands/config/show.ts @@ -16,6 +16,7 @@ export default defineCommand({ base_url: client.baseUrl, output: settings.output, timeout: settings.timeout, + config: settings.configName ?? "default", config_file: store.path, }; diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts new file mode 100644 index 0000000..c028626 --- /dev/null +++ b/packages/commands/src/commands/config/ui-html.ts @@ -0,0 +1,203 @@ +// 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 = ` + + + + +bailian-cli config + + + +
+ +
+
+

+ +
+
+
+ + +
+
+
+ + + +`; diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts new file mode 100644 index 0000000..eea68e9 --- /dev/null +++ b/packages/commands/src/commands/config/ui.ts @@ -0,0 +1,237 @@ +import http from "node:http"; +import { randomBytes } from "node:crypto"; + +import { + defineCommand, + detectOutputFormat, + BailianError, + ExitCode, + normalizeConfigName, + readConfigProfiles, + writeConfigFile, + deleteConfigProfile, + getConfigPath, + 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: "", + 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 { + 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): Record { + const cleaned: Record = {}; + 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; +} + +/** + * 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, activeProfile: string | null): 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 = readConfigProfiles(); + sendJson(res, 200, { + configFile: getConfigPath(), + keys: VALID_KEYS, + secretKeys: [...SECRET_KEYS], + activeProfile, + default: profiles.default, + named: profiles.named, + }); + 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; + try { + normalized = normalizeConfigName(body.name); + cleaned = buildProfilePatch(body.data as Record); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + return; + } + await writeConfigFile(cleaned, normalized); + sendJson(res, 200, { saved: cleaned }); + return; + } + + if (path === "/api/profile" && method === "DELETE") { + let normalized: string | undefined; + try { + normalized = normalizeConfigName(u.searchParams.get("name") ?? undefined); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + return; + } + if (!normalized) { + sendJson(res, 400, { error: "Cannot delete the default profile." }); + return; + } + const deleted = await deleteConfigProfile(normalized); + sendJson(res, 200, { deleted }); + 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 ] [--no-open]", + flags: FLAGS, + exampleArgs: ["", "--port 8787", "--config staging --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: getConfigPath(), + routes: [ + "GET / -> web UI", + "GET /api/config -> read all profiles", + "POST /api/profile -> save a profile", + "DELETE /api/profile -> delete a named profile", + ], + }, + format, + ); + return; + } + + const token = randomBytes(16).toString("hex"); + const activeProfile = settings.configName ?? null; + const server = createConfigUiServer(token, activeProfile); + + 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((resolve) => { + const shutdown = () => server.close(() => resolve()); + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + server.once("close", () => resolve()); + }); + }, +}); diff --git a/packages/commands/src/commands/shared/local-server.ts b/packages/commands/src/commands/shared/local-server.ts new file mode 100644 index 0000000..ffbf5c1 --- /dev/null +++ b/packages/commands/src/commands/shared/local-server.ts @@ -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 { + 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 { + 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(); + }); + }); +} diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index 2579847..d84ee22 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -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,7 @@ 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 configUi } from "./commands/config/ui.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 +88,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 bootstrap } from "./commands/bootstrap/index.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"; diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts new file mode 100644 index 0000000..d2fbc49 --- /dev/null +++ b/packages/commands/tests/config-ui.test.ts @@ -0,0 +1,134 @@ +import http from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vite-plus/test"; +import { 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 }, +): Promise { + return new Promise((resolve, reject) => { + const payload = opts?.body !== undefined ? JSON.stringify(opts.body) : undefined; + const headers: Record = { ...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( + activeProfile: string | null, + fn: (port: number) => Promise, +): Promise { + const saved = process.env.BAILIAN_CONFIG_DIR; + const dir = mkdtempSync(join(tmpdir(), "bl-ui-")); + process.env.BAILIAN_CONFIG_DIR = dir; + const server = createConfigUiServer(TOKEN, activeProfile); + await new Promise((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((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 且密钥明文回传、activeProfile 反映 --config", async () => { + await withServer("dev", async (port) => { + await writeConfigFile({ api_key: "sk-default", output: "json" }); + await writeConfigFile({ api_key: "sk-dev", access_token: "tok-dev" }, "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"); + }); +}); + +test("鉴权:错误 token 401、非 loopback Host 403", async () => { + await withServer(null, 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(null, async (port) => { + const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { name: "stage", data: { api_key: "sk-stage", timeout: "90" } }, + }); + expect(save.status).toBe(200); + expect(readConfigFile("stage")).toMatchObject({ api_key: "sk-stage", timeout: 90 }); + + // 空串清除 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 非法 key 返回 400", async () => { + await withServer(null, 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(null, 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); + }); +}); diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 6e8a2a4..1682f77 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -1,4 +1,6 @@ -import { readFileSync } from "fs"; +import { existsSync, readFileSync, writeFileSync } from "fs"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; import { @@ -9,6 +11,42 @@ import { } from "./helpers.ts"; import { AUTH_ROUTES } from "./topic-routes.ts"; +interface ValidationServer { + baseUrl: string; + requests: Array<{ path: string; body: Record }>; + close(): Promise; +} + +async function startValidationServer(statusCode = 200): Promise { + 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) : {}, + }); + 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((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((resolve) => server.close(() => resolve())), + }; +} + /** * Auth 相关 E2E:只验证 CLI 进程能正常解析参数并退出。 */ @@ -108,6 +146,130 @@ describe("e2e: auth", () => { expect(stdout).toContain("Would validate and save API key."); }); + test("auth login --api-key 验证后原子保存凭证和 Base URL", async () => { + const validationServer = await startValidationServer(); + const configDir = makeE2eOutputDir("auth-api-key-login"); + try { + const login = await runCommandE2e( + AUTH_ROUTES, + [ + "auth", + "login", + "--api-key", + "sk-e2e-placeholder", + "--base-url", + validationServer.baseUrl, + ], + { + 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", + 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 物化并重置内置预设", 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_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", + 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).toBeUndefined(); + 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.7-max", + default_image_model: "qwen-image-2.0", + }); + expect((config["token-plan"] as Record).base_url).not.toBe( + validationServer.baseUrl, + ); + expect((config["token-plan"] as Record).api_key).not.toBe( + "sk-env-must-not-be-persisted", + ); + } 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", "--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", diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index 66c7122..fa9e46d 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -19,6 +19,26 @@ describe("e2e: config", () => { expect(stderr).toMatch(/set|--key|--value/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", diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index faf1850..29b3126 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -15,6 +15,7 @@ export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" }; export const CONFIG_ROUTES: E2eRouteExports = { "config show": "configShow", "config set": "configSet", + "config ui": "configUi", }; export const MEMORY_ROUTES: E2eRouteExports = { diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 76a966f..00acf13 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -13,3 +13,4 @@ export type { AuthState, CredentialSource, } from "./types.ts"; +export { generateCLIAccessToken, refreshAccessToken } from "./refresh-token.ts"; diff --git a/packages/core/src/auth/refresh-token.ts b/packages/core/src/auth/refresh-token.ts new file mode 100644 index 0000000..c1d58ef --- /dev/null +++ b/packages/core/src/auth/refresh-token.ts @@ -0,0 +1,90 @@ +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> = { + 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 { + 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 { + const config = readConfigFile(); + 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() as Record; + existing.access_token = token; + await writeConfigFile(existing); + + return token; +} diff --git a/packages/core/src/auth/resolver.ts b/packages/core/src/auth/resolver.ts index 15e7d83..4508a5b 100644 --- a/packages/core/src/auth/resolver.ts +++ b/packages/core/src/auth/resolver.ts @@ -7,9 +7,9 @@ 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 s.flags.baseUrl || s.env.DASHSCOPE_BASE_URL || s.file.base_url || fallback; } /** @@ -55,6 +55,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 +67,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 +76,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 +92,7 @@ function resolveOpenApiPair( rawAccessKeyId: string | undefined, rawAccessKeySecret: string | undefined, provided: boolean, + rawSecurityToken?: string, ): OpenApiCredential | undefined { if (!provided) return undefined; @@ -103,7 +107,8 @@ function resolveOpenApiPair( ); } - return { accessKeyId, accessKeySecret, source }; + const securityToken = trimNonEmpty(rawSecurityToken); + return { accessKeyId, accessKeySecret, securityToken, source }; } function trimNonEmpty(value: string | undefined): string | undefined { diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index 45600e4..f08f7b7 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -1,6 +1,7 @@ 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 type { AuthState } from "./types.ts"; import { describeAuthState, resolveModelBaseUrl } from "./resolver.ts"; @@ -17,11 +18,14 @@ 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_image_model" >; /** @@ -31,43 +35,55 @@ 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; + /** 磁盘上当前是否存有各域凭证及 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 键忽略。 */ login(patch: AuthPersistPatch): Promise; /** 清凭证:console/openapi 只删对应域;all 清全部登录凭证。返回是否有变更。 */ logout(scope: "console" | "openapi" | "all"): Promise; + /** 实际写入的 config.json 路径(不受命名配置影响,一直是同一个文件)。 */ + path: string; + /** 当前命名配置名(`--config ` 解析后);未指定或 `default` 时为 undefined。 */ + configName?: string; } export function makeAuthStore(sources: ResolutionSources): AuthStore { + const configName = sources.configName; 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), + baseUrl: file.base_url, }; }, - resolveBaseUrl: () => resolveModelBaseUrl(sources), + resolveBaseUrl: (fallback) => resolveModelBaseUrl(sources, fallback), async login(patch) { - const existing = readConfigFile() as Record; + const existing = readConfigFile(configName) as Record; for (const [key, value] of Object.entries(patch)) { if (value !== undefined) existing[key] = value; } - await writeConfigFile(existing); + await writeConfigFile(existing, configName); }, async logout(scope) { - const existing = readConfigFile() as Record; + const existing = readConfigFile(configName) as Record; 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(); + }, + get configName() { + return configName; + }, }; } diff --git a/packages/core/src/auth/types.ts b/packages/core/src/auth/types.ts index e4f5b26..5b45a37 100644 --- a/packages/core/src/auth/types.ts +++ b/packages/core/src/auth/types.ts @@ -22,6 +22,7 @@ export interface ConsoleCredential { export interface OpenApiCredential { accessKeyId: string; accessKeySecret: string; + securityToken?: string; source: CredentialSource; } diff --git a/packages/core/src/client/acs.ts b/packages/core/src/client/acs.ts index b794f08..66c82b8 100644 --- a/packages/core/src/client/acs.ts +++ b/packages/core/src/client/acs.ts @@ -1,10 +1,11 @@ import { createHmac, createHash, randomUUID } from "crypto"; -export type AcsQueryParams = Record; +export type AcsQueryParams = Record; 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 { @@ -49,6 +52,7 @@ export function signAcsRequest(cfg: AcsSignConfig): Record { "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-")) diff --git a/packages/core/src/client/bailian-control.ts b/packages/core/src/client/bailian-control.ts new file mode 100644 index 0000000..fcc88c8 --- /dev/null +++ b/packages/core/src/client/bailian-control.ts @@ -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 { + 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 { + 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 { + 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, + }), + }, + }); +} diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index eb1dc2c..a1ef15c 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -7,6 +7,7 @@ import { buildAcsCanonicalQuery, signAcsRequest, type AcsQueryParams } from "./a import { 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; @@ -112,27 +124,58 @@ export class Client { return new McpClient(this.http, url, this.deps.apiCred?.token); } - console(api: string, data: Record): Promise { + async console(api: string, data: Record): Promise { 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; + 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; + } + } + + openApiQueryJson(opts: ClientOpenApiQueryOpts): Promise { + return this.openApiJson(opts); } - async openApiQueryJson(opts: ClientOpenApiQueryOpts): Promise { + async openApiJson(opts: ClientOpenApiJsonOpts): Promise { 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 +184,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}`, diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index b4308e3..536451d 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -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, diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 6825de9..679c680 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -1,6 +1,8 @@ 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 { 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"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index e59dc79..6f9c241 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -1,16 +1,45 @@ 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}$/; + +/** + * 校验并规范化 `--config `:`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)) { + throw new BailianError( + `Invalid config name "${name}". It conflicts with a config key.`, + ExitCode.USAGE, + ); + } + return name; +} + +/** 读完整 config.json 原始对象(不经过 `parseConfigFile` 过滤),保留其他命名配置 block。 */ +function readRawConfigObject(): Record { 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) + : {}; } catch (err) { const e = err as Error; if (e instanceof SyntaxError || e.message.includes("JSON")) { @@ -20,14 +49,74 @@ export function readConfigFile(): ConfigFile { } } -export async function writeConfigFile(data: Record): Promise { +function readRawConfigBlock(raw: Record, configName?: string): unknown { + if (!configName) return raw; + const block = raw[configName]; + return block && typeof block === "object" && !Array.isArray(block) ? block : {}; +} + +export function readConfigFile(configName?: string): ConfigFile { + const raw = readRawConfigObject(); + return parseConfigFile(readRawConfigBlock(raw, configName)); +} + +export async function writeConfigFile( + data: Record, + configName?: string, +): Promise { + 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); + } + await writeRawConfigObject(raw); +} + +async function writeRawConfigObject(raw: Record): Promise { 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; +} + +/** + * 读取全部 profile:顶层默认配置与各命名 block。 + * 命名 block = raw 中不属于 `CONFIG_FILE_KEYS`、且值为普通对象的项。 + */ +export function readConfigProfiles(): ConfigProfiles { + const raw = readRawConfigObject(); + const named: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if ((CONFIG_FILE_KEYS as readonly string[]).includes(key)) continue; + if (value && typeof value === "object" && !Array.isArray(value)) { + named[key] = parseConfigFile(value); + } + } + return { default: parseConfigFile(raw), named }; +} + +/** 删除一个命名 profile block;存在才删并回写,返回是否有变更。 */ +export async function deleteConfigProfile(name: string): Promise { + const raw = readRawConfigObject(); + if (!(name in raw)) return false; + delete raw[name]; + await writeRawConfigObject(raw); + return true; +} + /** * 解析的三个来源,dispatch 边界一次构建。flags 收 Partial:ParsedFlags 里 switch 是 * 必填 boolean,收 Partial 让 pipeline 等无 flag 场景传 {} 即可。 @@ -36,10 +125,21 @@ export interface ResolutionSources { flags: Partial; file: ConfigFile; env: NodeJS.ProcessEnv; + /** 当前命名配置名(`--config ` 解析后);未指定或 `default` 时为 undefined。 */ + configName?: string; + /** 实际 config.json 路径(不受 configName 影响,一直是同一个文件)。 */ + configPath?: string; } export function buildSources(flags: Partial): ResolutionSources { - return { flags, file: readConfigFile(), env: process.env }; + const configName = normalizeConfigName(flags.config); + return { + flags, + file: readConfigFile(configName), + env: process.env, + configName, + configPath: getConfigPath(), + }; } /** @@ -60,7 +160,8 @@ 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, diff --git a/packages/core/src/config/profile-presets.ts b/packages/core/src/config/profile-presets.ts new file mode 100644 index 0000000..197839e --- /dev/null +++ b/packages/core/src/config/profile-presets.ts @@ -0,0 +1,18 @@ +interface ModelProfilePreset { + baseUrl: string; + defaultTextModel: string; + defaultImageModel: string; +} + +const MODEL_PROFILE_PRESETS: Readonly> = { + "token-plan": { + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", + defaultTextModel: "qwen3.7-max", + defaultImageModel: "qwen-image-2.0", + }, +}; + +/** Defaults materialized when logging into a well-known model profile. */ +export function getModelProfilePreset(configName?: string): ModelProfilePreset | undefined { + return configName ? MODEL_PROFILE_PRESETS[configName] : undefined; +} diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index b76b931..d9c62b1 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -22,6 +22,8 @@ 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; @@ -38,6 +40,28 @@ 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_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(["text", "json"]); const VALID_CONSOLE_SITES = new Set(["domestic", "international"]); @@ -77,6 +101,8 @@ 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.security_token === "string" && obj.security_token.length > 0) + out.security_token = obj.security_token; if (typeof obj.base_url === "string" && isHttpUrl(obj.base_url)) out.base_url = obj.base_url; if (typeof obj.output === "string" && VALID_OUTPUTS.has(obj.output)) out.output = obj.output as ConfigFile["output"]; @@ -124,6 +150,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 diff --git a/packages/core/src/config/store.ts b/packages/core/src/config/store.ts index 8ce9833..72a26b2 100644 --- a/packages/core/src/config/store.ts +++ b/packages/core/src/config/store.ts @@ -13,26 +13,30 @@ export interface ConfigStore { /** 删除指定键。 */ unset(keys: (keyof ConfigFile)[]): Promise; path: string; + configName?: 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; + const existing = readConfigFile(configName) as Record; for (const [key, value] of Object.entries(patch)) { if (value === undefined) delete existing[key]; else existing[key] = value; } - await writeConfigFile(existing); + await writeConfigFile(existing, configName); }, async unset(keys) { - const existing = readConfigFile() as Record; + const existing = readConfigFile(configName) as Record; for (const key of keys) delete existing[key]; - await writeConfigFile(existing); + await writeConfigFile(existing, configName); }, get path() { return getConfigPath(); }, + get configName() { + return configName; + }, }; } diff --git a/packages/core/src/console/gateway.ts b/packages/core/src/console/gateway.ts index a7a7f69..38c7d9b 100644 --- a/packages/core/src/console/gateway.ts +++ b/packages/core/src/console/gateway.ts @@ -13,7 +13,10 @@ interface ConsoleGatewayInfo { const REGION_GATEWAYS: Record> = { "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, ): Promise { 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 | 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 }, ); } diff --git a/packages/core/src/console/index.ts b/packages/core/src/console/index.ts index 398b828..f1b2d1c 100644 --- a/packages/core/src/console/index.ts +++ b/packages/core/src/console/index.ts @@ -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, diff --git a/packages/core/src/errors/base.ts b/packages/core/src/errors/base.ts index 69d9abb..b3ed692 100644 --- a/packages/core/src/errors/base.ts +++ b/packages/core/src/errors/base.ts @@ -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() { diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 3facb5d..2130176 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -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: "", + description: "Use named config credentials", + }, help: { type: "switch", description: "Show help" }, version: { type: "switch", description: "Print version" }, } satisfies FlagsDef; @@ -132,6 +137,11 @@ export const OPENAPI_AUTH_FLAGS = { valueHint: "", description: "Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET)", }, + securityToken: { + type: "string", + valueHint: "", + description: "Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN)", + }, } satisfies FlagsDef; /** sources 里可能出现的全部 flag(全局 + 凭证域)。 */ diff --git a/packages/core/tests/config-priority.test.ts b/packages/core/tests/config-priority.test.ts index f51c6f2..c6f22ba 100644 --- a/packages/core/tests/config-priority.test.ts +++ b/packages/core/tests/config-priority.test.ts @@ -7,21 +7,36 @@ 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; 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[0]): Settings => buildSettings(src(s)); +test("token-plan Profile 预设保持固定", () => { + expect(getModelProfilePreset("token-plan")).toEqual({ + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com", + defaultTextModel: "qwen3.7-max", + defaultImageModel: "qwen-image-2.0", + }); +}); + test("baseUrl:flag > env > file > 默认(原为 flag>file>env,已归一)", () => { const flags = { baseUrl: "https://flag.example.com" }; const env = { DASHSCOPE_BASE_URL: "https://env.example.com" }; @@ -32,6 +47,47 @@ test("baseUrl:flag > env > file > 默认(原为 flag>file>env,已归一)", () => expect(resolveModelBaseUrl(src({}))).toBe("https://dashscope.aliyuncs.com"); }); +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" }; diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index e94f1bd..c21c657 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -4,6 +4,15 @@ 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 { + buildSources, + normalizeConfigName, + readConfigFile, + writeConfigFile, + readConfigProfiles, + deleteConfigProfile, +} from "../src/config/loader.ts"; +import { getConfigPath } from "../src/config/paths.ts"; /** 在隔离的临时配置目录里执行,结束后恢复环境。 */ async function inTempConfigDir(fn: () => Promise): Promise { @@ -64,3 +73,88 @@ test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async () expect(makeConfigStore().read().workspace_id).toBe("ws-1"); }); }); + +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/); +}); + +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.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); + }); +}); + +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(); + }); +}); diff --git a/packages/core/tests/index.test.ts b/packages/core/tests/index.test.ts index b6da358..1e4e021 100644 --- a/packages/core/tests/index.test.ts +++ b/packages/core/tests/index.test.ts @@ -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; settings: Settings } { +function testDeps(identity: Partial = {}): { + 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 { diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 1106409..6eb03a1 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -183,7 +183,7 @@ export function createCli(commands: Record, 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) }), diff --git a/skills/bailian-cli/assets/setup.md b/skills/bailian-cli/assets/setup.md index 48d3556..311c4d7 100644 --- a/skills/bailian-cli/assets/setup.md +++ b/skills/bailian-cli/assets/setup.md @@ -21,11 +21,12 @@ Verify: `bl --version` (prints `bl X.Y.Z`). ## Authentication -| Auth | How | Used by | -| ---------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------- | -| API key | `export DASHSCOPE_API_KEY=sk-...` or `bl auth login --api-key sk-...` | Most DashScope API commands | -| Console | `bl auth login --console --console-site domestic` or `... international` | `app list`, `usage free`, `console call` | -| OpenAPI AK | `bl auth login --open-api --access-key-id --access-key-secret ` or Alibaba env vars | `token-plan *` | +| Auth | How | Used by | +| ------------------ | ------------------------------------------------------------------------------------------------ | --------------------------------------------- | +| API key | `export DASHSCOPE_API_KEY=sk-...` or `bl auth login --api-key sk-...` | Most DashScope API commands | +| Token Plan API key | `bl auth login --config token-plan --api-key sk-sp-...` | Token Plan text and image model consumption | +| Console | `bl auth login --console --console-site domestic` or `... international` | `app list`, `usage free`, `console call` | +| OpenAPI AK | `bl auth login --open-api --access-key-id --access-key-secret ` or Alibaba env vars | Token Plan management commands (`token-plan`) | ```bash bl auth status # check current auth @@ -36,6 +37,24 @@ bl auth logout --open-api # clear OpenAPI AK/SK only Get an API key: https://bailian.console.aliyun.com/cn-beijing/?tab=app#/api-key +### Token Plan model consumption + +Use the `PlainApiKey` returned by `bl token-plan create-key` as a model API key. It is separate from the OpenAPI AK/SK used by Token Plan management commands. + +```bash +bl auth login --config token-plan --api-key sk-sp-xxx +bl text chat --config token-plan --message "Hello" +bl image generate --config token-plan --prompt "A cat" +``` + +The built-in `token-plan` profile defaults to: + +- Base URL: `https://token-plan.cn-beijing.maas.aliyuncs.com` +- Text model: `qwen3.7-max` +- Image model: `qwen-image-2.0` + +The usual priority applies to this profile too: per-command `--api-key` / `--base-url`, then `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL`, then the selected profile. Unset environment overrides when you want to use the credentials saved in `token-plan`. + ### Console site selection Console login and console-gateway commands (`app list`, `usage *`, `quota *`, `workspace list`, `console call`) target one of two Bailian consoles: diff --git a/skills/bailian-cli/reference/auth.md b/skills/bailian-cli/reference/auth.md index eda1496..c6b492e 100644 --- a/skills/bailian-cli/reference/auth.md +++ b/skills/bailian-cli/reference/auth.md @@ -7,14 +7,37 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| ---------------- | -------------------------------------------------------------------------------------------- | -| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | -| `bl auth logout` | Clear stored credentials | -| `bl auth status` | Show current authentication state | +| Command | Description | +| ------------------------------- | -------------------------------------------------------------------------------------------- | +| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK | +| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | +| `bl auth logout` | Clear stored credentials | +| `bl auth status` | Show current authentication state | ## Command details +### `bl auth generate-access-token` + +| Field | Value | +| --------------- | ---------------------------------------------------------------------------------------------------------- | +| **Name** | `auth generate-access-token` | +| **Description** | Generate a CLI access token using OpenAPI AK/SK | +| **Usage** | `bl auth generate-access-token --access-key-id --access-key-secret --security-token ` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ---------------------------------------------------- | +| `--access-key-id ` | string | yes | Alibaba Cloud Access Key ID | +| `--access-key-secret ` | string | yes | Alibaba Cloud Access Key Secret | +| `--security-token ` | string | no | Alibaba Cloud STS Security Token to store (optional) | + +#### Examples + +```bash +bl auth generate-access-token --access-key-id LTAIxxxxx --access-key-secret xxxxx --security-token +``` + ### `bl auth login` | Field | Value | @@ -27,8 +50,8 @@ Index: [index.md](index.md) | Flag | Type | Required | Description | | ------------------------------ | ------ | -------- | ------------------------------------------------------------------------------------- | -| `--api-key ` | string | no | DashScope API key to store | -| `--base-url ` | string | no | DashScope API base URL (used with --api-key for validation) | +| `--api-key ` | string | no | Model API key to store | +| `--base-url ` | string | no | Model API base URL (used with --api-key for validation) | | `--console` | switch | no | Sign in via browser; use --console-site to choose domestic (default) or international | | `--console-site ` | string | no | Console site: domestic, international | | `--open-api` | switch | no | Store Alibaba Cloud OpenAPI AK/SK credentials | @@ -41,6 +64,10 @@ Index: [index.md](index.md) bl auth login --api-key sk-xxxxx ``` +```bash +bl auth login --config token-plan --api-key sk-sp-xxxxx +``` + ```bash bl auth login --console ``` diff --git a/skills/bailian-cli/reference/bootstrap.md b/skills/bailian-cli/reference/bootstrap.md new file mode 100644 index 0000000..24d552a --- /dev/null +++ b/skills/bailian-cli/reference/bootstrap.md @@ -0,0 +1,36 @@ +# `bl bootstrap` commands + +> Auto-generated from `packages/cli/src/commands.ts`. Do not edit by hand. +> Regenerate: `pnpm --filter bailian-cli run generate:reference`. + +Index: [index.md](index.md) + +## Commands in this group + +| Command | Description | +| -------------- | ----------------------------------------------------------- | +| `bl bootstrap` | Initialize Bailian workspace and activate postpaid services | + +## Command details + +### `bl bootstrap` + +| Field | Value | +| --------------- | ------------------------------------------------------------------------------------------- | +| **Name** | `bootstrap` | +| **Description** | Initialize Bailian workspace and activate postpaid services | +| **Usage** | `bl bootstrap --access-key-id --access-key-secret [--security-token ]` | + +#### Flags + +| Flag | Type | Required | Description | +| ------------------------------ | ------ | -------- | ------------------------------------------- | +| `--access-key-id ` | string | no | Alibaba Cloud Access Key ID | +| `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret | +| `--security-token ` | string | no | Alibaba Cloud STS Security Token (optional) | + +#### Examples + +```bash +bl bootstrap --access-key-id LTAIxxxxx --access-key-secret xxxxx +``` diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index eb03de2..29a5139 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -7,10 +7,11 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| ---------------- | ----------------------------- | -| `bl config set` | Set a config value | -| `bl config show` | Display current configuration | +| Command | Description | +| ---------------- | --------------------------------------------- | +| `bl config set` | Set a config value | +| `bl config show` | Display current configuration | +| `bl config ui` | Open a local web UI to manage config profiles | ## Command details @@ -24,10 +25,10 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `--key ` | string | yes | Config key (base*url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, default*\*\_model, workspace_id) | -| `--value ` | string | yes | Value to set | +| Flag | Type | Required | Description | +| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--key ` | string | yes | Config key (base*url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) | +| `--value ` | string | yes | Value to set | #### Examples @@ -64,3 +65,32 @@ bl config show ```bash bl config show --output json ``` + +### `bl config ui` + +| Field | Value | +| --------------- | --------------------------------------------- | +| **Name** | `config ui` | +| **Description** | Open a local web UI to manage config profiles | +| **Usage** | `bl config ui [--port ] [--no-open]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------------- | +| `--port ` | number | no | Port to listen on (default: random free port) | +| `--no-open` | switch | no | Do not open the browser automatically | + +#### Examples + +```bash +bl config ui +``` + +```bash +bl config ui --port 8787 +``` + +```bash +bl config ui --config staging --no-open +``` diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 6a19b71..3326a8d 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -8,91 +8,94 @@ Use this index for the full quick index and global flags. ## Quick index -| Command | Description | Detail | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) | -| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) | -| `bl app list` | List Bailian applications | [app.md](app.md) | -| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) | -| `bl auth logout` | Clear stored credentials | [auth.md](auth.md) | -| `bl auth status` | Show current authentication state | [auth.md](auth.md) | -| `bl config set` | Set a config value | [config.md](config.md) | -| `bl config show` | Display current configuration | [config.md](config.md) | -| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) | -| `bl dataset delete` | Delete a dataset file by ID | [dataset.md](dataset.md) | -| `bl dataset get` | Get details of a single dataset file | [dataset.md](dataset.md) | -| `bl dataset list` | List uploaded dataset files | [dataset.md](dataset.md) | -| `bl dataset upload` | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) | -| `bl dataset validate` | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) | -| `bl deploy audio create` | Create an audio (TTS) model deployment | [deploy.md](deploy.md) | -| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) | [deploy.md](deploy.md) | -| `bl deploy get` | Get details of a single model deployment | [deploy.md](deploy.md) | -| `bl deploy image create` | Create an image generation model deployment | [deploy.md](deploy.md) | -| `bl deploy list` | List model deployments | [deploy.md](deploy.md) | -| `bl deploy models` | List models available for deployment | [deploy.md](deploy.md) | -| `bl deploy scale` | Scale a deployment's capacity | [deploy.md](deploy.md) | -| `bl deploy text create` | Create a text model deployment | [deploy.md](deploy.md) | -| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) | -| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) | -| `bl finetune audio create` | Create an audio TTS model fine-tune job (sft-lora) | [finetune.md](finetune.md) | -| `bl finetune cancel` | Cancel a running fine-tune job | [finetune.md](finetune.md) | -| `bl finetune capability` | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | [finetune.md](finetune.md) | -| `bl finetune checkpoints` | List checkpoints produced by a fine-tune job | [finetune.md](finetune.md) | -| `bl finetune delete` | Delete a fine-tune job record | [finetune.md](finetune.md) | -| `bl finetune export` | Publish a checkpoint as a deployable model | [finetune.md](finetune.md) | -| `bl finetune get` | Get details of a single fine-tune job | [finetune.md](finetune.md) | -| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) | -| `bl finetune list` | List fine-tune jobs | [finetune.md](finetune.md) | -| `bl finetune logs` | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) | -| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) | -| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) | -| `bl image edit` | Edit an existing image with text instructions (Qwen-Image) | [image.md](image.md) | -| `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | -| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) | -| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) | -| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) | -| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) | -| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) | -| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) | -| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) | -| `bl memory delete` | Delete a memory node | [memory.md](memory.md) | -| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) | -| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) | -| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) | -| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) | -| `bl memory update` | Update a memory node content | [memory.md](memory.md) | -| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) | -| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | -| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) | -| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) | -| `bl plugin install` | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) | -| `bl plugin link` | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) | -| `bl plugin list` | List installed Command Packs and their load status | [plugin.md](plugin.md) | -| `bl plugin remove` | Remove an installed Command Pack | [plugin.md](plugin.md) | -| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) | -| `bl quota history` | View quota change history | [quota.md](quota.md) | -| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) | -| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) | -| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) | -| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) | -| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | -| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) | -| `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) | -| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) | -| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) | -| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) | -| `bl update` | Update the CLI to the latest version | [update.md](update.md) | -| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) | -| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | -| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) | -| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) | -| `bl video download` | Download a completed video by task ID | [video.md](video.md) | -| `bl video edit` | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | -| `bl video generate` | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) | -| `bl video ref` | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | -| `bl video task get` | Query async task status | [video.md](video.md) | -| `bl vision describe` | Describe an image or video using Qwen-VL | [vision.md](vision.md) | -| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) | +| Command | Description | Detail | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | +| `bl advisor recommend` | Recommend the best models for your use case (intent analysis → candidate recall → LLM ranking) | [advisor.md](advisor.md) | +| `bl app call` | Call a Bailian application (agent or workflow) | [app.md](app.md) | +| `bl app list` | List Bailian applications | [app.md](app.md) | +| `bl auth generate-access-token` | Generate a CLI access token using OpenAPI AK/SK | [auth.md](auth.md) | +| `bl auth login` | Authenticate with API key, console browser login, or OpenAPI AK/SK (credentials can coexist) | [auth.md](auth.md) | +| `bl auth logout` | Clear stored credentials | [auth.md](auth.md) | +| `bl auth status` | Show current authentication state | [auth.md](auth.md) | +| `bl bootstrap` | Initialize Bailian workspace and activate postpaid services | [bootstrap.md](bootstrap.md) | +| `bl config set` | Set a config value | [config.md](config.md) | +| `bl config show` | Display current configuration | [config.md](config.md) | +| `bl config ui` | Open a local web UI to manage config profiles | [config.md](config.md) | +| `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) | +| `bl dataset delete` | Delete a dataset file by ID | [dataset.md](dataset.md) | +| `bl dataset get` | Get details of a single dataset file | [dataset.md](dataset.md) | +| `bl dataset list` | List uploaded dataset files | [dataset.md](dataset.md) | +| `bl dataset upload` | Upload a dataset file (.jsonl or .zip) to Bailian | [dataset.md](dataset.md) | +| `bl dataset validate` | Locally validate a dataset file (.jsonl or .zip) without uploading | [dataset.md](dataset.md) | +| `bl deploy audio create` | Create an audio (TTS) model deployment | [deploy.md](deploy.md) | +| `bl deploy delete` | Delete a model deployment (must be STOPPED or FAILED) | [deploy.md](deploy.md) | +| `bl deploy get` | Get details of a single model deployment | [deploy.md](deploy.md) | +| `bl deploy image create` | Create an image generation model deployment | [deploy.md](deploy.md) | +| `bl deploy list` | List model deployments | [deploy.md](deploy.md) | +| `bl deploy models` | List models available for deployment | [deploy.md](deploy.md) | +| `bl deploy scale` | Scale a deployment's capacity | [deploy.md](deploy.md) | +| `bl deploy text create` | Create a text model deployment | [deploy.md](deploy.md) | +| `bl deploy update` | Update a deployment's rate limits (rpm_limit / tpm_limit) | [deploy.md](deploy.md) | +| `bl file upload` | Upload a local file to DashScope temporary storage (48h) | [file.md](file.md) | +| `bl finetune audio create` | Create an audio TTS model fine-tune job (sft-lora) | [finetune.md](finetune.md) | +| `bl finetune cancel` | Cancel a running fine-tune job | [finetune.md](finetune.md) | +| `bl finetune capability` | Query fine-tune training capability — by model (which training types it supports) or by training type (which models support it) | [finetune.md](finetune.md) | +| `bl finetune checkpoints` | List checkpoints produced by a fine-tune job | [finetune.md](finetune.md) | +| `bl finetune delete` | Delete a fine-tune job record | [finetune.md](finetune.md) | +| `bl finetune export` | Publish a checkpoint as a deployable model | [finetune.md](finetune.md) | +| `bl finetune get` | Get details of a single fine-tune job | [finetune.md](finetune.md) | +| `bl finetune image create` | Create an image generation model fine-tune job (sft-lora) | [finetune.md](finetune.md) | +| `bl finetune list` | List fine-tune jobs | [finetune.md](finetune.md) | +| `bl finetune logs` | Fetch training logs for a fine-tune job | [finetune.md](finetune.md) | +| `bl finetune text create` | Create a text model fine-tune job (sft \| sft-lora \| dpo \| dpo-lora \| cpt) | [finetune.md](finetune.md) | +| `bl finetune watch` | Probe a fine-tune job's status (default: single non-blocking fetch). Pass --follow to poll until terminal. | [finetune.md](finetune.md) | +| `bl image edit` | Edit an existing image with text instructions (Qwen-Image) | [image.md](image.md) | +| `bl image generate` | Generate images (Qwen-Image / wan2.x) | [image.md](image.md) | +| `bl knowledge chat` | Chat with a Bailian knowledge base (RAG Q&A with streaming) | [knowledge.md](knowledge.md) | +| `bl knowledge retrieve` | Retrieve from a Bailian knowledge base (deprecated, use `search` instead) | [knowledge.md](knowledge.md) | +| `bl knowledge search` | Search a Bailian knowledge base (RAG semantic retrieval) | [knowledge.md](knowledge.md) | +| `bl mcp call` | Call a tool on an MCP server (tools/call) | [mcp.md](mcp.md) | +| `bl mcp list` | List MCP servers activated under your Bailian account | [mcp.md](mcp.md) | +| `bl mcp tools` | List tools exposed by an MCP server (tools/list) | [mcp.md](mcp.md) | +| `bl memory add` | Add memory from messages or custom content | [memory.md](memory.md) | +| `bl memory delete` | Delete a memory node | [memory.md](memory.md) | +| `bl memory list` | List memory nodes for a user | [memory.md](memory.md) | +| `bl memory profile create` | Create a user profile schema for memory profiling | [memory.md](memory.md) | +| `bl memory profile get` | Get user profile by schema ID and user ID | [memory.md](memory.md) | +| `bl memory search` | Search memory nodes by query or messages | [memory.md](memory.md) | +| `bl memory update` | Update a memory node content | [memory.md](memory.md) | +| `bl model list` | Browse model families or show detailed model info in the Bailian model marketplace | [model.md](model.md) | +| `bl omni` | Multimodal chat with text + audio output (Qwen-Omni) | [omni.md](omni.md) | +| `bl pipeline run` | Run a pipeline workflow definition | [pipeline.md](pipeline.md) | +| `bl pipeline validate` | Validate a pipeline definition without executing | [pipeline.md](pipeline.md) | +| `bl plugin install` | Install or upgrade an allowlisted Command Pack | [plugin.md](plugin.md) | +| `bl plugin link` | Link an allowlisted local Command Pack for development | [plugin.md](plugin.md) | +| `bl plugin list` | List installed Command Packs and their load status | [plugin.md](plugin.md) | +| `bl plugin remove` | Remove an installed Command Pack | [plugin.md](plugin.md) | +| `bl quota check` | Check current usage against rate limits | [quota.md](quota.md) | +| `bl quota history` | View quota change history | [quota.md](quota.md) | +| `bl quota list` | View model RPM/TPM rate limits | [quota.md](quota.md) | +| `bl quota request` | Request a temporary quota increase | [quota.md](quota.md) | +| `bl search web` | Search the web using DashScope MCP WebSearch service | [search.md](search.md) | +| `bl speech recognize` | Recognize speech from audio files (FunAudio-ASR) | [speech.md](speech.md) | +| `bl speech synthesize` | Synthesize speech from text (CosyVoice TTS) | [speech.md](speech.md) | +| `bl text chat` | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) | +| `bl token-plan add-member` | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) | +| `bl token-plan assign-seats` | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) | +| `bl token-plan create-key` | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) | +| `bl token-plan list-seats` | List Token Plan subscription seat details | [token-plan.md](token-plan.md) | +| `bl update` | Update the CLI to the latest version | [update.md](update.md) | +| `bl usage free` | Query free-tier quota for models (all models if --model is omitted) | [usage.md](usage.md) | +| `bl usage freetier` | Enable or disable auto-stop for free-tier models. Enables by default; use --off to disable | [usage.md](usage.md) | +| `bl usage stats` | Query model usage statistics | [usage.md](usage.md) | +| `bl usage summary` | Show a unified usage summary: free-tier quota and recent usage overview | [usage.md](usage.md) | +| `bl video download` | Download a completed video by task ID | [video.md](video.md) | +| `bl video edit` | Edit a video with happyhorse-1.0-video-edit (style transfer, object replacement, etc.) | [video.md](video.md) | +| `bl video generate` | Generate a video from text or image (happyhorse-1.1-t2v / happyhorse-1.1-i2v / wan2.6-t2v) | [video.md](video.md) | +| `bl video ref` | Reference-to-video generation (happyhorse-1.1-r2v / wan2.6-r2v): multi-subject, multi-shot with voice | [video.md](video.md) | +| `bl video task get` | Query async task status | [video.md](video.md) | +| `bl vision describe` | Describe an image or video using Qwen-VL | [vision.md](vision.md) | +| `bl workspace list` | List all workspaces | [workspace.md](workspace.md) | ## By group @@ -100,8 +103,9 @@ Use this index for the full quick index and global flags. | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | | `advisor` | `recommend` | [advisor.md](advisor.md) | | `app` | `call`, `list` | [app.md](app.md) | -| `auth` | `login`, `logout`, `status` | [auth.md](auth.md) | -| `config` | `set`, `show` | [config.md](config.md) | +| `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) | +| `bootstrap` | `(root)` | [bootstrap.md](bootstrap.md) | +| `config` | `set`, `show`, `ui` | [config.md](config.md) | | `console` | `call` | [console.md](console.md) | | `dataset` | `delete`, `get`, `list`, `upload`, `validate` | [dataset.md](dataset.md) | | `deploy` | `audio create`, `delete`, `get`, `image create`, `list`, `models`, `scale`, `text create`, `update` | [deploy.md](deploy.md) | @@ -137,6 +141,7 @@ Available on every command (in addition to command-specific flags): | `--quiet` | switch | no | Suppress non-essential output | | `--verbose` | switch | no | Print HTTP request/response details | | `--dry-run` | switch | no | Dry run mode | +| `--config ` | string | no | Use named config credentials | | `--help` | switch | no | Show help | | `--version` | switch | no | Print version | @@ -168,6 +173,7 @@ Available on OpenAPI-domain commands (AK/SK auth); also listed per command below | --------------------------- | ------ | -------- | ---------------------------------------------------------------------- | | `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (env: ALIBABA_CLOUD_ACCESS_KEY_ID) | | `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET) | +| `--security-token ` | string | no | Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN) | ## Notes diff --git a/skills/bailian-cli/reference/token-plan.md b/skills/bailian-cli/reference/token-plan.md index 09a4691..aec6656 100644 --- a/skills/bailian-cli/reference/token-plan.md +++ b/skills/bailian-cli/reference/token-plan.md @@ -36,6 +36,7 @@ Index: [index.md](index.md) | `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | | `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (env: ALIBABA_CLOUD_ACCESS_KEY_ID) | | `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET) | +| `--security-token ` | string | no | Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN) | #### Examples @@ -71,6 +72,7 @@ bl token-plan add-member --account-name member1 --org-id org_123 --spec-type sta | `--locale ` | string | no | Language: zh-CN or en-US | | `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (env: ALIBABA_CLOUD_ACCESS_KEY_ID) | | `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET) | +| `--security-token ` | string | no | Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN) | #### Examples @@ -101,6 +103,7 @@ bl token-plan assign-seats --workspace-id ws_456 --seat-type pro --account-id ac | `--namespace-id ` | string | no | Product namespace ID (Token Plan default: namespace-1) | | `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (env: ALIBABA_CLOUD_ACCESS_KEY_ID) | | `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET) | +| `--security-token ` | string | no | Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN) | #### Examples @@ -135,6 +138,7 @@ bl token-plan create-key --account-id acc_123 --workspace-id ws_456 --descriptio | `--query-assigned ` | string | no | Filter by assignment: true=assigned, false=unassigned | | `--access-key-id ` | string | no | Alibaba Cloud Access Key ID (env: ALIBABA_CLOUD_ACCESS_KEY_ID) | | `--access-key-secret ` | string | no | Alibaba Cloud Access Key Secret (env: ALIBABA_CLOUD_ACCESS_KEY_SECRET) | +| `--security-token ` | string | no | Alibaba Cloud STS Security Token (env: ALIBABA_CLOUD_SECURITY_TOKEN) | #### Examples