Skip to content

Commit 155c9dc

Browse files
committed
feat(config): 支持命名配置功能并隔离默认配置数据
- 新增 `--config <name>` 参数支持读取与写入命名的配置块 - 命名配置与默认配置完全隔离,互不影响 - 规范命名配置名称格式,禁止路径穿越及顶层字段冲突 - 配置文件读取写入逻辑改为维护原始完整对象,支持多配置块共存 - AuthStore 和 ConfigStore 均支持命名配置,登录登出只影响指定配置块 - CLI 命令增加对 `--config` 标志的支持,包括 config set/show/auth status 等 - 鉴权状态输出带上配置名和配置文件路径信息 - 提示和报错信息包含配置相关上下文,增强用户体验 - 完善相关单元测试覆盖命名配置行为
1 parent e1532bf commit 155c9dc

16 files changed

Lines changed: 375 additions & 36 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
2+
import { tmpdir } from "os";
3+
import { join } from "path";
4+
import { describe, expect, test } from "vite-plus/test";
5+
import { parseStdoutJson, runCli } from "./helpers.ts";
6+
7+
function withTempConfigDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
8+
const dir = mkdtempSync(join(tmpdir(), "bl-config-profile-"));
9+
return fn(dir).finally(() => {
10+
rmSync(dir, { recursive: true, force: true });
11+
});
12+
}
13+
14+
function writeConfig(dir: string, data: Record<string, unknown>): void {
15+
mkdirSync(dir, { recursive: true });
16+
writeFileSync(join(dir, "config.json"), JSON.stringify(data, null, 2) + "\n");
17+
}
18+
19+
describe("e2e: named config", () => {
20+
test("根帮助展示 --config 全局标志", async () => {
21+
const { stderr, exitCode } = await runCli(["--help"]);
22+
expect(exitCode, stderr).toBe(0);
23+
expect(stderr).toMatch(/--config <name>/);
24+
});
25+
26+
test("config set --config 写入命名 block 且不影响默认配置", async () => {
27+
await withTempConfigDir(async (dir) => {
28+
writeConfig(dir, { output: "text", api_key: "sk-default" });
29+
30+
const setResult = await runCli(
31+
[
32+
"config",
33+
"set",
34+
"--config",
35+
"dev",
36+
"--key",
37+
"output",
38+
"--value",
39+
"json",
40+
"--output",
41+
"json",
42+
],
43+
{ BAILIAN_CONFIG_DIR: dir },
44+
);
45+
expect(setResult.exitCode, setResult.stderr).toBe(0);
46+
const setData = parseStdoutJson<{
47+
output?: string;
48+
config?: string;
49+
config_file?: string;
50+
}>(setResult.stdout);
51+
expect(setData.output).toBe("json");
52+
expect(setData.config).toBe("dev");
53+
expect(setData.config_file).toBe(join(dir, "config.json"));
54+
55+
const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record<
56+
string,
57+
unknown
58+
>;
59+
expect(raw.output).toBe("text");
60+
expect((raw.dev as Record<string, unknown>).output).toBe("json");
61+
});
62+
});
63+
64+
test("config show --config 只展示命名 block", async () => {
65+
await withTempConfigDir(async (dir) => {
66+
writeConfig(dir, {
67+
output: "text",
68+
api_key: "sk-default",
69+
dev: { output: "json", access_token: "tok-dev" },
70+
});
71+
72+
const { stdout, stderr, exitCode } = await runCli(
73+
["config", "show", "--config", "dev", "--output", "json"],
74+
{ BAILIAN_CONFIG_DIR: dir },
75+
);
76+
expect(exitCode, stderr).toBe(0);
77+
const data = parseStdoutJson<Record<string, unknown>>(stdout);
78+
expect(data.config).toBe("dev");
79+
expect(data.config_file).toBe(join(dir, "config.json"));
80+
expect(data.output).toBe("json");
81+
expect(data.access_token).toBeDefined();
82+
expect(data.api_key).toBeUndefined();
83+
});
84+
});
85+
86+
test("auth status --config 不继承默认凭证", async () => {
87+
await withTempConfigDir(async (dir) => {
88+
writeConfig(dir, { api_key: "sk-default", dev: { output: "json" } });
89+
90+
const devStatus = await runCli(["auth", "status", "--config", "dev", "--output", "json"], {
91+
BAILIAN_CONFIG_DIR: dir,
92+
});
93+
expect(devStatus.exitCode, devStatus.stderr).toBe(0);
94+
const devData = parseStdoutJson<Record<string, unknown>>(devStatus.stdout);
95+
expect(devData.authenticated).toBe(false);
96+
expect(devData.config).toBe("dev");
97+
98+
const defaultStatus = await runCli(["auth", "status", "--output", "json"], {
99+
BAILIAN_CONFIG_DIR: dir,
100+
});
101+
expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0);
102+
const defaultData = parseStdoutJson<Record<string, unknown>>(defaultStatus.stdout);
103+
expect(defaultData.authenticated).toBe(true);
104+
expect(defaultData.config).toBe("default");
105+
});
106+
});
107+
108+
test("--config default 等价默认配置", async () => {
109+
await withTempConfigDir(async (dir) => {
110+
writeConfig(dir, { output: "json", api_key: "sk-default" });
111+
const { stdout, stderr, exitCode } = await runCli(
112+
["config", "show", "--config", "default", "--output", "json"],
113+
{ BAILIAN_CONFIG_DIR: dir },
114+
);
115+
expect(exitCode, stderr).toBe(0);
116+
const data = parseStdoutJson<Record<string, unknown>>(stdout);
117+
expect(data.config).toBe("default");
118+
expect(data.api_key).toBeDefined();
119+
});
120+
});
121+
122+
test("非法 --config 名称报 usage error", async () => {
123+
const { stderr, exitCode } = await runCli(["auth", "status", "--config", "../evil"]);
124+
expect(exitCode).toBe(2);
125+
expect(stderr).toMatch(/Invalid config name/);
126+
});
127+
});

packages/commands/src/commands/auth/login-console.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
BailianError,
77
ExitCode,
88
chatPath,
9-
getConfigPath,
109
requestJson,
1110
type AuthStore,
1211
type ConfigFile,
@@ -23,6 +22,9 @@ export interface LoginDeps {
2322

2423
const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
2524
const MAX_AUTH_CALLBACK_BODY = 65536;
25+
// Regex for double newline (\r\n\r\n or \n\n); built via RegExp to avoid
26+
// literal multi-line splitting in source.
27+
const REGEX_DOUBLE_NEWLINE = new RegExp("\r\n\r\n|\n\n");
2628

2729
const CONSOLE_ORIGINS: Record<string, string> = {
2830
domestic: "https://bailian.console.aliyun.com",
@@ -76,7 +78,7 @@ function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): stri
7678
for (let i = 1; i < segments.length; i++) {
7779
const part = segments[i]!;
7880
if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue;
79-
const sep = part.match(/\r\n\r\n|\n\n/);
81+
const sep = part.match(REGEX_DOUBLE_NEWLINE);
8082
if (!sep || sep.index === undefined) continue;
8183
let value = part.slice(sep.index + sep[0].length);
8284
value = value
@@ -495,7 +497,7 @@ export async function runConsoleLogin(
495497
console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined,
496498
workspace_id: workspaceId || undefined,
497499
});
498-
process.stderr.write(`Config saved to ${getConfigPath()}\n`);
500+
process.stderr.write(`Config saved to ${deps.authStore.path}\n`);
499501
}
500502
if (apiKey) {
501503
const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl();

packages/commands/src/commands/auth/login.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineCommand, getConfigPath } from "bailian-cli-core";
1+
import { defineCommand } from "bailian-cli-core";
22
import { emitBare } from "bailian-cli-runtime";
33
import {
44
resolveConsoleOrigin,
@@ -129,7 +129,7 @@ export default defineCommand({
129129
access_key_secret: flags.accessKeySecret,
130130
access_token: accessToken,
131131
});
132-
process.stderr.write(`OpenAPI credentials saved to ${getConfigPath()}\n`);
132+
process.stderr.write(`OpenAPI credentials saved to ${store.path}\n`);
133133
return;
134134
}
135135

packages/commands/src/commands/auth/logout.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineCommand, getConfigPath } from "bailian-cli-core";
1+
import { defineCommand } from "bailian-cli-core";
22
import { emitBare } from "bailian-cli-runtime";
33

44
export default defineCommand({
@@ -25,13 +25,13 @@ export default defineCommand({
2525

2626
if (flags.console) {
2727
if (settings.dryRun) {
28-
if (stored.console) emitBare("Would clear access_token from ~/.bailian/config.json");
28+
if (stored.console) emitBare(`Would clear access_token from ${store.path}`);
2929
else emitBare("No console access_token to clear.");
3030
emitBare("No changes made.");
3131
return;
3232
}
3333
if (await store.logout("console")) {
34-
process.stderr.write(`Cleared access_token from ${getConfigPath()}\n`);
34+
process.stderr.write(`Cleared access_token from ${store.path}\n`);
3535
if (stored.apiKey) {
3636
process.stderr.write(
3737
"api_key is still configured and will be used for authentication.\n",
@@ -46,13 +46,13 @@ export default defineCommand({
4646
if (flags.openApi) {
4747
if (settings.dryRun) {
4848
if (stored.openapi)
49-
emitBare("Would clear access_key_id / access_key_secret from ~/.bailian/config.json");
49+
emitBare(`Would clear access_key_id / access_key_secret from ${store.path}`);
5050
else emitBare("No OpenAPI AK/SK credentials to clear.");
5151
emitBare("No changes made.");
5252
return;
5353
}
5454
if (await store.logout("openapi")) {
55-
process.stderr.write(`Cleared access_key_id / access_key_secret from ${getConfigPath()}\n`);
55+
process.stderr.write(`Cleared access_key_id / access_key_secret from ${store.path}\n`);
5656
if (stored.apiKey || stored.console) {
5757
process.stderr.write(
5858
"Other credentials are still configured and will be used for authentication.\n",
@@ -69,7 +69,7 @@ export default defineCommand({
6969
if (settings.dryRun) {
7070
if (hasKey)
7171
emitBare(
72-
"Would clear api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json",
72+
`Would clear api_key / access_token / access_key_id / access_key_secret from ${store.path}`,
7373
);
7474
else emitBare("No credentials to clear.");
7575
emitBare("No changes made.");
@@ -78,7 +78,7 @@ export default defineCommand({
7878

7979
if (await store.logout("all")) {
8080
process.stderr.write(
81-
"Cleared api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json\n",
81+
`Cleared api_key / access_token / access_key_id / access_key_secret from ${store.path}\n`,
8282
);
8383
} else {
8484
process.stderr.write("No credentials to clear.\n");

packages/commands/src/commands/auth/status.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,15 @@ export default defineCommand({
3535
: undefined;
3636

3737
const authenticated = !!(apiKey || consoleCred || openapi);
38+
const configName = settings.configName ?? "default";
39+
const configFile = ctx.authStore().path;
3840

3941
if (!authenticated) {
4042
emitResult(
4143
{
4244
authenticated: false,
45+
config: configName,
46+
config_file: configFile,
4347
message: "Not authenticated.",
4448
hint: [
4549
`API key (model): ${identity.binName} auth login --api-key <key> or DASHSCOPE_API_KEY`,
@@ -54,10 +58,21 @@ export default defineCommand({
5458
}
5559

5660
if (format !== "text") {
57-
emitResult({ authenticated: true, api_key: apiKey, console: consoleCred, openapi }, format);
61+
emitResult(
62+
{
63+
authenticated: true,
64+
config: configName,
65+
config_file: configFile,
66+
api_key: apiKey,
67+
console: consoleCred,
68+
openapi,
69+
},
70+
format,
71+
);
5872
return;
5973
}
6074

75+
emitBare(`Config: ${configName} (${configFile})`);
6176
emitBare("Authentication Status:");
6277
if (apiKey) {
6378
emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`);

packages/commands/src/commands/config/set.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,14 @@ export default defineCommand({
101101
const format = detectOutputFormat(settings.output);
102102

103103
if (settings.dryRun) {
104-
emitResult({ would_set: { [resolvedKey]: value } }, format);
104+
emitResult(
105+
{
106+
would_set: { [resolvedKey]: value },
107+
config: settings.configName ?? "default",
108+
config_file: ctx.configStore().path,
109+
},
110+
format,
111+
);
105112
return;
106113
}
107114

@@ -110,7 +117,14 @@ export default defineCommand({
110117

111118
if (!settings.quiet) {
112119
const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced;
113-
emitResult({ [resolvedKey]: shown }, format);
120+
emitResult(
121+
{
122+
[resolvedKey]: shown,
123+
config: settings.configName ?? "default",
124+
config_file: ctx.configStore().path,
125+
},
126+
format,
127+
);
114128
}
115129
},
116130
});

packages/commands/src/commands/config/show.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default defineCommand({
1616
base_url: client.baseUrl,
1717
output: settings.output,
1818
timeout: settings.timeout,
19+
config: settings.configName ?? "default",
1920
config_file: store.path,
2021
};
2122

packages/core/src/auth/store.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ConfigFile } from "../config/schema.ts";
22
import type { ResolutionSources } from "../config/loader.ts";
33
import { readConfigFile, writeConfigFile } from "../config/loader.ts";
4+
import { getConfigPath } from "../config/paths.ts";
45
import type { AuthState } from "./types.ts";
56
import { describeAuthState, resolveModelBaseUrl } from "./resolver.ts";
67

@@ -40,13 +41,18 @@ export interface AuthStore {
4041
login(patch: AuthPersistPatch): Promise<void>;
4142
/** 清凭证:console/openapi 只删对应域;all 清全部登录凭证。返回是否有变更。 */
4243
logout(scope: "console" | "openapi" | "all"): Promise<boolean>;
44+
/** 实际写入的 config.json 路径(不受命名配置影响,一直是同一个文件)。 */
45+
path: string;
46+
/** 当前命名配置名(`--config <name>` 解析后);未指定或 `default` 时为 undefined。 */
47+
configName?: string;
4348
}
4449

4550
export function makeAuthStore(sources: ResolutionSources): AuthStore {
51+
const configName = sources.configName;
4652
return {
4753
describe: () => describeAuthState(sources),
4854
stored() {
49-
const file = readConfigFile();
55+
const file = readConfigFile(configName);
5056
return {
5157
apiKey: !!file.api_key,
5258
console: !!file.access_token,
@@ -55,20 +61,26 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore {
5561
},
5662
resolveBaseUrl: () => resolveModelBaseUrl(sources),
5763
async login(patch) {
58-
const existing = readConfigFile() as Record<string, unknown>;
64+
const existing = readConfigFile(configName) as Record<string, unknown>;
5965
for (const [key, value] of Object.entries(patch)) {
6066
if (value !== undefined) existing[key] = value;
6167
}
62-
await writeConfigFile(existing);
68+
await writeConfigFile(existing, configName);
6369
},
6470
async logout(scope) {
65-
const existing = readConfigFile() as Record<string, unknown>;
71+
const existing = readConfigFile(configName) as Record<string, unknown>;
6672
const keys = LOGOUT_KEYS[scope];
6773
const had = keys.some((key) => existing[key] !== undefined);
6874
if (!had) return false;
6975
for (const key of keys) delete existing[key];
70-
await writeConfigFile(existing);
76+
await writeConfigFile(existing, configName);
7177
return true;
7278
},
79+
get path() {
80+
return sources.configPath ?? getConfigPath();
81+
},
82+
get configName() {
83+
return configName;
84+
},
7385
};
7486
}

packages/core/src/config/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export type { ConfigFile, Region, Identity, Settings } from "./schema.ts";
2-
export { BAILIAN_HOST, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts";
3-
export { readConfigFile, writeConfigFile } from "./loader.ts";
2+
export { BAILIAN_HOST, CONFIG_FILE_KEYS, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts";
3+
export { normalizeConfigName, readConfigFile, writeConfigFile } from "./loader.ts";
44
export { buildSources, buildSettings, type ResolutionSources } from "./loader.ts";
55
export { makeConfigStore, type ConfigStore } from "./store.ts";
66
export { ensureConfigDir, getConfigDir, getConfigPath, getCredentialsPath } from "./paths.ts";

0 commit comments

Comments
 (0)