From de9f1a3889b01692196ca2d3491878fc183182ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 16 Jul 2026 11:05:59 +0800 Subject: [PATCH] feat(config): add active profile selection - persist the active profile in config.json - resolve config with --config > active_config > default - add config list and config use commands - make auth and config writes target the selected profile - reset activation to default when deleting the active profile - update config UI with profile activation controls - keep token refresh and pipeline execution profile-aware - add loader, UI, auth, and CLI interaction coverage --- AGENTS.md | 31 +-- docs/agents/auth-change.md | 2 +- docs/agents/config-profile-change.md | 69 +++++++ docs/token-plan-profile-integration.md | 16 +- packages/cli/src/commands.ts | 4 + .../cli/tests/e2e/config-profile.e2e.test.ts | 39 +++- packages/commands/src/commands/auth/status.ts | 3 +- packages/commands/src/commands/config/list.ts | 31 +++ .../commands/src/commands/config/ui-html.ts | 101 ++++++++-- packages/commands/src/commands/config/ui.ts | 49 +++-- packages/commands/src/commands/config/use.ts | 42 +++++ packages/commands/src/index.ts | 2 + packages/commands/tests/config-ui.test.ts | 70 +++++-- packages/commands/tests/e2e/auth.e2e.test.ts | 45 +++++ .../commands/tests/e2e/config.e2e.test.ts | 92 +++++++++ packages/commands/tests/e2e/topic-routes.ts | 2 + .../tests/e2e/usage-stats.e2e.test.ts | 4 +- packages/core/src/auth/refresh-token.ts | 7 +- packages/core/src/config/index.ts | 8 +- packages/core/src/config/loader.ts | 82 +++++++- packages/core/src/config/store.ts | 22 ++- packages/core/src/types/command.ts | 2 +- packages/core/tests/config-store.test.ts | 177 +++++++++++++++++- packages/e2e/src/gating.ts | 8 +- packages/runtime/src/pipeline/bl-config.ts | 4 +- skills/bailian-cli/assets/setup.md | 15 ++ 26 files changed, 825 insertions(+), 102 deletions(-) create mode 100644 docs/agents/config-profile-change.md create mode 100644 packages/commands/src/commands/config/list.ts create mode 100644 packages/commands/src/commands/config/use.ts diff --git a/AGENTS.md b/AGENTS.md index a28fa7b..d26a7a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,21 +56,22 @@ Skill / 命令手册随 `skills/bailian-cli/` 经 `npx skills add modelstudioai/ 按当前任务从下表挑一条进入对应文档: -| 场景 | 何时进入 | 详见 | -| -------------- | -------------------------------------------- | ------------------------------------------------------------------------ | -| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) | -| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) | -| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) | -| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | -| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | -| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | -| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | -| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | -| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) | -| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) | -| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) | -| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) | -| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) | +| 场景 | 何时进入 | 详见 | +| -------------- | -------------------------------------------- | ---------------------------------------------------------------------------- | +| 命令增删改 | 增加 / 删除 / 重命名 `bl xxx` 或入口命令路径 | [docs/agents/command-add-remove.md](docs/agents/command-add-remove.md) | +| E2E 测试维护 | 新增/改命令或 e2e 用例、补 help/缺参/dry-run | [docs/agents/cli-e2e-tests.md](docs/agents/cli-e2e-tests.md) | +| 批量压测 | 改/跑多能力并发压测、`test:stress`、fixtures | [docs/agents/stress-batch-tests.md](docs/agents/stress-batch-tests.md) | +| 选项变更 | 给已有命令加 `--flag` 或改默认值 | [docs/agents/command-flag-change.md](docs/agents/command-flag-change.md) | +| 模型上下架 | 增加新模型 / 改默认模型 / 废弃旧模型 | [docs/agents/model-add-remove.md](docs/agents/model-add-remove.md) | +| 错误文案变更 | 改 `BailianError` 的 message 或 hint | [docs/agents/error-hint-change.md](docs/agents/error-hint-change.md) | +| URL / 渠道变更 | 控制台域名 / 文档站 / 追踪参数 | [docs/agents/url-change.md](docs/agents/url-change.md) | +| 鉴权扩展 | 加 OAuth / SSO / 换 token 来源 | [docs/agents/auth-change.md](docs/agents/auth-change.md) | +| 配置项扩展 | 新 env var 或 `~/.bailian/config.json` 字段 | [docs/agents/config-add.md](docs/agents/config-add.md) | +| Profile / 激活 | 改命名 Profile、预设或 `active_config` | [docs/agents/config-profile-change.md](docs/agents/config-profile-change.md) | +| 发布 | channel / stable 发布到 npm(CI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) | +| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) | +| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) | +| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) | 如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/.md`,把清单沉淀下来。 diff --git a/docs/agents/auth-change.md b/docs/agents/auth-change.md index 72ca85f..9f641a6 100644 --- a/docs/agents/auth-change.md +++ b/docs/agents/auth-change.md @@ -37,7 +37,7 @@ defineCommand({ auth }) → runtime/authStage → ctx.client → command.run(ctx - `bl auth login --console` 只更新 `access_token` 以及回调携带的 console 作用域字段 - `bl auth login --open-api ...` 只更新 `access_key_id` / `access_key_secret` - `bl auth logout --console` 只清 `access_token` -- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` +- `bl auth logout --open-api` 只清 `access_key_id` / `access_key_secret` / `security_token` - `bl auth logout` 清 `api_key` + `access_token` + `access_key_*` 解析分工: diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md new file mode 100644 index 0000000..fa4ec93 --- /dev/null +++ b/docs/agents/config-profile-change.md @@ -0,0 +1,69 @@ +# Config Profile 与激活状态变更清单 + +适用于新增 Profile 预设、修改命名 Profile 选择规则、调整 `active_config`,或新增/修改 `bl config list/use/show/ui` 等 Profile 管理能力。 + +## 1. 保持存储边界 + +- Profile 业务字段继续由 `ConfigFile` / `CONFIG_FILE_KEYS` 管理。 +- `active_config` 是 `config.json` 顶层元数据,不得进入命名 Profile block,也不得被 `config set` 当作普通字段写入。 +- 识别命名 Profile 时必须排除业务字段和顶层元数据。 +- 旧配置缺少 `active_config` 时继续等价于激活 `default`。 + +## 2. 保持选择语义 + +```text +显式 --config > active_config > default +``` + +- 解析阶段用局部变量保留“是否显式传入 `--config`”的信息;完成 Config 选择后不进入 `Settings`。 +- `--config default` 必须显式选择顶层配置并绕过命名激活项。 +- `--config` 和 `auth login --config ...` 不得隐式修改持久化激活状态。 +- 激活状态只选择配置 block,不改变字段优先级;字段仍为 flag > env > selected config > 默认值。 +- Pipeline 等进程内调用链也要复用统一的 `buildSources()`,避免绕过激活状态。 +- Console access token 自动刷新等后台读写必须携带 `settings.configName`,不得直接读写顶层 default。 + +## 3. 保持读写命令交互一致 + +- `auth login`、`config set` 等写命令未传 `--config` 时修改当前激活项。 +- 写命令显式指定不存在的 `--config ` 时,仅在业务操作成功并实际落盘时创建 Profile。 +- `config show`、`auth status` 和业务消费等读命令不得因为显式指定不存在的名称而创建 Profile。 +- `auth logout` 默认只清理当前激活项;显式 `--config` 只清理指定项。 +- 按凭证域退出时必须清理该域的完整字段集合,例如 OpenAPI 同时清理 AK、SK 和 STS `security_token`。 +- 所有生产代码读取“当前配置”时优先经过 `buildSources()` 或携带解析后的 `configName`;直接调用无名称的 `readConfigFile()` / `writeConfigFile()` 只适用于明确操作顶层 default 的底层能力。 + +## 4. 保持状态一致性 + +- `config use` 只能激活已经存在的命名 Profile;`default` 始终有效。 +- 配置文件中的 `active_config` 指向不存在的 Profile 时返回 usage error,不静默回退。 +- 删除当前激活的命名 Profile 时,同一次落盘切回 `default`,不得留下悬空引用。 +- 配置写入继续使用临时文件 + rename,避免中断后留下半写文件。 + +## 5. 命令与展示联动 + +- 新增/重命名命令时同步 `packages/commands/src/index.ts` 和产品入口 `packages/cli/src/commands.ts`。 +- `config list` 标识所有 Profile 与当前激活项。 +- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。 +- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。 +- 同步 E2E topic routes、Skill setup 和自动生成 reference。 + +## 6. 最小测试矩阵 + +- 旧配置无 `active_config` -> `default`。 +- 激活命名 Profile 后,无 `--config` 的命令选择该 Profile。 +- 显式命名 `--config` 和 `--config default` 均覆盖激活项且不修改磁盘状态。 +- 激活不存在的 Profile 失败且不写盘。 +- 悬空 `active_config` 明确失败。 +- 删除激活 Profile 后切回 `default`。 +- 登录、退出、`config set` 分别覆盖“当前激活项”和“显式不存在名称成功后创建”。 +- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。 +- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。 + +## 7. 完成检查 + +```sh +pnpm run sync:skill-assets +vp check +vp test +``` + +命令 E2E 会启动本地子进程,Config UI 测试还会监听 `127.0.0.1` 临时端口;受限沙箱内出现 `EPERM` 时,需要在允许本地进程和端口的环境中复跑。 diff --git a/docs/token-plan-profile-integration.md b/docs/token-plan-profile-integration.md index a504383..5bccab0 100644 --- a/docs/token-plan-profile-integration.md +++ b/docs/token-plan-profile-integration.md @@ -1,6 +1,6 @@ # Token Plan Profile 与激活配置接入方案 -> 状态:Token Plan 模型消费 MVP 已实现;Config 激活状态和通用 Base URL 归一化待实现。 +> 状态:Token Plan 模型消费 MVP 与 Config 激活状态已实现;通用 Base URL 归一化待实现。 > > 目标分支:`feat/cli-access-token`。 @@ -163,7 +163,7 @@ token-plan * - 未传 `--config`:展示当前激活的 Config。 - 传 `--config `:展示指定 Config,不改变激活状态。 -- 输出中包含 `config`、`active` 和 `config_file`。 +- 输出中包含最终选择的 `config` 和 `config_file`;激活状态统一由 `config list` / `config ui` 展示。 `config ui` 应展示当前激活项,并提供激活操作。 @@ -453,7 +453,7 @@ feat(cli): enable token-plan text and image consumption - [ ] 增加 `auth login --config token-plan --api-key ...`、文本消费和图片消费示例。 - [ ] 与届时实际上线范围核对模型名称、服务地域、限制条件和用户措辞。 -### Commit 4:Config 激活状态与切换命令(待实现) +### Commit 4:Config 激活状态与切换命令(已实现) 建议提交信息: @@ -468,12 +468,18 @@ feat(config): add active profile selection - 保证 `--config default` 能显式覆盖激活项。 - 新增 `bl config list`。 - 新增 `bl config use --name `。 -- `config show`、`auth status` 和 `config ui` 展示激活状态。 +- `config show`、`auth status` 展示最终选择项,`config list` 和 `config ui` 展示激活状态。 - 删除激活 Profile 时处理状态一致性。 - 验证激活 `token-plan` 后不传 `--config` 的文本和图片请求。 - 验证临时 `--config default` 不改变激活状态。 - 更新命令导出、`packages/cli/src/commands.ts`、E2E 和生成 reference。 +实现选择:删除当前激活的命名 Profile 时,在同一次配置文件写入中将 `active_config` 重置为 `default`。`auth login --config ` 和所有显式 `--config` 仍只作用于本次命令,不修改激活状态。 + +相关写入交互统一为:`auth login`、`auth logout` 和 `config set` 未传 `--config` 时作用于当前激活项;显式指定名称时作用于该名称。写命令可在成功落盘时创建不存在的 Profile,读命令不创建。Console access token 自动刷新同样限定在当前选中的 Profile,不得回退读写顶层 default。 + +激活项选择的是完整 Config,而不是只选择模型消费凭证。激活 `token-plan` 后,Token Plan 管控命令也会从该 Profile 解析 OpenAPI AK/SK,Console 命令也会从该 Profile 解析 Console 凭证。如果相应凭证仍保存在顶层 `default`,用户需要为单次命令显式传入 `--config default`,或将对应凭证域登录到 `token-plan`;CLI 不为不同鉴权域做隐式跨 Profile 回退。 + ### Commit 5:通用模型 Base URL 归一化(待实现) 建议提交信息: @@ -532,7 +538,7 @@ vp check vp test ``` -完成改动后,应评估“Profile 预设与激活状态”是否需要沉淀为新的 `docs/agents/config-profile-change.md` 场景清单。 +“Profile 预设与激活状态”的维护要求已沉淀到 `docs/agents/config-profile-change.md`。 ## 最终结论 diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 1fb3aa1..556a0b9 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -16,6 +16,8 @@ import { visionDescribe, configShow, configSet, + configList, + configUse, configUi, update, appCall, @@ -110,6 +112,8 @@ export const commands: Record = { "vision describe": visionDescribe, "config show": configShow, "config set": configSet, + "config list": configList, + "config use": configUse, "config ui": configUi, update, "app call": appCall, diff --git a/packages/cli/tests/e2e/config-profile.e2e.test.ts b/packages/cli/tests/e2e/config-profile.e2e.test.ts index d9f996c..8dec96e 100644 --- a/packages/cli/tests/e2e/config-profile.e2e.test.ts +++ b/packages/cli/tests/e2e/config-profile.e2e.test.ts @@ -89,6 +89,9 @@ describe("e2e: named config", () => { const devStatus = await runCli(["auth", "status", "--config", "dev", "--output", "json"], { BAILIAN_CONFIG_DIR: dir, + DASHSCOPE_API_KEY: "", + ALIBABA_CLOUD_ACCESS_KEY_ID: "", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }); expect(devStatus.exitCode, devStatus.stderr).toBe(0); const devData = parseStdoutJson>(devStatus.stdout); @@ -97,6 +100,9 @@ describe("e2e: named config", () => { const defaultStatus = await runCli(["auth", "status", "--output", "json"], { BAILIAN_CONFIG_DIR: dir, + DASHSCOPE_API_KEY: "", + ALIBABA_CLOUD_ACCESS_KEY_ID: "", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", }); expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0); const defaultData = parseStdoutJson>(defaultStatus.stdout); @@ -107,7 +113,12 @@ describe("e2e: named config", () => { test("--config default 等价默认配置", async () => { await withTempConfigDir(async (dir) => { - writeConfig(dir, { output: "json", api_key: "sk-default" }); + writeConfig(dir, { + active_config: "token-plan", + output: "json", + api_key: "sk-default", + "token-plan": { output: "text", api_key: "sk-token" }, + }); const { stdout, stderr, exitCode } = await runCli( ["config", "show", "--config", "default", "--output", "json"], { BAILIAN_CONFIG_DIR: dir }, @@ -115,7 +126,13 @@ describe("e2e: named config", () => { expect(exitCode, stderr).toBe(0); const data = parseStdoutJson>(stdout); expect(data.config).toBe("default"); + expect(data.active).toBeUndefined(); expect(data.api_key).toBeDefined(); + const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(raw.active_config).toBe("token-plan"); }); }); @@ -124,4 +141,24 @@ describe("e2e: named config", () => { expect(exitCode).toBe(2); expect(stderr).toMatch(/Invalid config name/); }); + + test("auth status 文本输出分行展示选中 Config 和配置文件", async () => { + await withTempConfigDir(async (dir) => { + writeConfig(dir, { + active_config: "token-plan", + "token-plan": { api_key: "sk-token" }, + }); + + const result = await runCli(["auth", "status", "--output", "text"], { + BAILIAN_CONFIG_DIR: dir, + DASHSCOPE_API_KEY: "", + ALIBABA_CLOUD_ACCESS_KEY_ID: "", + ALIBABA_CLOUD_ACCESS_KEY_SECRET: "", + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Config: token-plan\n"); + expect(result.stdout).toContain(`Config file: ${join(dir, "config.json")}\n`); + expect(result.stdout).not.toContain("Active config:"); + }); + }); }); diff --git a/packages/commands/src/commands/auth/status.ts b/packages/commands/src/commands/auth/status.ts index 99e5824..d15c603 100644 --- a/packages/commands/src/commands/auth/status.ts +++ b/packages/commands/src/commands/auth/status.ts @@ -72,7 +72,8 @@ export default defineCommand({ return; } - emitBare(`Config: ${configName} (${configFile})`); + emitBare(`Config: ${configName}`); + emitBare(`Config file: ${configFile}`); emitBare("Authentication Status:"); if (apiKey) { emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`); diff --git a/packages/commands/src/commands/config/list.ts b/packages/commands/src/commands/config/list.ts new file mode 100644 index 0000000..bff9694 --- /dev/null +++ b/packages/commands/src/commands/config/list.ts @@ -0,0 +1,31 @@ +import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { emitBare, emitResult } from "bailian-cli-runtime"; + +export default defineCommand({ + description: "List config profiles and show the active profile", + auth: "none", + exampleArgs: ["", "--output json"], + async run(ctx) { + const profiles = ctx.configStore.profiles(); + const names = ["default", ...Object.keys(profiles.named).sort()]; + const format = detectOutputFormat(ctx.settings.output); + + if (format === "json") { + emitResult( + { + active_config: profiles.active, + profiles: names, + config_file: ctx.configStore.path, + }, + format, + ); + return; + } + + const nameWidth = Math.max("NAME".length, ...names.map((name) => name.length)); + emitBare(`${"NAME".padEnd(nameWidth)} ACTIVE`); + for (const name of names) { + emitBare(`${name.padEnd(nameWidth)} ${name === profiles.active ? "*" : ""}`); + } + }, +}); diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts index c028626..1bcbf8c 100644 --- a/packages/commands/src/commands/config/ui-html.ts +++ b/packages/commands/src/commands/config/ui-html.ts @@ -16,7 +16,7 @@ export const PAGE_HTML = ` #profileList { list-style: none; margin: 0 0 12px; padding: 0; } #profileList li { padding: 8px 10px; border-radius: 6px; cursor: pointer; word-break: break-all; } #profileList li:hover { background: #f0f3f6; } - #profileList li.active { background: #0969da; color: #fff; } + #profileList li.selected { background: #0969da; color: #fff; } main { flex: 1; padding: 24px 32px; max-width: 720px; } #editorHead { display: flex; align-items: center; justify-content: space-between; } h2 { font-size: 18px; margin: 0 0 4px; } @@ -51,13 +51,14 @@ export const PAGE_HTML = `
+
diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts index eea68e9..4d206d2 100644 --- a/packages/commands/src/commands/config/ui.ts +++ b/packages/commands/src/commands/config/ui.ts @@ -7,10 +7,9 @@ import { BailianError, ExitCode, normalizeConfigName, - readConfigProfiles, writeConfigFile, deleteConfigProfile, - getConfigPath, + type ConfigStore, type FlagsDef, } from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; @@ -78,7 +77,7 @@ function buildProfilePatch(data: Record): Record { try { const host = (req.headers.host || "").split(":")[0]; @@ -105,18 +104,37 @@ export function createConfigUiServer(token: string, activeProfile: string | null } if (path === "/api/config" && method === "GET") { - const profiles = readConfigProfiles(); + const profiles = configStore.profiles(); sendJson(res, 200, { - configFile: getConfigPath(), + configFile: configStore.path, keys: VALID_KEYS, secretKeys: [...SECRET_KEYS], - activeProfile, + activeProfile: profiles.active, default: profiles.default, named: profiles.named, }); return; } + if (path === "/api/active" && method === "POST") { + const raw = await readBody(req); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + sendJson(res, 400, { error: "invalid JSON body" }); + return; + } + const body = parsed as { name?: unknown }; + try { + const activeProfile = await configStore.activate(body.name); + sendJson(res, 200, { activeProfile }); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + if (path === "/api/profile" && method === "POST") { const raw = await readBody(req); let parsed: unknown; @@ -146,19 +164,12 @@ export function createConfigUiServer(token: string, activeProfile: string | null } if (path === "/api/profile" && method === "DELETE") { - let normalized: string | undefined; try { - normalized = normalizeConfigName(u.searchParams.get("name") ?? undefined); + const deleted = await deleteConfigProfile(u.searchParams.get("name") ?? undefined); + sendJson(res, 200, { deleted, activeProfile: configStore.profiles().active }); } catch (err) { sendJson(res, 400, { error: errMessage(err) }); - return; } - if (!normalized) { - sendJson(res, 400, { error: "Cannot delete the default profile." }); - return; - } - const deleted = await deleteConfigProfile(normalized); - sendJson(res, 200, { deleted }); return; } @@ -176,7 +187,7 @@ export default defineCommand({ auth: "none", usageArgs: "[--port ] [--no-open]", flags: FLAGS, - exampleArgs: ["", "--port 8787", "--config staging --no-open"], + exampleArgs: ["", "--port 8787", "--no-open"], async run(ctx) { const { settings, flags } = ctx; const format = detectOutputFormat(settings.output); @@ -186,11 +197,12 @@ export default defineCommand({ { host: "127.0.0.1", port: flags.port ?? "random free port", - config_file: getConfigPath(), + config_file: ctx.configStore.path, routes: [ "GET / -> web UI", "GET /api/config -> read all profiles", "POST /api/profile -> save a profile", + "POST /api/active -> activate a profile", "DELETE /api/profile -> delete a named profile", ], }, @@ -200,8 +212,7 @@ export default defineCommand({ } const token = randomBytes(16).toString("hex"); - const activeProfile = settings.configName ?? null; - const server = createConfigUiServer(token, activeProfile); + const server = createConfigUiServer(token, ctx.configStore); let port: number; try { diff --git a/packages/commands/src/commands/config/use.ts b/packages/commands/src/commands/config/use.ts new file mode 100644 index 0000000..13de63a --- /dev/null +++ b/packages/commands/src/commands/config/use.ts @@ -0,0 +1,42 @@ +import { defineCommand, detectOutputFormat } from "bailian-cli-core"; +import { emitResult } from "bailian-cli-runtime"; + +export default defineCommand({ + description: "Set the active config profile", + auth: "none", + usageArgs: "--name ", + flags: { + name: { + type: "string", + valueHint: "", + description: "Existing profile name, or default", + required: true, + }, + }, + exampleArgs: ["--name token-plan", "--name default"], + async run(ctx) { + const format = detectOutputFormat(ctx.settings.output); + if (ctx.settings.dryRun) { + const activeConfig = ctx.configStore.validateActivation(ctx.flags.name); + emitResult( + { + would_activate: activeConfig, + config_file: ctx.configStore.path, + }, + format, + ); + return; + } + + const activeConfig = await ctx.configStore.activate(ctx.flags.name); + if (!ctx.settings.quiet) { + emitResult( + { + active_config: activeConfig, + config_file: ctx.configStore.path, + }, + format, + ); + } + }, +}); diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index d84ee22..2fdc1f1 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -19,6 +19,8 @@ export { default as videoDownload } from "./commands/video/download.ts"; export { default as visionDescribe } from "./commands/vision/describe.ts"; export { default as configShow } from "./commands/config/show.ts"; export { default as configSet } from "./commands/config/set.ts"; +export { default as configList } from "./commands/config/list.ts"; +export { default as configUse } from "./commands/config/use.ts"; export { default as configUi } from "./commands/config/ui.ts"; export { default as update } from "./commands/update.ts"; export { default as appCall } from "./commands/app/call.ts"; diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index d2fbc49..5aeed18 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -3,7 +3,13 @@ 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 { + activateConfigProfile, + makeConfigStore, + writeConfigFile, + readConfigFile, + readConfigProfiles, +} from "bailian-cli-core"; import { createConfigUiServer } from "../src/commands/config/ui.ts"; const TOKEN = "test-token"; @@ -44,14 +50,11 @@ function httpJson( } /** 隔离临时配置目录 + 启动 UI server,跑完清理。 */ -async function withServer( - activeProfile: string | null, - fn: (port: number) => Promise, -): Promise { +async function withServer(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); + const server = createConfigUiServer(TOKEN, makeConfigStore()); 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; @@ -65,10 +68,11 @@ async function withServer( } } -test("GET /api/config 返回全部 profile 且密钥明文回传、activeProfile 反映 --config", async () => { - await withServer("dev", async (port) => { +test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", async () => { + await withServer(async (port) => { await writeConfigFile({ api_key: "sk-default", output: "json" }); await writeConfigFile({ api_key: "sk-dev", access_token: "tok-dev" }, "dev"); + await activateConfigProfile("dev"); const res = await httpJson(port, "GET", `/api/config?token=${TOKEN}`); expect(res.status).toBe(200); @@ -80,7 +84,7 @@ test("GET /api/config 返回全部 profile 且密钥明文回传、activeProfile }); test("鉴权:错误 token 401、非 loopback Host 403", async () => { - await withServer(null, async (port) => { + await withServer(async (port) => { const bad = await httpJson(port, "GET", `/api/config?token=wrong`); expect(bad.status).toBe(401); @@ -92,7 +96,7 @@ test("鉴权:错误 token 401、非 loopback Host 403", async () => { }); test("POST /api/profile 写命名 profile(timeout 强制为 number),空串清除键", async () => { - await withServer(null, async (port) => { + await withServer(async (port) => { const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { body: { name: "stage", data: { api_key: "sk-stage", timeout: "90" } }, }); @@ -110,8 +114,23 @@ test("POST /api/profile 写命名 profile(timeout 强制为 number),空串 }); }); +test("New profile 立即保存空 Profile,其他配置读取可以看到", async () => { + await withServer(async (port) => { + const create = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { name: "new-profile", data: {} }, + }); + expect(create.status).toBe(200); + expect(create.json.saved).toEqual({}); + expect(readConfigProfiles().named["new-profile"]).toEqual({}); + + const list = await httpJson(port, "GET", `/api/config?token=${TOKEN}`); + expect(list.status).toBe(200); + expect(list.json.named["new-profile"]).toEqual({}); + }); +}); + test("POST /api/profile 非法 key 返回 400", async () => { - await withServer(null, async (port) => { + await withServer(async (port) => { const res = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { body: { name: "stage", data: { not_a_key: "x" } }, }); @@ -121,7 +140,7 @@ test("POST /api/profile 非法 key 返回 400", async () => { }); test("DELETE /api/profile 删命名 profile;缺 name 返回 400", async () => { - await withServer(null, async (port) => { + await withServer(async (port) => { await writeConfigFile({ api_key: "sk-stage" }, "stage"); const del = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`); expect(del.status).toBe(200); @@ -132,3 +151,30 @@ test("DELETE /api/profile 删命名 profile;缺 name 返回 400", async () => expect(noName.status).toBe(400); }); }); + +test("Save & Activate 创建并激活 Profile;删除激活项后切回 default", async () => { + await withServer(async (port) => { + const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { name: "stage", data: { api_key: "sk-stage" } }, + }); + expect(save.status).toBe(200); + + const activate = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, { + body: { name: "stage" }, + }); + expect(activate.status).toBe(200); + expect(activate.json.activeProfile).toBe("stage"); + expect(readConfigProfiles().active).toBe("stage"); + + const missing = await httpJson(port, "POST", `/api/active?token=${TOKEN}`, { + body: { name: "missing" }, + }); + expect(missing.status).toBe(400); + expect(readConfigProfiles().active).toBe("stage"); + + const deleted = await httpJson(port, "DELETE", `/api/profile?name=stage&token=${TOKEN}`); + expect(deleted.status).toBe(200); + expect(deleted.json.activeProfile).toBe("default"); + expect(readConfigProfiles().active).toBe("default"); + }); +}); diff --git a/packages/commands/tests/e2e/auth.e2e.test.ts b/packages/commands/tests/e2e/auth.e2e.test.ts index 1682f77..5841f86 100644 --- a/packages/commands/tests/e2e/auth.e2e.test.ts +++ b/packages/commands/tests/e2e/auth.e2e.test.ts @@ -248,6 +248,51 @@ describe("e2e: auth", () => { } }); + test("auth login 未传 --config 时写当前激活 Config", async () => { + const validationServer = await startValidationServer(); + const configDir = makeE2eOutputDir("auth-active-profile-login"); + writeFileSync( + join(configDir, "config.json"), + JSON.stringify( + { + active_config: "dev", + dev: { base_url: validationServer.baseUrl }, + }, + null, + 2, + ) + "\n", + ); + + const env = { + BAILIAN_CONFIG_DIR: configDir, + DASHSCOPE_API_KEY: "", + DASHSCOPE_BASE_URL: "", + }; + try { + const activeLogin = await runCommandE2e( + AUTH_ROUTES, + ["auth", "login", "--api-key", "sk-active-placeholder"], + env, + ); + expect(activeLogin.exitCode, activeLogin.stderr).toBe(0); + + expect(validationServer.requests).toHaveLength(1); + + const config = JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(config.api_key).toBeUndefined(); + expect(config.active_config).toBe("dev"); + expect(config.dev).toMatchObject({ + api_key: "sk-active-placeholder", + base_url: validationServer.baseUrl, + }); + } finally { + await validationServer.close(); + } + }); + test("auth login --api-key 验证失败不留下半配置", async () => { const validationServer = await startValidationServer(400); const configDir = makeE2eOutputDir("auth-api-key-login-failure"); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index fa9e46d..bcaca0f 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { describe, expect, test } from "vite-plus/test"; import { parseStdoutJson, runCommandE2e } from "./helpers.ts"; import { CONFIG_ROUTES } from "./topic-routes.ts"; @@ -19,6 +22,16 @@ describe("e2e: config", () => { expect(stderr).toMatch(/set|--key|--value/i); }); + test("config list/use --help 正常退出", async () => { + const listResult = await runCommandE2e(CONFIG_ROUTES, ["config", "list", "--help"]); + expect(listResult.exitCode, listResult.stderr).toBe(0); + expect(listResult.stderr).toMatch(/list|active|profile/i); + + const useResult = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--help"]); + expect(useResult.exitCode, useResult.stderr).toBe(0); + expect(useResult.stderr).toMatch(/use|--name|active/i); + }); + test("config ui --help 正常退出", async () => { const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "ui", "--help"]); expect(exitCode, stderr).toBe(0); @@ -74,6 +87,85 @@ describe("e2e: config", () => { expect(stderr).toMatch(/--key|--value|Usage:/i); }); + test("config use 缺少 --name 时报用法错误并退出 (2)", async () => { + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--quiet"]); + expect(exitCode, stderr).toBe(2); + expect(stderr).toMatch(/--name|Usage:/i); + }); + + test("config use 持久化激活项,config list 展示激活状态", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-")); + try { + const configPath = join(configDir, "config.json"); + writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n"); + const env = { BAILIAN_CONFIG_DIR: configDir }; + + const useResult = await runCommandE2e( + CONFIG_ROUTES, + ["config", "use", "--name", "dev", "--output", "json"], + env, + ); + expect(useResult.exitCode, useResult.stderr).toBe(0); + expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe( + "dev", + ); + expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev"); + + const listResult = await runCommandE2e( + CONFIG_ROUTES, + ["config", "list", "--output", "json"], + env, + ); + expect(listResult.exitCode, listResult.stderr).toBe(0); + const listData = parseStdoutJson<{ + active_config?: string; + profiles?: string[]; + }>(listResult.stdout); + expect(listData.active_config).toBe("dev"); + expect(listData.profiles).toEqual(["default", "dev"]); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + + test("config use --dry-run 校验目标但不修改激活项", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-dry-run-")); + try { + const configPath = join(configDir, "config.json"); + writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n"); + const result = await runCommandE2e( + CONFIG_ROUTES, + ["config", "use", "--name", "dev", "--dry-run", "--output", "json"], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(parseStdoutJson<{ would_activate?: string }>(result.stdout).would_activate).toBe( + "dev", + ); + expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined(); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + + test("config use 拒绝不存在的 Profile 且不写入状态", async () => { + const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-missing-")); + try { + const configPath = join(configDir, "config.json"); + writeFileSync(configPath, JSON.stringify({ output: "text" }, null, 2) + "\n"); + const result = await runCommandE2e( + CONFIG_ROUTES, + ["config", "use", "--name", "missing", "--output", "json"], + { BAILIAN_CONFIG_DIR: configDir }, + ); + expect(result.exitCode).toBe(2); + expect(result.stderr).toMatch(/does not exist/); + expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined(); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); + test("config set 非法 key 时退出为用法错误", async () => { const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index 29b3126..53cda0d 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -15,6 +15,8 @@ export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" }; export const CONFIG_ROUTES: E2eRouteExports = { "config show": "configShow", "config set": "configSet", + "config list": "configList", + "config use": "configUse", "config ui": "configUi", }; diff --git a/packages/commands/tests/e2e/usage-stats.e2e.test.ts b/packages/commands/tests/e2e/usage-stats.e2e.test.ts index 150a6c8..80986cf 100644 --- a/packages/commands/tests/e2e/usage-stats.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-stats.e2e.test.ts @@ -6,12 +6,12 @@ import { runCommandE2e, } from "./helpers.ts"; import { USAGE_ROUTES } from "./topic-routes.ts"; -import { readConfigFile } from "bailian-cli-core"; +import { buildSources } from "bailian-cli-core"; function getStaticWorkspaceId(): string | undefined { if (process.env.BAILIAN_WORKSPACE_ID?.trim()) return process.env.BAILIAN_WORKSPACE_ID.trim(); try { - const config = readConfigFile(); + const config = buildSources({}).file; if (config.workspace_id) return config.workspace_id; } catch {} return undefined; diff --git a/packages/core/src/auth/refresh-token.ts b/packages/core/src/auth/refresh-token.ts index c1d58ef..30644c5 100644 --- a/packages/core/src/auth/refresh-token.ts +++ b/packages/core/src/auth/refresh-token.ts @@ -62,7 +62,8 @@ export async function refreshAccessToken(opts: { settings: Settings; baseUrl: string; }): Promise { - const config = readConfigFile(); + const configName = opts.settings.configName; + const config = readConfigFile(configName); const accessKeyId = config.access_key_id; const accessKeySecret = config.access_key_secret; if (!accessKeyId || !accessKeySecret) return null; @@ -82,9 +83,9 @@ export async function refreshAccessToken(opts: { const token: string | undefined = resp.cliAccessToken; if (!token) return null; - const existing = readConfigFile() as Record; + const existing = readConfigFile(configName) as Record; existing.access_token = token; - await writeConfigFile(existing); + await writeConfigFile(existing, configName); return token; } diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 679c680..823454b 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -1,7 +1,13 @@ export type { ConfigFile, Region, Identity, Settings } from "./schema.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 { + activateConfigProfile, + validateConfigProfileActivation, + readConfigProfiles, + deleteConfigProfile, + type ConfigProfiles, +} from "./loader.ts"; export { buildSources, buildSettings, type ResolutionSources } from "./loader.ts"; export { makeConfigStore, type ConfigStore } from "./store.ts"; export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from "./paths.ts"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 6f9c241..dc24357 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -7,6 +7,11 @@ import { ExitCode } from "../errors/codes.ts"; import type { SourceFlags } from "../types/command.ts"; const CONFIG_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const ACTIVE_CONFIG_KEY = "active_config"; + +function isConfigBlock(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} /** * 校验并规范化 `--config `:`undefined`/""/"default" 都视为未指定(等价顶层默认配置)。 @@ -22,7 +27,7 @@ export function normalizeConfigName(name?: unknown): string | undefined { "Use letters, numbers, '-' or '_', starting with a letter or number.", ); } - if ((CONFIG_FILE_KEYS as readonly string[]).includes(name)) { + if ((CONFIG_FILE_KEYS as readonly string[]).includes(name) || name === ACTIVE_CONFIG_KEY) { throw new BailianError( `Invalid config name "${name}". It conflicts with a config key.`, ExitCode.USAGE, @@ -49,10 +54,26 @@ function readRawConfigObject(): Record { } } +/** 读取顶层激活元数据;按需校验命名 Profile 必须实际存在。 */ +function readStoredActiveConfigName( + raw: Record, + requireExisting: boolean, +): string | undefined { + const activeConfigName = normalizeConfigName(raw[ACTIVE_CONFIG_KEY]); + if (activeConfigName && requireExisting && !isConfigBlock(raw[activeConfigName])) { + throw new BailianError( + `Active config "${activeConfigName}" does not exist.`, + ExitCode.USAGE, + "Use --config default to select the default config, then activate an existing profile.", + ); + } + return activeConfigName; +} + function readRawConfigBlock(raw: Record, configName?: string): unknown { if (!configName) return raw; const block = raw[configName]; - return block && typeof block === "object" && !Array.isArray(block) ? block : {}; + return isConfigBlock(block) ? block : {}; } export function readConfigFile(configName?: string): ConfigFile { @@ -90,6 +111,8 @@ export interface ConfigProfiles { default: ConfigFile; /** 命名配置 name -> 配置。 */ named: Record; + /** 当前持久化激活项;default 表示顶层配置。 */ + active: string; } /** @@ -100,19 +123,55 @@ 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)) { + if ((CONFIG_FILE_KEYS as readonly string[]).includes(key) || key === ACTIVE_CONFIG_KEY) + continue; + if (isConfigBlock(value)) { named[key] = parseConfigFile(value); } } - return { default: parseConfigFile(raw), named }; + return { + default: parseConfigFile(raw), + named, + active: readStoredActiveConfigName(raw, true) ?? "default", + }; +} + +function resolveConfigProfileActivation(raw: Record, name?: unknown): string { + const configName = normalizeConfigName(name); + if (configName && !isConfigBlock(raw[configName])) { + throw new BailianError( + `Config "${configName}" does not exist.`, + ExitCode.USAGE, + "Create or log in to the profile before activating it.", + ); + } + return configName ?? "default"; +} + +/** 校验激活目标并返回规范化展示名;不写配置。 */ +export function validateConfigProfileActivation(name?: unknown): string { + return resolveConfigProfileActivation(readRawConfigObject(), name); +} + +/** 将已存在的命名 Profile(或 default)设为持久化激活项。 */ +export async function activateConfigProfile(name?: unknown): Promise { + const raw = readRawConfigObject(); + const active = resolveConfigProfileActivation(raw, name); + raw[ACTIVE_CONFIG_KEY] = active; + await writeRawConfigObject(raw); + return active; } /** 删除一个命名 profile block;存在才删并回写,返回是否有变更。 */ -export async function deleteConfigProfile(name: string): Promise { +export async function deleteConfigProfile(name?: unknown): Promise { + const configName = normalizeConfigName(name); + if (!configName) { + throw new BailianError("Cannot delete the default profile.", ExitCode.USAGE); + } const raw = readRawConfigObject(); - if (!(name in raw)) return false; - delete raw[name]; + if (!isConfigBlock(raw[configName])) return false; + delete raw[configName]; + if (readStoredActiveConfigName(raw, false) === configName) raw[ACTIVE_CONFIG_KEY] = "default"; await writeRawConfigObject(raw); return true; } @@ -132,10 +191,13 @@ export interface ResolutionSources { } export function buildSources(flags: Partial): ResolutionSources { - const configName = normalizeConfigName(flags.config); + const raw = readRawConfigObject(); + const configExplicit = flags.config !== undefined; + const activeConfigName = readStoredActiveConfigName(raw, !configExplicit); + const configName = configExplicit ? normalizeConfigName(flags.config) : activeConfigName; return { flags, - file: readConfigFile(configName), + file: parseConfigFile(readRawConfigBlock(raw, configName)), env: process.env, configName, configPath: getConfigPath(), diff --git a/packages/core/src/config/store.ts b/packages/core/src/config/store.ts index 72a26b2..0e00fea 100644 --- a/packages/core/src/config/store.ts +++ b/packages/core/src/config/store.ts @@ -1,5 +1,12 @@ import type { ConfigFile } from "./schema.ts"; -import { readConfigFile, writeConfigFile } from "./loader.ts"; +import { + activateConfigProfile, + readConfigFile, + readConfigProfiles, + validateConfigProfileActivation, + writeConfigFile, + type ConfigProfiles, +} from "./loader.ts"; import { getConfigPath } from "./paths.ts"; /** @@ -12,8 +19,13 @@ export interface ConfigStore { write(patch: Partial): Promise; /** 删除指定键。 */ unset(keys: (keyof ConfigFile)[]): Promise; + /** 读取所有 Profile 与持久化激活项。 */ + profiles(): ConfigProfiles; + /** 激活已存在的命名 Profile;undefined/default 激活顶层配置。 */ + activate(name?: unknown): Promise; + /** 校验激活目标并返回规范化展示名,不落盘。 */ + validateActivation(name?: unknown): string; path: string; - configName?: string; } export function makeConfigStore(configName?: string): ConfigStore { @@ -32,11 +44,11 @@ export function makeConfigStore(configName?: string): ConfigStore { for (const key of keys) delete existing[key]; await writeConfigFile(existing, configName); }, + profiles: () => readConfigProfiles(), + activate: (name) => activateConfigProfile(name), + validateActivation: (name) => validateConfigProfileActivation(name), get path() { return getConfigPath(); }, - get configName() { - return configName; - }, }; } diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index 2130176..4ed9e0d 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -75,7 +75,7 @@ export const GLOBAL_FLAGS = { config: { type: "string", valueHint: "", - description: "Use named config credentials", + description: "Use a config profile for this command", }, help: { type: "switch", description: "Show help" }, version: { type: "switch", description: "Print version" }, diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index c21c657..e4fde5d 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -1,15 +1,18 @@ -import { mkdtempSync, rmSync } from "fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { expect, test } from "vite-plus/test"; import { makeConfigStore } from "../src/config/store.ts"; import { makeAuthStore } from "../src/auth/store.ts"; +import { refreshAccessToken } from "../src/auth/refresh-token.ts"; import { + buildSettings, buildSources, normalizeConfigName, readConfigFile, writeConfigFile, readConfigProfiles, + activateConfigProfile, deleteConfigProfile, } from "../src/config/loader.ts"; import { getConfigPath } from "../src/config/paths.ts"; @@ -51,6 +54,9 @@ test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async () await store.login({ api_key: "sk-1", access_token: "tok-1", + access_key_id: "ak-1", + access_key_secret: "secret-1", + security_token: "sts-1", workspace_id: "ws-1", console_site: "international", }); @@ -65,6 +71,12 @@ test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async () expect(makeConfigStore().read().access_token).toBeUndefined(); expect(makeConfigStore().read().api_key).toBe("sk-1"); + expect(await store.logout("openapi")).toBe(true); + expect(makeConfigStore().read()).toMatchObject({ api_key: "sk-1" }); + expect(makeConfigStore().read().access_key_id).toBeUndefined(); + expect(makeConfigStore().read().access_key_secret).toBeUndefined(); + expect(makeConfigStore().read().security_token).toBeUndefined(); + expect(await store.logout("all")).toBe(true); expect(makeConfigStore().read().api_key).toBeUndefined(); expect(await store.logout("all")).toBe(false); @@ -74,6 +86,98 @@ test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async () }); }); +test("AuthStore:未传 --config 时写当前激活项,显式不存在名称在登录成功后创建", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile({ access_token: "tok-dev" }, "dev"); + await activateConfigProfile("dev"); + + const activeStore = makeAuthStore(buildSources({})); + await activeStore.login({ access_token: "tok-dev-updated", workspace_id: "ws-dev" }); + expect(readConfigFile("dev")).toMatchObject({ + access_token: "tok-dev-updated", + workspace_id: "ws-dev", + }); + expect(readConfigFile().api_key).toBe("sk-default"); + expect(readConfigFile().access_token).toBeUndefined(); + + const newStore = makeAuthStore(buildSources({ config: "new-profile" })); + await newStore.login({ access_token: "tok-new" }); + expect(readConfigFile("new-profile").access_token).toBe("tok-new"); + + expect(await activeStore.logout("console")).toBe(true); + expect(readConfigFile("dev").access_token).toBeUndefined(); + expect(readConfigFile("new-profile").access_token).toBe("tok-new"); + }); +}); + +test("Console access token 自动刷新只读取当前选中 Config 的 AK/SK", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ + access_key_id: "ak-default", + access_key_secret: "secret-default", + }); + await writeConfigFile({ access_token: "expired-dev" }, "dev"); + await activateConfigProfile("dev"); + + const sources = buildSources({}); + const refreshed = await refreshAccessToken({ + identity: { + binName: "bl", + version: "0.0.0-test", + npmPackage: "bailian-cli", + clientName: "bailian-cli-test", + }, + settings: buildSettings(sources), + baseUrl: "https://dashscope.aliyuncs.com", + }); + + expect(refreshed).toBeNull(); + expect(readConfigFile("dev").access_token).toBe("expired-dev"); + }); +}); + +test("Console access token 自动刷新写回当前选中 Config", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ access_token: "tok-default" }); + await writeConfigFile( + { + access_token: "expired-dev", + access_key_id: "ak-dev", + access_key_secret: "secret-dev", + }, + "dev", + ); + await activateConfigProfile("dev"); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response(JSON.stringify({ cliAccessToken: "refreshed-dev" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + try { + const sources = buildSources({}); + const refreshed = await refreshAccessToken({ + identity: { + binName: "bl", + version: "0.0.0-test", + npmPackage: "bailian-cli", + clientName: "bailian-cli-test", + }, + settings: buildSettings(sources), + baseUrl: "https://dashscope.aliyuncs.com", + }); + + expect(refreshed).toBe("refreshed-dev"); + expect(readConfigFile("dev").access_token).toBe("refreshed-dev"); + expect(readConfigFile().access_token).toBe("tok-default"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + test("ConfigStore:命名 config 与默认配置隔离且写入保留其它 block", async () => { await inTempConfigDir(async () => { await writeConfigFile({ api_key: "sk-default", output: "json" }); @@ -118,6 +222,7 @@ test("config name 校验拒绝路径穿越和 ConfigFile 字段冲突", () => { expect(normalizeConfigName("default")).toBeUndefined(); expect(() => normalizeConfigName("../evil")).toThrow(/Invalid config name/); expect(() => normalizeConfigName("api_key")).toThrow(/conflicts with a config key/); + expect(() => normalizeConfigName("active_config")).toThrow(/conflicts with a config key/); }); test("readConfigProfiles 分离 default 与 named,deleteConfigProfile 只删指定 block", async () => { @@ -127,6 +232,7 @@ test("readConfigProfiles 分离 default 与 named,deleteConfigProfile 只删指 await writeConfigFile({ access_token: "tok-dev" }, "dev"); const profiles = readConfigProfiles(); + expect(profiles.active).toBe("default"); expect(profiles.default).toMatchObject({ api_key: "sk-default", output: "json" }); expect(Object.keys(profiles.named).sort()).toEqual(["dev", "prod"]); expect(profiles.named.prod).toMatchObject({ api_key: "sk-prod" }); @@ -139,6 +245,75 @@ test("readConfigProfiles 分离 default 与 named,deleteConfigProfile 只删指 expect(after.default).toMatchObject({ api_key: "sk-default" }); // 再次删除不存在的 block 返回 false expect(await deleteConfigProfile("prod")).toBe(false); + await expect(deleteConfigProfile("default")).rejects.toThrow(/Cannot delete the default/); + await expect(deleteConfigProfile("api_key")).rejects.toThrow(/conflicts with a config key/); + expect(readConfigFile().api_key).toBe("sk-default"); + }); +}); + +test("active_config:未配置时使用 default,激活命名 Profile 后无 flag 自动选择", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile({ api_key: "sk-token", default_text_model: "qwen3.7-max" }, "token-plan"); + + expect(buildSources({}).configName).toBeUndefined(); + expect(await activateConfigProfile("token-plan")).toBe("token-plan"); + + const activeSources = buildSources({}); + expect(activeSources.configName).toBe("token-plan"); + expect(activeSources.file.api_key).toBe("sk-token"); + expect(readConfigProfiles().active).toBe("token-plan"); + }); +}); + +test("显式 --config 优先于 active_config,--config default 可绕过激活项", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default" }); + await writeConfigFile({ api_key: "sk-active" }, "active"); + await writeConfigFile({ api_key: "sk-other" }, "other"); + await activateConfigProfile("active"); + + const explicitDefault = buildSources({ config: "default" }); + expect(explicitDefault.configName).toBeUndefined(); + expect(explicitDefault.file.api_key).toBe("sk-default"); + + const explicitActive = buildSources({ config: "active" }); + expect(explicitActive.configName).toBe("active"); + + const explicitOther = buildSources({ config: "other" }); + expect(explicitOther.configName).toBe("other"); + expect(explicitOther.file.api_key).toBe("sk-other"); + expect(readConfigProfiles().active).toBe("active"); + }); +}); + +test("激活不存在 Profile 不写盘;悬空 active_config 不静默回退", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default" }); + await expect(activateConfigProfile("missing")).rejects.toThrow(/does not exist/); + expect(readConfigProfiles().active).toBe("default"); + + const configPath = getConfigPath(); + writeFileSync( + configPath, + JSON.stringify({ api_key: "sk-default", active_config: "missing" }, null, 2) + "\n", + ); + expect(() => buildSources({})).toThrow(/Active config "missing" does not exist/); + + const explicitDefault = buildSources({ config: "default" }); + expect(explicitDefault.file.api_key).toBe("sk-default"); + expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("missing"); + }); +}); + +test("删除当前激活 Profile 时原子切回 default", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-dev" }, "dev"); + await activateConfigProfile("dev"); + + expect(await deleteConfigProfile("dev")).toBe(true); + expect(readConfigProfiles()).toMatchObject({ active: "default", named: {} }); + expect(buildSources({}).configName).toBeUndefined(); }); }); diff --git a/packages/e2e/src/gating.ts b/packages/e2e/src/gating.ts index 36ade27..28916d9 100644 --- a/packages/e2e/src/gating.ts +++ b/packages/e2e/src/gating.ts @@ -1,4 +1,4 @@ -import { readConfigFile } from "bailian-cli-core"; +import { buildSources } from "bailian-cli-core"; /** 显式开启后才跑真实网络 E2E */ export function isBailianE2EEnabled(): boolean { @@ -10,8 +10,8 @@ export function isDashScopeE2EReady(): boolean { if (!isBailianE2EEnabled()) return false; if (process.env.DASHSCOPE_API_KEY?.trim()) return true; try { - const f = readConfigFile(); - return typeof f.api_key === "string" && f.api_key.length > 0; + const config = buildSources({}).file; + return typeof config.api_key === "string" && config.api_key.length > 0; } catch { return false; } @@ -21,7 +21,7 @@ export function isDashScopeE2EReady(): boolean { export function isConsoleE2EReady(): boolean { if (!isBailianE2EEnabled()) return false; try { - const config = readConfigFile(); + const config = buildSources({}).file; return typeof config.access_token === "string" && config.access_token.length > 0; } catch { return false; diff --git a/packages/runtime/src/pipeline/bl-config.ts b/packages/runtime/src/pipeline/bl-config.ts index 7b9ca59..39bc9c0 100644 --- a/packages/runtime/src/pipeline/bl-config.ts +++ b/packages/runtime/src/pipeline/bl-config.ts @@ -1,7 +1,7 @@ import { Client, + buildSources, buildSettings, - readConfigFile, resolveApiKey, resolveModelBaseUrl, type ApiKeyCredential, @@ -22,7 +22,7 @@ export interface PipelineEnv { * output + quiet mode. */ export function buildPipelineEnv(): PipelineEnv { - const sources: ResolutionSources = { flags: {}, file: readConfigFile(), env: process.env }; + const sources: ResolutionSources = buildSources({}); const settings: Settings = { ...buildSettings(sources), output: "json", diff --git a/skills/bailian-cli/assets/setup.md b/skills/bailian-cli/assets/setup.md index 311c4d7..b75f99e 100644 --- a/skills/bailian-cli/assets/setup.md +++ b/skills/bailian-cli/assets/setup.md @@ -47,6 +47,18 @@ bl text chat --config token-plan --message "Hello" bl image generate --config token-plan --prompt "A cat" ``` +To make Token Plan the default Profile for commands that omit `--config`, activate it explicitly after login: + +```bash +bl config use --name token-plan +bl text chat --message "Hello" +bl image generate --prompt "A cat" +``` + +`auth login --config token-plan` saves that Profile but does not activate it. Use `bl config list` to inspect the active Profile, `bl config use --name default` to switch back, or `--config default` for a one-command override. Config selection follows explicit `--config` > persisted `active_config` > `default`; credential and endpoint fields inside the selected Profile still follow flag > environment > config. + +Activation selects the entire Config for every credential domain, not only model consumption. After activating `token-plan`, Token Plan management and Console commands also read their OpenAPI or Console credentials from that Profile. If those credentials remain in `default`, invoke the command with `--config default` or log the corresponding credential domain into `token-plan`. + The built-in `token-plan` profile defaults to: - Base URL: `https://token-plan.cn-beijing.maas.aliyuncs.com` @@ -113,6 +125,9 @@ Default: `https://dashscope.aliyuncs.com` (China). Override with any of: ```bash bl config show +bl config list +bl config use --name +bl config use --name default bl config set --key default-text-model --value qwen3.7-max bl config set --key output_dir --value ~/bailian-output ```