mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
feat(config): 支持命名配置功能并隔离默认配置数据
- 新增 `--config <name>` 参数支持读取与写入命名的配置块 - 命名配置与默认配置完全隔离,互不影响 - 规范命名配置名称格式,禁止路径穿越及顶层字段冲突 - 配置文件读取写入逻辑改为维护原始完整对象,支持多配置块共存 - AuthStore 和 ConfigStore 均支持命名配置,登录登出只影响指定配置块 - CLI 命令增加对 `--config` 标志的支持,包括 config set/show/auth status 等 - 鉴权状态输出带上配置名和配置文件路径信息 - 提示和报错信息包含配置相关上下文,增强用户体验 - 完善相关单元测试覆盖命名配置行为
This commit is contained in:
@@ -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<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||
const dir = mkdtempSync(join(tmpdir(), "bl-config-profile-"));
|
||||
return fn(dir).finally(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
function writeConfig(dir: string, data: Record<string, unknown>): 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 <name>/);
|
||||
});
|
||||
|
||||
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<string, unknown>).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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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<Record<string, unknown>>(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/);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 <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}`);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
/** 清凭证:console/openapi 只删对应域;all 清全部登录凭证。返回是否有变更。 */
|
||||
logout(scope: "console" | "openapi" | "all"): Promise<boolean>;
|
||||
/** 实际写入的 config.json 路径(不受命名配置影响,一直是同一个文件)。 */
|
||||
path: string;
|
||||
/** 当前命名配置名(`--config <name>` 解析后);未指定或 `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<string, unknown>;
|
||||
const existing = readConfigFile(configName) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const existing = readConfigFile(configName) as Record<string, unknown>;
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 <name>`:`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<string, unknown> {
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
} 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<string, unknown>): Promise<void> {
|
||||
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 : {};
|
||||
}
|
||||
|
||||
export function readConfigFile(configName?: string): ConfigFile {
|
||||
const raw = readRawConfigObject();
|
||||
return parseConfigFile(readRawConfigBlock(raw, configName));
|
||||
}
|
||||
|
||||
export async function writeConfigFile(
|
||||
data: Record<string, unknown>,
|
||||
configName?: string,
|
||||
): Promise<void> {
|
||||
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<SourceFlags>;
|
||||
file: ConfigFile;
|
||||
env: NodeJS.ProcessEnv;
|
||||
/** 当前命名配置名(`--config <name>` 解析后);未指定或 `default` 时为 undefined。 */
|
||||
configName?: string;
|
||||
/** 实际 config.json 路径(不受 configName 影响,一直是同一个文件)。 */
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
export function buildSources(flags: Partial<SourceFlags>): 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),
|
||||
|
||||
@@ -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<string>(["text", "json"]);
|
||||
const VALID_CONSOLE_SITES = new Set<string>(["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";
|
||||
|
||||
@@ -13,26 +13,30 @@ export interface ConfigStore {
|
||||
/** 删除指定键。 */
|
||||
unset(keys: (keyof ConfigFile)[]): Promise<void>;
|
||||
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<string, unknown>;
|
||||
const existing = readConfigFile(configName) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const existing = readConfigFile(configName) as Record<string, unknown>;
|
||||
for (const key of keys) delete existing[key];
|
||||
await writeConfigFile(existing);
|
||||
await writeConfigFile(existing, configName);
|
||||
},
|
||||
get path() {
|
||||
return getConfigPath();
|
||||
},
|
||||
get configName() {
|
||||
return configName;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: "<name>",
|
||||
description: "Use named config credentials",
|
||||
},
|
||||
help: { type: "switch", description: "Show help" },
|
||||
version: { type: "switch", description: "Print version" },
|
||||
} satisfies FlagsDef;
|
||||
|
||||
@@ -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<void>): Promise<void> {
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ export function createCli(commands: Record<string, AnyCommand>, opts: CliOptions
|
||||
flags: ownFlags,
|
||||
settings,
|
||||
sources,
|
||||
configStore: () => makeConfigStore(),
|
||||
configStore: () => makeConfigStore(sources.configName),
|
||||
authStore: () => makeAuthStore(sources),
|
||||
client: new Client({ identity, settings, baseUrl: resolveModelBaseUrl(sources) }),
|
||||
};
|
||||
|
||||
@@ -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 <name>` | string | no | Use named config credentials |
|
||||
| `--help` | switch | no | Show help |
|
||||
| `--version` | switch | no | Print version |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user