diff --git a/packages/cli/tests/e2e/config-profile.e2e.test.ts b/packages/cli/tests/e2e/config-profile.e2e.test.ts new file mode 100644 index 0000000..d9f996c --- /dev/null +++ b/packages/cli/tests/e2e/config-profile.e2e.test.ts @@ -0,0 +1,127 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { describe, expect, test } from "vite-plus/test"; +import { parseStdoutJson, runCli } from "./helpers.ts"; + +function withTempConfigDir(fn: (dir: string) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), "bl-config-profile-")); + return fn(dir).finally(() => { + rmSync(dir, { recursive: true, force: true }); + }); +} + +function writeConfig(dir: string, data: Record): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), JSON.stringify(data, null, 2) + "\n"); +} + +describe("e2e: named config", () => { + test("根帮助展示 --config 全局标志", async () => { + const { stderr, exitCode } = await runCli(["--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--config /); + }); + + test("config set --config 写入命名 block 且不影响默认配置", async () => { + await withTempConfigDir(async (dir) => { + writeConfig(dir, { output: "text", api_key: "sk-default" }); + + const setResult = await runCli( + [ + "config", + "set", + "--config", + "dev", + "--key", + "output", + "--value", + "json", + "--output", + "json", + ], + { BAILIAN_CONFIG_DIR: dir }, + ); + expect(setResult.exitCode, setResult.stderr).toBe(0); + const setData = parseStdoutJson<{ + output?: string; + config?: string; + config_file?: string; + }>(setResult.stdout); + expect(setData.output).toBe("json"); + expect(setData.config).toBe("dev"); + expect(setData.config_file).toBe(join(dir, "config.json")); + + const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf8")) as Record< + string, + unknown + >; + expect(raw.output).toBe("text"); + expect((raw.dev as Record).output).toBe("json"); + }); + }); + + test("config show --config 只展示命名 block", async () => { + await withTempConfigDir(async (dir) => { + writeConfig(dir, { + output: "text", + api_key: "sk-default", + dev: { output: "json", access_token: "tok-dev" }, + }); + + const { stdout, stderr, exitCode } = await runCli( + ["config", "show", "--config", "dev", "--output", "json"], + { BAILIAN_CONFIG_DIR: dir }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson>(stdout); + expect(data.config).toBe("dev"); + expect(data.config_file).toBe(join(dir, "config.json")); + expect(data.output).toBe("json"); + expect(data.access_token).toBeDefined(); + expect(data.api_key).toBeUndefined(); + }); + }); + + test("auth status --config 不继承默认凭证", async () => { + await withTempConfigDir(async (dir) => { + writeConfig(dir, { api_key: "sk-default", dev: { output: "json" } }); + + const devStatus = await runCli(["auth", "status", "--config", "dev", "--output", "json"], { + BAILIAN_CONFIG_DIR: dir, + }); + expect(devStatus.exitCode, devStatus.stderr).toBe(0); + const devData = parseStdoutJson>(devStatus.stdout); + expect(devData.authenticated).toBe(false); + expect(devData.config).toBe("dev"); + + const defaultStatus = await runCli(["auth", "status", "--output", "json"], { + BAILIAN_CONFIG_DIR: dir, + }); + expect(defaultStatus.exitCode, defaultStatus.stderr).toBe(0); + const defaultData = parseStdoutJson>(defaultStatus.stdout); + expect(defaultData.authenticated).toBe(true); + expect(defaultData.config).toBe("default"); + }); + }); + + test("--config default 等价默认配置", async () => { + await withTempConfigDir(async (dir) => { + writeConfig(dir, { output: "json", api_key: "sk-default" }); + const { stdout, stderr, exitCode } = await runCli( + ["config", "show", "--config", "default", "--output", "json"], + { BAILIAN_CONFIG_DIR: dir }, + ); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson>(stdout); + expect(data.config).toBe("default"); + expect(data.api_key).toBeDefined(); + }); + }); + + test("非法 --config 名称报 usage error", async () => { + const { stderr, exitCode } = await runCli(["auth", "status", "--config", "../evil"]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/Invalid config name/); + }); +}); diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index 2b26e9e..2696ea4 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -6,7 +6,6 @@ import { BailianError, ExitCode, chatPath, - getConfigPath, requestJson, type AuthStore, type ConfigFile, @@ -23,6 +22,9 @@ export interface LoginDeps { const CONSOLE_LOGIN_TIMEOUT_MS = 15 * 60 * 1000; const MAX_AUTH_CALLBACK_BODY = 65536; +// Regex for double newline (\r\n\r\n or \n\n); built via RegExp to avoid +// literal multi-line splitting in source. +const REGEX_DOUBLE_NEWLINE = new RegExp("\r\n\r\n|\n\n"); const CONSOLE_ORIGINS: Record = { domestic: "https://bailian.console.aliyun.com", @@ -76,7 +78,7 @@ function parseAccessTokenFromMultipart(raw: string, boundaryValue: string): stri for (let i = 1; i < segments.length; i++) { const part = segments[i]!; if (!/name\s*=\s*["'](?:access_token|accessToken)["']/i.test(part)) continue; - const sep = part.match(/\r\n\r\n|\n\n/); + const sep = part.match(REGEX_DOUBLE_NEWLINE); if (!sep || sep.index === undefined) continue; let value = part.slice(sep.index + sep[0].length); value = value @@ -495,7 +497,7 @@ export async function runConsoleLogin( console_switch_agent: consoleSwitchAgent ? Number(consoleSwitchAgent) : undefined, workspace_id: workspaceId || undefined, }); - process.stderr.write(`Config saved to ${getConfigPath()}\n`); + process.stderr.write(`Config saved to ${deps.authStore.path}\n`); } if (apiKey) { const testBaseUrl = baseUrl || deps.authStore.resolveBaseUrl(); diff --git a/packages/commands/src/commands/auth/login.ts b/packages/commands/src/commands/auth/login.ts index 0a7511e..a52d25a 100644 --- a/packages/commands/src/commands/auth/login.ts +++ b/packages/commands/src/commands/auth/login.ts @@ -1,4 +1,4 @@ -import { defineCommand, getConfigPath } from "bailian-cli-core"; +import { defineCommand } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; import { resolveConsoleOrigin, @@ -129,7 +129,7 @@ export default defineCommand({ access_key_secret: flags.accessKeySecret, access_token: accessToken, }); - process.stderr.write(`OpenAPI credentials saved to ${getConfigPath()}\n`); + process.stderr.write(`OpenAPI credentials saved to ${store.path}\n`); return; } diff --git a/packages/commands/src/commands/auth/logout.ts b/packages/commands/src/commands/auth/logout.ts index 53b7b88..7cea422 100644 --- a/packages/commands/src/commands/auth/logout.ts +++ b/packages/commands/src/commands/auth/logout.ts @@ -1,4 +1,4 @@ -import { defineCommand, getConfigPath } from "bailian-cli-core"; +import { defineCommand } from "bailian-cli-core"; import { emitBare } from "bailian-cli-runtime"; export default defineCommand({ @@ -25,13 +25,13 @@ export default defineCommand({ if (flags.console) { if (settings.dryRun) { - if (stored.console) emitBare("Would clear access_token from ~/.bailian/config.json"); + if (stored.console) emitBare(`Would clear access_token from ${store.path}`); else emitBare("No console access_token to clear."); emitBare("No changes made."); return; } if (await store.logout("console")) { - process.stderr.write(`Cleared access_token from ${getConfigPath()}\n`); + process.stderr.write(`Cleared access_token from ${store.path}\n`); if (stored.apiKey) { process.stderr.write( "api_key is still configured and will be used for authentication.\n", @@ -46,13 +46,13 @@ export default defineCommand({ if (flags.openApi) { if (settings.dryRun) { if (stored.openapi) - emitBare("Would clear access_key_id / access_key_secret from ~/.bailian/config.json"); + emitBare(`Would clear access_key_id / access_key_secret from ${store.path}`); else emitBare("No OpenAPI AK/SK credentials to clear."); emitBare("No changes made."); return; } if (await store.logout("openapi")) { - process.stderr.write(`Cleared access_key_id / access_key_secret from ${getConfigPath()}\n`); + process.stderr.write(`Cleared access_key_id / access_key_secret from ${store.path}\n`); if (stored.apiKey || stored.console) { process.stderr.write( "Other credentials are still configured and will be used for authentication.\n", @@ -69,7 +69,7 @@ export default defineCommand({ if (settings.dryRun) { if (hasKey) emitBare( - "Would clear api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json", + `Would clear api_key / access_token / access_key_id / access_key_secret from ${store.path}`, ); else emitBare("No credentials to clear."); emitBare("No changes made."); @@ -78,7 +78,7 @@ export default defineCommand({ if (await store.logout("all")) { process.stderr.write( - "Cleared api_key / access_token / access_key_id / access_key_secret from ~/.bailian/config.json\n", + `Cleared api_key / access_token / access_key_id / access_key_secret from ${store.path}\n`, ); } else { process.stderr.write("No credentials to clear.\n"); diff --git a/packages/commands/src/commands/auth/status.ts b/packages/commands/src/commands/auth/status.ts index e46b252..6bc001f 100644 --- a/packages/commands/src/commands/auth/status.ts +++ b/packages/commands/src/commands/auth/status.ts @@ -35,11 +35,15 @@ export default defineCommand({ : undefined; const authenticated = !!(apiKey || consoleCred || openapi); + const configName = settings.configName ?? "default"; + const configFile = ctx.authStore().path; if (!authenticated) { emitResult( { authenticated: false, + config: configName, + config_file: configFile, message: "Not authenticated.", hint: [ `API key (model): ${identity.binName} auth login --api-key or DASHSCOPE_API_KEY`, @@ -54,10 +58,21 @@ export default defineCommand({ } if (format !== "text") { - emitResult({ authenticated: true, api_key: apiKey, console: consoleCred, openapi }, format); + emitResult( + { + authenticated: true, + config: configName, + config_file: configFile, + api_key: apiKey, + console: consoleCred, + openapi, + }, + format, + ); return; } + emitBare(`Config: ${configName} (${configFile})`); emitBare("Authentication Status:"); if (apiKey) { emitBare(` API key (model): ${apiKey.source} ${apiKey.masked}`); diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index 6715d01..d4d431f 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -101,7 +101,14 @@ export default defineCommand({ const format = detectOutputFormat(settings.output); if (settings.dryRun) { - emitResult({ would_set: { [resolvedKey]: value } }, format); + emitResult( + { + would_set: { [resolvedKey]: value }, + config: settings.configName ?? "default", + config_file: ctx.configStore().path, + }, + format, + ); return; } @@ -110,7 +117,14 @@ export default defineCommand({ if (!settings.quiet) { const shown = SECRET_KEYS.has(resolvedKey) ? maskToken(String(coerced)) : coerced; - emitResult({ [resolvedKey]: shown }, format); + emitResult( + { + [resolvedKey]: shown, + config: settings.configName ?? "default", + config_file: ctx.configStore().path, + }, + format, + ); } }, }); diff --git a/packages/commands/src/commands/config/show.ts b/packages/commands/src/commands/config/show.ts index 228a2b1..4a1ad14 100644 --- a/packages/commands/src/commands/config/show.ts +++ b/packages/commands/src/commands/config/show.ts @@ -16,6 +16,7 @@ export default defineCommand({ base_url: client.baseUrl, output: settings.output, timeout: settings.timeout, + config: settings.configName ?? "default", config_file: store.path, }; diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index c93987d..32315ea 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -1,6 +1,7 @@ import type { ConfigFile } from "../config/schema.ts"; import type { ResolutionSources } from "../config/loader.ts"; import { readConfigFile, writeConfigFile } from "../config/loader.ts"; +import { getConfigPath } from "../config/paths.ts"; import type { AuthState } from "./types.ts"; import { describeAuthState, resolveModelBaseUrl } from "./resolver.ts"; @@ -40,13 +41,18 @@ export interface AuthStore { login(patch: AuthPersistPatch): Promise; /** 清凭证:console/openapi 只删对应域;all 清全部登录凭证。返回是否有变更。 */ logout(scope: "console" | "openapi" | "all"): Promise; + /** 实际写入的 config.json 路径(不受命名配置影响,一直是同一个文件)。 */ + path: string; + /** 当前命名配置名(`--config ` 解析后);未指定或 `default` 时为 undefined。 */ + configName?: string; } export function makeAuthStore(sources: ResolutionSources): AuthStore { + const configName = sources.configName; return { describe: () => describeAuthState(sources), stored() { - const file = readConfigFile(); + const file = readConfigFile(configName); return { apiKey: !!file.api_key, console: !!file.access_token, @@ -55,20 +61,26 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore { }, resolveBaseUrl: () => resolveModelBaseUrl(sources), async login(patch) { - const existing = readConfigFile() as Record; + const existing = readConfigFile(configName) as Record; for (const [key, value] of Object.entries(patch)) { if (value !== undefined) existing[key] = value; } - await writeConfigFile(existing); + await writeConfigFile(existing, configName); }, async logout(scope) { - const existing = readConfigFile() as Record; + const existing = readConfigFile(configName) as Record; const keys = LOGOUT_KEYS[scope]; const had = keys.some((key) => existing[key] !== undefined); if (!had) return false; for (const key of keys) delete existing[key]; - await writeConfigFile(existing); + await writeConfigFile(existing, configName); return true; }, + get path() { + return sources.configPath ?? getConfigPath(); + }, + get configName() { + return configName; + }, }; } diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index 6825de9..f5e313a 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -1,6 +1,6 @@ export type { ConfigFile, Region, Identity, Settings } from "./schema.ts"; -export { BAILIAN_HOST, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts"; -export { readConfigFile, writeConfigFile } from "./loader.ts"; +export { BAILIAN_HOST, CONFIG_FILE_KEYS, DOCS_HOSTS, REGIONS, parseConfigFile } from "./schema.ts"; +export { normalizeConfigName, readConfigFile, writeConfigFile } from "./loader.ts"; export { 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 9674e2e..9ab2130 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -1,16 +1,45 @@ import { readFileSync, writeFileSync, renameSync, existsSync } from "fs"; -import { parseConfigFile, type ConfigFile, type Settings } from "./schema.ts"; +import { CONFIG_FILE_KEYS, parseConfigFile, type ConfigFile, type Settings } from "./schema.ts"; import { ensureConfigDir, getConfigPath } from "./paths.ts"; import { detectOutputFormat } from "../output/formatter.ts"; import { BailianError } from "../errors/base.ts"; import { ExitCode } from "../errors/codes.ts"; import type { SourceFlags } from "../types/command.ts"; -export function readConfigFile(): ConfigFile { +const CONFIG_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; + +/** + * 校验并规范化 `--config `:`undefined`/""/"default" 都视为未指定(等价顶层默认配置)。 + * 合法命名只允许字母、数字、`-`/`_`,且不能与 `ConfigFile` 顶层字段同名(避免写入时与默认配置字段歧义)。 + */ +export function normalizeConfigName(name?: unknown): string | undefined { + if (name === undefined || name === "" || name === "default") return undefined; + if (typeof name !== "string" || !CONFIG_NAME_PATTERN.test(name)) { + const display = typeof name === "string" ? name : JSON.stringify(name); + throw new BailianError( + `Invalid config name "${display}".`, + ExitCode.USAGE, + "Use letters, numbers, '-' or '_', starting with a letter or number.", + ); + } + if ((CONFIG_FILE_KEYS as readonly string[]).includes(name)) { + throw new BailianError( + `Invalid config name "${name}". It conflicts with a config key.`, + ExitCode.USAGE, + ); + } + return name; +} + +/** 读完整 config.json 原始对象(不经过 `parseConfigFile` 过滤),保留其他命名配置 block。 */ +function readRawConfigObject(): Record { const path = getConfigPath(); if (!existsSync(path)) return {}; try { - return parseConfigFile(JSON.parse(readFileSync(path, "utf-8"))); + const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown; + return raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : {}; } catch (err) { const e = err as Error; if (e instanceof SyntaxError || e.message.includes("JSON")) { @@ -20,11 +49,34 @@ export function readConfigFile(): ConfigFile { } } -export async function writeConfigFile(data: Record): Promise { +function readRawConfigBlock(raw: Record, configName?: string): unknown { + if (!configName) return raw; + const block = raw[configName]; + return block && typeof block === "object" && !Array.isArray(block) ? block : {}; +} + +export function readConfigFile(configName?: string): ConfigFile { + const raw = readRawConfigObject(); + return parseConfigFile(readRawConfigBlock(raw, configName)); +} + +export async function writeConfigFile( + data: Record, + configName?: string, +): Promise { + const raw = readRawConfigObject(); + if (configName) { + raw[configName] = data; + } else { + for (const key of Object.keys(raw)) { + if ((CONFIG_FILE_KEYS as readonly string[]).includes(key)) delete raw[key]; + } + Object.assign(raw, data); + } await ensureConfigDir(); const path = getConfigPath(); const tmp = path + ".tmp"; - writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 }); + writeFileSync(tmp, JSON.stringify(raw, null, 2) + "\n", { mode: 0o600 }); renameSync(tmp, path); } @@ -36,10 +88,21 @@ export interface ResolutionSources { flags: Partial; file: ConfigFile; env: NodeJS.ProcessEnv; + /** 当前命名配置名(`--config ` 解析后);未指定或 `default` 时为 undefined。 */ + configName?: string; + /** 实际 config.json 路径(不受 configName 影响,一直是同一个文件)。 */ + configPath?: string; } export function buildSources(flags: Partial): ResolutionSources { - return { flags, file: readConfigFile(), env: process.env }; + const configName = normalizeConfigName(flags.config); + return { + flags, + file: readConfigFile(configName), + env: process.env, + configName, + configPath: getConfigPath(), + }; } /** @@ -60,7 +123,8 @@ export function buildSettings(s: ResolutionSources): Settings { } return { - configPath: getConfigPath(), + configPath: s.configPath ?? getConfigPath(), + configName: s.configName, intentDetectBaseUrl: file.intent_detect_base_url || env.DASHSCOPE_INTENT_DETECT_BASE_URL || undefined, output: detectOutputFormat(flags.output || env.DASHSCOPE_OUTPUT || file.output), diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 12fdaec..b03a40f 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -46,6 +46,29 @@ export interface ConfigFile { telemetry?: boolean; } +export const CONFIG_FILE_KEYS = [ + "api_key", + "access_token", + "access_key_id", + "access_key_secret", + "security_token", + "base_url", + "intent_detect_base_url", + "output", + "output_dir", + "timeout", + "default_text_model", + "default_video_model", + "default_image_model", + "default_speech_model", + "default_omni_model", + "workspace_id", + "console_site", + "console_region", + "console_switch_agent", + "telemetry", +] as const satisfies readonly (keyof ConfigFile)[]; + const VALID_OUTPUTS = new Set(["text", "json"]); const VALID_CONSOLE_SITES = new Set(["domestic", "international"]); @@ -136,6 +159,7 @@ export interface Identity { */ export interface Settings { configPath?: string; + configName?: string; /** Dedicated base URL for intent-detect model; falls back to the model baseUrl at call site. */ intentDetectBaseUrl?: string; output: "text" | "json"; diff --git a/packages/core/src/config/store.ts b/packages/core/src/config/store.ts index 8ce9833..72a26b2 100644 --- a/packages/core/src/config/store.ts +++ b/packages/core/src/config/store.ts @@ -13,26 +13,30 @@ export interface ConfigStore { /** 删除指定键。 */ unset(keys: (keyof ConfigFile)[]): Promise; path: string; + configName?: string; } -export function makeConfigStore(): ConfigStore { +export function makeConfigStore(configName?: string): ConfigStore { return { - read: () => readConfigFile(), + read: () => readConfigFile(configName), async write(patch) { - const existing = readConfigFile() as Record; + const existing = readConfigFile(configName) as Record; for (const [key, value] of Object.entries(patch)) { if (value === undefined) delete existing[key]; else existing[key] = value; } - await writeConfigFile(existing); + await writeConfigFile(existing, configName); }, async unset(keys) { - const existing = readConfigFile() as Record; + const existing = readConfigFile(configName) as Record; for (const key of keys) delete existing[key]; - await writeConfigFile(existing); + await writeConfigFile(existing, configName); }, get path() { return getConfigPath(); }, + get configName() { + return configName; + }, }; } diff --git a/packages/core/src/types/command.ts b/packages/core/src/types/command.ts index c4d336d..dde8342 100644 --- a/packages/core/src/types/command.ts +++ b/packages/core/src/types/command.ts @@ -71,6 +71,11 @@ export const GLOBAL_FLAGS = { quiet: { type: "switch", description: "Suppress non-essential output" }, verbose: { type: "switch", description: "Print HTTP request/response details" }, dryRun: { type: "switch", description: "Dry run mode" }, + config: { + type: "string", + valueHint: "", + description: "Use named config credentials", + }, help: { type: "switch", description: "Show help" }, version: { type: "switch", description: "Print version" }, } satisfies FlagsDef; diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index e94f1bd..17034c2 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -4,6 +4,13 @@ import { join } from "path"; import { expect, test } from "vite-plus/test"; import { makeConfigStore } from "../src/config/store.ts"; import { makeAuthStore } from "../src/auth/store.ts"; +import { + buildSources, + normalizeConfigName, + readConfigFile, + writeConfigFile, +} from "../src/config/loader.ts"; +import { getConfigPath } from "../src/config/paths.ts"; /** 在隔离的临时配置目录里执行,结束后恢复环境。 */ async function inTempConfigDir(fn: () => Promise): Promise { @@ -64,3 +71,66 @@ test("AuthStore:login 合并落盘,logout 按域清理并报告变更", async () expect(makeConfigStore().read().workspace_id).toBe("ws-1"); }); }); + +test("ConfigStore:命名 config 与默认配置隔离且写入保留其它 block", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default", output: "json" }); + await writeConfigFile({ api_key: "sk-prod", output: "text" }, "prod"); + + const dev = makeConfigStore("dev"); + await dev.write({ api_key: "sk-dev", timeout: 120 }); + + expect(makeConfigStore().read()).toMatchObject({ api_key: "sk-default", output: "json" }); + expect(dev.read()).toMatchObject({ api_key: "sk-dev", timeout: 120 }); + expect(makeConfigStore("prod").read()).toMatchObject({ api_key: "sk-prod", output: "text" }); + expect(readConfigFile("dev")).not.toMatchObject({ output: "json" }); + expect(dev.path).toBe(getConfigPath()); + }); +}); + +test("AuthStore:login/logout 只影响当前命名 config", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default", access_token: "tok-default" }); + const sources = buildSources({ config: "dev" }); + const store = makeAuthStore(sources); + + await store.login({ api_key: "sk-dev", access_token: "tok-dev", workspace_id: "ws-dev" }); + expect(makeConfigStore().read()).toMatchObject({ + api_key: "sk-default", + access_token: "tok-default", + }); + expect(makeConfigStore("dev").read()).toMatchObject({ + api_key: "sk-dev", + access_token: "tok-dev", + workspace_id: "ws-dev", + }); + + expect(await store.logout("console")).toBe(true); + expect(makeConfigStore("dev").read().access_token).toBeUndefined(); + expect(makeConfigStore().read().access_token).toBe("tok-default"); + }); +}); + +test("config name 校验拒绝路径穿越和 ConfigFile 字段冲突", () => { + expect(normalizeConfigName("dev_1")).toBe("dev_1"); + expect(normalizeConfigName("default")).toBeUndefined(); + expect(() => normalizeConfigName("../evil")).toThrow(/Invalid config name/); + expect(() => normalizeConfigName("api_key")).toThrow(/conflicts with a config key/); +}); + +test("buildSources 暴露命名 config 且 default 等价顶层", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default", output: "json" }); + await writeConfigFile({ access_token: "tok-dev" }, "dev"); + + const defaultSources = buildSources({ config: "default" }); + expect(defaultSources.configName).toBeUndefined(); + expect(defaultSources.file.api_key).toBe("sk-default"); + + const devSources = buildSources({ config: "dev" }); + expect(devSources.configName).toBe("dev"); + expect(devSources.configPath).toBe(getConfigPath()); + expect(devSources.file.access_token).toBe("tok-dev"); + expect(devSources.file.api_key).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/create-cli.ts b/packages/runtime/src/create-cli.ts index 986a30c..bbc5709 100644 --- a/packages/runtime/src/create-cli.ts +++ b/packages/runtime/src/create-cli.ts @@ -167,7 +167,7 @@ export function createCli(commands: Record, opts: CliOptions flags: ownFlags, settings, sources, - configStore: () => makeConfigStore(), + configStore: () => makeConfigStore(sources.configName), authStore: () => makeAuthStore(sources), client: new Client({ identity, settings, baseUrl: resolveModelBaseUrl(sources) }), }; diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 77a309d..2f877ae 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -128,6 +128,7 @@ Available on every command (in addition to command-specific flags): | `--quiet` | switch | no | Suppress non-essential output | | `--verbose` | switch | no | Print HTTP request/response details | | `--dry-run` | switch | no | Dry run mode | +| `--config ` | string | no | Use named config credentials | | `--help` | switch | no | Show help | | `--version` | switch | no | Print version |