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
This commit is contained in:
若麒
2026-07-16 11:05:59 +08:00
parent 75a45e1a2a
commit de9f1a3889
26 changed files with 825 additions and 102 deletions
+16 -15
View File
@@ -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 发布到 npmCI 驱动) | [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 发布到 npmCI 驱动) | [docs/agents/publish.md](docs/agents/publish.md) |
| Change Log | 发版说明 / 历史版本说明 | [docs/agents/changelog-write.md](docs/agents/changelog-write.md) |
| 工具链调整 | lint 规则 / 构建配置 / 依赖升级 | [docs/agents/lint-toolchain.md](docs/agents/lint-toolchain.md) |
| Command Pack | 扩展包 / 白名单 / plugin 管理命令 | [docs/agents/command-pack.md](docs/agents/command-pack.md) |
如果当前任务无法对应任何场景,先按经验完成,然后**回来评估这是不是一类新场景** —— 是就新增 `docs/agents/<scenario>.md`,把清单沉淀下来。
+1 -1
View File
@@ -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_*`
解析分工:
+69
View File
@@ -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 <name> > 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 <name>` 时,仅在业务操作成功并实际落盘时创建 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` 时,需要在允许本地进程和端口的环境中复跑。
+11 -5
View File
@@ -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 <name>`:展示指定 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 4Config 激活状态与切换命令(实现)
### Commit 4Config 激活状态与切换命令(实现)
建议提交信息:
@@ -468,12 +468,18 @@ feat(config): add active profile selection
- 保证 `--config default` 能显式覆盖激活项。
- 新增 `bl config list`
- 新增 `bl config use --name <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 <name>` 和所有显式 `--config` 仍只作用于本次命令,不修改激活状态。
相关写入交互统一为:`auth login``auth logout``config set` 未传 `--config` 时作用于当前激活项;显式指定名称时作用于该名称。写命令可在成功落盘时创建不存在的 Profile读命令不创建。Console access token 自动刷新同样限定在当前选中的 Profile不得回退读写顶层 default。
激活项选择的是完整 Config而不是只选择模型消费凭证。激活 `token-plan`Token Plan 管控命令也会从该 Profile 解析 OpenAPI AK/SKConsole 命令也会从该 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`
## 最终结论
+4
View File
@@ -16,6 +16,8 @@ import {
visionDescribe,
configShow,
configSet,
configList,
configUse,
configUi,
update,
appCall,
@@ -110,6 +112,8 @@ export const commands: Record<string, AnyCommand> = {
"vision describe": visionDescribe,
"config show": configShow,
"config set": configSet,
"config list": configList,
"config use": configUse,
"config ui": configUi,
update,
"app call": appCall,
@@ -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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(stdout);
expect(data.config).toBe("default");
expect(data.active).toBeUndefined();
expect(data.api_key).toBeDefined();
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
string,
unknown
>;
expect(raw.active_config).toBe("token-plan");
});
});
@@ -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:");
});
});
});
@@ -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}`);
@@ -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 ? "*" : ""}`);
}
},
});
@@ -16,7 +16,7 @@ export const PAGE_HTML = `<!doctype 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 = `<!doctype html>
<form id="form" onsubmit="return false"></form>
<div class="actions">
<button id="saveBtn">Save</button>
<button id="useBtn">Save &amp; Activate</button>
<span id="status" class="muted"></span>
</div>
</main>
</div>
<script>
var token = new URLSearchParams(location.search).get('token') || '';
var KEYS = [], SECRETS = [], DATA = { default: {}, named: {} }, CURRENT = '';
var KEYS = [], SECRETS = [], DATA = { default: {}, named: {} }, CURRENT = '', ACTIVE = 'default';
function api(path, opts) {
var sep = path.indexOf('?') >= 0 ? '&' : '?';
@@ -79,8 +80,9 @@ export const PAGE_HTML = `<!doctype html>
KEYS = j.keys || [];
SECRETS = j.secretKeys || [];
DATA = { default: j.default || {}, named: j.named || {} };
ACTIVE = j.activeProfile || 'default';
document.getElementById('cfgFile').textContent = j.configFile || '';
CURRENT = (j.activeProfile && DATA.named[j.activeProfile]) ? j.activeProfile : '';
CURRENT = ACTIVE === 'default' ? '' : ACTIVE;
renderProfiles();
renderForm();
}).catch(function (e) { setStatus('Load failed: ' + e, true); });
@@ -92,8 +94,9 @@ export const PAGE_HTML = `<!doctype html>
var names = [''].concat(Object.keys(DATA.named));
names.forEach(function (name) {
var li = document.createElement('li');
li.textContent = name === '' ? 'default' : name;
if (name === CURRENT) li.className = 'active';
var displayName = name === '' ? 'default' : name;
li.textContent = displayName + (displayName === ACTIVE ? ' *' : '');
if (name === CURRENT) li.className = 'selected';
li.onclick = function () { CURRENT = name; renderProfiles(); renderForm(); setStatus(''); };
ul.appendChild(li);
});
@@ -104,6 +107,10 @@ export const PAGE_HTML = `<!doctype html>
form.innerHTML = '';
document.getElementById('currentName').textContent = CURRENT === '' ? 'default (top-level)' : CURRENT;
document.getElementById('deleteBtn').style.display = CURRENT === '' ? 'none' : '';
var selectedName = CURRENT === '' ? 'default' : CURRENT;
var useBtn = document.getElementById('useBtn');
useBtn.disabled = selectedName === ACTIVE;
useBtn.textContent = selectedName === ACTIVE ? 'Active' : 'Save & Activate';
var data = profileData(CURRENT);
KEYS.forEach(function (key) {
var row = document.createElement('div');
@@ -150,29 +157,83 @@ export const PAGE_HTML = `<!doctype html>
return data;
}
function saveProfile(name, data) {
var profileData = data === undefined ? collect() : data;
var body = JSON.stringify({ name: name, data: profileData });
return api('/api/profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body
})
.then(function (response) {
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
})
.then(function (result) {
if (!result.ok) {
throw new Error((result.json && result.json.error) || 'error');
}
var saved = result.json.saved || {};
if (name === '') DATA.default = saved; else DATA.named[name] = saved;
return saved;
});
}
function save() {
var name = CURRENT;
var body = JSON.stringify({ name: name, data: collect() });
api('/api/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body })
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
if (!res.ok) { setStatus('Save failed: ' + ((res.j && res.j.error) || 'error'), true); return; }
var saved = res.j.saved || {};
if (name === '') DATA.default = saved; else DATA.named[name] = saved;
saveProfile(name)
.then(function () {
renderForm();
setStatus('Saved.');
})
.catch(function (e) { setStatus('Save failed: ' + e, true); });
.catch(function (error) { setStatus('Save failed: ' + error.message, true); });
}
function newProfile() {
var name = prompt('New profile name (letters, numbers, - or _):');
if (!name) return;
if (DATA.named[name] === undefined) DATA.named[name] = {};
CURRENT = name;
renderProfiles();
renderForm();
setStatus('Unsaved new profile. Fill fields and Save.');
if (DATA.named[name] !== undefined) {
CURRENT = name;
renderProfiles();
renderForm();
setStatus('Profile already exists.');
return;
}
setStatus('Creating profile...');
saveProfile(name, {})
.then(function () {
CURRENT = name;
renderProfiles();
renderForm();
setStatus('Profile created and saved.');
})
.catch(function (error) { setStatus('Create failed: ' + error.message, true); });
}
function saveAndActivateProfile() {
var name = CURRENT === '' ? 'default' : CURRENT;
var profileName = CURRENT;
setStatus('Saving...');
saveProfile(profileName)
.then(function () {
return api('/api/active', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
})
.then(function (response) {
return response.json().then(function (json) { return { ok: response.ok, json: json }; });
})
.then(function (result) {
if (!result.ok) {
throw new Error('Saved, but activation failed: ' + ((result.json && result.json.error) || 'error'));
}
ACTIVE = result.json.activeProfile || 'default';
renderProfiles();
renderForm();
setStatus('Saved and activated.');
})
.catch(function (error) { setStatus(error.message || String(error), true); });
}
function deleteProfile() {
@@ -183,8 +244,9 @@ export const PAGE_HTML = `<!doctype html>
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'error'); });
return r.json();
})
.then(function () {
.then(function (result) {
delete DATA.named[CURRENT];
ACTIVE = result.activeProfile || 'default';
CURRENT = '';
renderProfiles();
renderForm();
@@ -195,6 +257,7 @@ export const PAGE_HTML = `<!doctype html>
document.getElementById('saveBtn').onclick = save;
document.getElementById('newBtn').onclick = newProfile;
document.getElementById('useBtn').onclick = saveAndActivateProfile;
document.getElementById('deleteBtn').onclick = deleteProfile;
load();
</script>
+30 -19
View File
@@ -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<string, unknown>): Record<string, string
* - 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 {
export function createConfigUiServer(token: string, configStore: ConfigStore): http.Server {
return http.createServer(async (req, res) => {
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 <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 {
@@ -0,0 +1,42 @@
import { defineCommand, detectOutputFormat } from "bailian-cli-core";
import { emitResult } from "bailian-cli-runtime";
export default defineCommand({
description: "Set the active config profile",
auth: "none",
usageArgs: "--name <name>",
flags: {
name: {
type: "string",
valueHint: "<name>",
description: "Existing profile name, or default",
required: true,
},
},
exampleArgs: ["--name token-plan", "--name default"],
async run(ctx) {
const format = detectOutputFormat(ctx.settings.output);
if (ctx.settings.dryRun) {
const activeConfig = ctx.configStore.validateActivation(ctx.flags.name);
emitResult(
{
would_activate: activeConfig,
config_file: ctx.configStore.path,
},
format,
);
return;
}
const activeConfig = await ctx.configStore.activate(ctx.flags.name);
if (!ctx.settings.quiet) {
emitResult(
{
active_config: activeConfig,
config_file: ctx.configStore.path,
},
format,
);
}
},
});
+2
View File
@@ -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";
+58 -12
View File
@@ -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<void>,
): Promise<void> {
async function withServer(fn: (port: number) => Promise<void>): Promise<void> {
const saved = process.env.BAILIAN_CONFIG_DIR;
const dir = mkdtempSync(join(tmpdir(), "bl-ui-"));
process.env.BAILIAN_CONFIG_DIR = dir;
const server = createConfigUiServer(TOKEN, activeProfile);
const server = createConfigUiServer(TOKEN, makeConfigStore());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const addr = server.address();
const port = addr && typeof addr === "object" ? addr.port : 0;
@@ -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 写命名 profiletimeout 强制为 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 写命名 profiletimeout 强制为 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");
});
});
@@ -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");
@@ -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",
@@ -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",
};
@@ -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;
+4 -3
View File
@@ -62,7 +62,8 @@ export async function refreshAccessToken(opts: {
settings: Settings;
baseUrl: string;
}): Promise<string | null> {
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<string, unknown>;
const existing = readConfigFile(configName) as Record<string, unknown>;
existing.access_token = token;
await writeConfigFile(existing);
await writeConfigFile(existing, configName);
return token;
}
+7 -1
View File
@@ -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";
+72 -10
View File
@@ -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<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
/**
* `--config <name>``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<string, unknown> {
}
}
/** 读取顶层激活元数据;按需校验命名 Profile 必须实际存在。 */
function readStoredActiveConfigName(
raw: Record<string, unknown>,
requireExisting: boolean,
): string | undefined {
const activeConfigName = normalizeConfigName(raw[ACTIVE_CONFIG_KEY]);
if (activeConfigName && requireExisting && !isConfigBlock(raw[activeConfigName])) {
throw new BailianError(
`Active config "${activeConfigName}" does not exist.`,
ExitCode.USAGE,
"Use --config default to select the default config, then activate an existing profile.",
);
}
return activeConfigName;
}
function readRawConfigBlock(raw: Record<string, unknown>, configName?: string): unknown {
if (!configName) return raw;
const block = raw[configName];
return 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<string, ConfigFile>;
/** 当前持久化激活项default 表示顶层配置。 */
active: string;
}
/**
@@ -100,19 +123,55 @@ export function readConfigProfiles(): ConfigProfiles {
const raw = readRawConfigObject();
const named: Record<string, ConfigFile> = {};
for (const [key, value] of Object.entries(raw)) {
if ((CONFIG_FILE_KEYS as readonly string[]).includes(key)) 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<string, unknown>, name?: unknown): string {
const configName = normalizeConfigName(name);
if (configName && !isConfigBlock(raw[configName])) {
throw new BailianError(
`Config "${configName}" does not exist.`,
ExitCode.USAGE,
"Create or log in to the profile before activating it.",
);
}
return configName ?? "default";
}
/** 校验激活目标并返回规范化展示名;不写配置。 */
export function validateConfigProfileActivation(name?: unknown): string {
return resolveConfigProfileActivation(readRawConfigObject(), name);
}
/** 将已存在的命名 Profile或 default设为持久化激活项。 */
export async function activateConfigProfile(name?: unknown): Promise<string> {
const raw = readRawConfigObject();
const active = resolveConfigProfileActivation(raw, name);
raw[ACTIVE_CONFIG_KEY] = active;
await writeRawConfigObject(raw);
return active;
}
/** 删除一个命名 profile block存在才删并回写返回是否有变更。 */
export async function deleteConfigProfile(name: string): Promise<boolean> {
export async function deleteConfigProfile(name?: unknown): Promise<boolean> {
const configName = normalizeConfigName(name);
if (!configName) {
throw new BailianError("Cannot delete the default profile.", ExitCode.USAGE);
}
const raw = readRawConfigObject();
if (!(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<SourceFlags>): 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(),
+17 -5
View File
@@ -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<ConfigFile>): Promise<void>;
/** 删除指定键。 */
unset(keys: (keyof ConfigFile)[]): Promise<void>;
/** 读取所有 Profile 与持久化激活项。 */
profiles(): ConfigProfiles;
/** 激活已存在的命名 Profileundefined/default 激活顶层配置。 */
activate(name?: unknown): Promise<string>;
/** 校验激活目标并返回规范化展示名,不落盘。 */
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;
},
};
}
+1 -1
View File
@@ -75,7 +75,7 @@ export const GLOBAL_FLAGS = {
config: {
type: "string",
valueHint: "<name>",
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" },
+176 -1
View File
@@ -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();
});
});
+4 -4
View File
@@ -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;
+2 -2
View File
@@ -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",
+15
View File
@@ -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 <existing-profile>
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
```