From e0f3d450aedf2eea03d767cc20a666fdbd2b8780 Mon Sep 17 00:00:00 2001 From: "lisheng.lisheng" Date: Mon, 13 Jul 2026 14:20:32 +0800 Subject: [PATCH] feat(config): add "config ui" local web UI to manage config profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动绑定 127.0.0.1 的本地 HTTP server + 内嵌单页 WebUI,可视化查看/ 新建/切换/删除全部命名 profile 并编辑键值与凭证。 - core: readConfigProfiles / deleteConfigProfile 全量配置读写 API - commands/config/shared.ts: 抽出 VALID_KEYS/别名/校验,set.ts 复用 - commands/shared/local-server.ts: 抽出 listen/openInBrowser,login-console 复用 - config ui: token + Host 校验,--config 决定初始聚焦,密钥明文可编辑 --- packages/cli/src/commands.ts | 2 + .../src/commands/auth/login-console.ts | 29 +-- packages/commands/src/commands/config/set.ts | 81 +----- .../commands/src/commands/config/shared.ts | 88 +++++++ .../commands/src/commands/config/ui-html.ts | 203 +++++++++++++++ packages/commands/src/commands/config/ui.ts | 237 ++++++++++++++++++ .../src/commands/shared/local-server.ts | 37 +++ packages/commands/src/index.ts | 1 + packages/commands/tests/config-ui.test.ts | 134 ++++++++++ .../commands/tests/e2e/config.e2e.test.ts | 20 ++ packages/commands/tests/e2e/topic-routes.ts | 1 + packages/core/src/config/index.ts | 1 + packages/core/src/config/loader.ts | 37 +++ packages/core/tests/config-store.test.ts | 24 ++ skills/bailian-cli/reference/config.md | 46 +++- skills/bailian-cli/reference/index.md | 3 +- 16 files changed, 833 insertions(+), 111 deletions(-) create mode 100644 packages/commands/src/commands/config/shared.ts create mode 100644 packages/commands/src/commands/config/ui-html.ts create mode 100644 packages/commands/src/commands/config/ui.ts create mode 100644 packages/commands/src/commands/shared/local-server.ts create mode 100644 packages/commands/tests/config-ui.test.ts diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 223d0b6..7ef20f5 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -16,6 +16,7 @@ import { visionDescribe, configShow, configSet, + configUi, update, appCall, appList, @@ -103,6 +104,7 @@ export const commands: Record = { "vision describe": visionDescribe, "config show": configShow, "config set": configSet, + "config ui": configUi, update, "app call": appCall, "app list": appList, diff --git a/packages/commands/src/commands/auth/login-console.ts b/packages/commands/src/commands/auth/login-console.ts index 2696ea4..43954c7 100644 --- a/packages/commands/src/commands/auth/login-console.ts +++ b/packages/commands/src/commands/auth/login-console.ts @@ -1,4 +1,3 @@ -import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; import http from "node:http"; @@ -12,6 +11,7 @@ import { type Identity, type Settings, } from "bailian-cli-core"; +import { listenLocalServer, openInBrowser } from "../shared/local-server.ts"; /** 登录流程的能力面:身份(UA)、有效配置(timeout 等)、auth 域落盘。 */ export interface LoginDeps { @@ -361,32 +361,7 @@ async function extractCredentialsFromRequest( } function listenServerOnFreeLocalPort(server: http.Server): Promise { - return new Promise((resolve, reject) => { - const onErr = (e: Error) => reject(e); - server.once("error", onErr); - server.listen({ port: 0, host: "127.0.0.1", exclusive: true }, () => { - server.off("error", onErr); - const addr = server.address(); - if (!addr || typeof addr === "string") { - reject(new Error("Expected TCP socket address")); - return; - } - resolve(addr.port); - }); - }); -} - -function openInBrowser(url: string): Promise { - const platform = process.platform; - const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; - const args = platform === "win32" ? ["/c", "start", "", url] : [url]; - - return new Promise((resolve, reject) => { - execFile(cmd, args, { windowsHide: true }, (err) => { - if (err) reject(err); - else resolve(); - }); - }); + return listenLocalServer(server); } const RETRY_DELAY_BASE_MS = 500; diff --git a/packages/commands/src/commands/config/set.ts b/packages/commands/src/commands/config/set.ts index d4d431f..351e143 100644 --- a/packages/commands/src/commands/config/set.ts +++ b/packages/commands/src/commands/config/set.ts @@ -1,50 +1,6 @@ -import { - defineCommand, - detectOutputFormat, - maskToken, - BailianError, - ExitCode, - type ConfigFile, -} from "bailian-cli-core"; +import { defineCommand, detectOutputFormat, maskToken, type ConfigFile } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; - -const VALID_KEYS = [ - "base_url", - "output", - "output_dir", - "timeout", - "api_key", - "access_token", - "access_key_id", - "access_key_secret", - "default_text_model", - "default_video_model", - "default_image_model", - "default_speech_model", - "default_omni_model", - "workspace_id", -]; - -// Keys whose values are secrets. Their stored value must never be echoed back in -// cleartext (CI logs, pipes, shared terminals); show a masked form instead — the -// same policy `config show` and `auth status` already follow. -const SECRET_KEYS = new Set(["api_key", "access_token", "access_key_id", "access_key_secret"]); - -// Allow hyphen-style keys (e.g. default-text-model → default_text_model) -const KEY_ALIASES: Record = { - "base-url": "base_url", - "output-dir": "output_dir", - "api-key": "api_key", - "access-token": "access_token", - "access-key-id": "access_key_id", - "access-key-secret": "access_key_secret", - "default-text-model": "default_text_model", - "default-video-model": "default_video_model", - "default-image-model": "default_image_model", - "default-speech-model": "default_speech_model", - "default-omni-model": "default_omni_model", - "workspace-id": "workspace_id", -}; +import { SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts"; export default defineCommand({ description: "Set a config value", @@ -55,7 +11,7 @@ export default defineCommand({ type: "string", valueHint: "", description: - "Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, default_*_model, workspace_id)", + "Config key (base_url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default_*_model, workspace_id)", required: true, }, value: { type: "string", valueHint: "", description: "Value to set", required: true }, @@ -70,33 +26,9 @@ export default defineCommand({ const key = flags.key; const value = flags.value; - // Resolve hyphen aliases to underscore keys - const resolvedKey: string = KEY_ALIASES[key] || key; - - if (!VALID_KEYS.includes(resolvedKey)) { - throw new BailianError( - `Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`, - ExitCode.USAGE, - ); - } - - // Validate specific values - if (resolvedKey === "output" && !["text", "json"].includes(value)) { - throw new BailianError( - `Invalid output format "${value}". Valid values: text, json`, - ExitCode.USAGE, - ); - } - - if (resolvedKey === "timeout") { - const num = Number(value); - if (isNaN(num) || num <= 0) { - throw new BailianError( - `Invalid timeout "${value}". Must be a positive number.`, - ExitCode.USAGE, - ); - } - } + // Resolve hyphen aliases to underscore keys and validate/coerce the value. + const resolvedKey: string = resolveKey(key); + const coerced = validateAndCoerce(key, value); const format = detectOutputFormat(settings.output); @@ -112,7 +44,6 @@ export default defineCommand({ return; } - const coerced = resolvedKey === "timeout" ? Number(value) : value; await ctx.configStore().write({ [resolvedKey]: coerced } as Partial); if (!settings.quiet) { diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts new file mode 100644 index 0000000..2ca0e08 --- /dev/null +++ b/packages/commands/src/commands/config/shared.ts @@ -0,0 +1,88 @@ +import { BailianError, ExitCode } from "bailian-cli-core"; + +/** Config keys that `config set` / `config ui` accept for read/write. */ +export const VALID_KEYS = [ + "base_url", + "output", + "output_dir", + "timeout", + "api_key", + "access_token", + "access_key_id", + "access_key_secret", + "security_token", + "default_text_model", + "default_video_model", + "default_image_model", + "default_speech_model", + "default_omni_model", + "workspace_id", +] as const; + +// Keys whose values are secrets. `config set` / `config show` mask these; the +// web UI renders them as password fields (values are still sent in cleartext +// over the token-gated localhost socket). +export const SECRET_KEYS = new Set([ + "api_key", + "access_token", + "access_key_id", + "access_key_secret", + "security_token", +]); + +// Allow hyphen-style keys (e.g. default-text-model → default_text_model). +export const KEY_ALIASES: Record = { + "base-url": "base_url", + "output-dir": "output_dir", + "api-key": "api_key", + "access-token": "access_token", + "access-key-id": "access_key_id", + "access-key-secret": "access_key_secret", + "security-token": "security_token", + "default-text-model": "default_text_model", + "default-video-model": "default_video_model", + "default-image-model": "default_image_model", + "default-speech-model": "default_speech_model", + "default-omni-model": "default_omni_model", + "workspace-id": "workspace_id", +}; + +/** Resolve a hyphen alias to its underscore config key. */ +export function resolveKey(key: string): string { + return KEY_ALIASES[key] || key; +} + +/** + * Validate a single config entry and coerce its value to the stored type. + * Throws BailianError(USAGE) for unknown keys or invalid values. + */ +export function validateAndCoerce(key: string, value: string): string | number { + const resolvedKey = resolveKey(key); + + if (!(VALID_KEYS as readonly string[]).includes(resolvedKey)) { + throw new BailianError( + `Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`, + ExitCode.USAGE, + ); + } + + if (resolvedKey === "output" && !["text", "json"].includes(value)) { + throw new BailianError( + `Invalid output format "${value}". Valid values: text, json`, + ExitCode.USAGE, + ); + } + + if (resolvedKey === "timeout") { + const num = Number(value); + if (isNaN(num) || num <= 0) { + throw new BailianError( + `Invalid timeout "${value}". Must be a positive number.`, + ExitCode.USAGE, + ); + } + return num; + } + + return value; +} diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts new file mode 100644 index 0000000..c028626 --- /dev/null +++ b/packages/commands/src/commands/config/ui-html.ts @@ -0,0 +1,203 @@ +// Self-contained single-page web UI for managing config profiles. Served as a +// string by `config ui`; no build step, no client dependencies. All fetches +// carry the session token from the page URL. +export const PAGE_HTML = ` + + + + +bailian-cli config + + + +
+ +
+
+

+ +
+
+
+ + +
+
+
+ + + +`; diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts new file mode 100644 index 0000000..eea68e9 --- /dev/null +++ b/packages/commands/src/commands/config/ui.ts @@ -0,0 +1,237 @@ +import http from "node:http"; +import { randomBytes } from "node:crypto"; + +import { + defineCommand, + detectOutputFormat, + BailianError, + ExitCode, + normalizeConfigName, + readConfigProfiles, + writeConfigFile, + deleteConfigProfile, + getConfigPath, + type FlagsDef, +} from "bailian-cli-core"; +import { emitResult, emitBare } from "bailian-cli-runtime"; +import { listenLocalServer, openInBrowser } from "../shared/local-server.ts"; +import { PAGE_HTML } from "./ui-html.ts"; +import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts"; + +const FLAGS = { + port: { + type: "number", + valueHint: "", + description: "Port to listen on (default: random free port)", + }, + noOpen: { type: "switch", description: "Do not open the browser automatically" }, +} satisfies FlagsDef; + +const MAX_BODY = 1 << 20; // 1 MiB + +function errMessage(err: unknown): string { + return err instanceof BailianError + ? err.message + : err instanceof Error + ? err.message + : String(err); +} + +function sendJson(res: http.ServerResponse, status: number, obj: unknown): void { + res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(obj)); +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY) { + reject(new Error("payload too large")); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +/** Build the request cleaned/validated config block from a posted `data` map. */ +function buildProfilePatch(data: Record): Record { + const cleaned: Record = {}; + for (const [k, v] of Object.entries(data)) { + let value = ""; + if (typeof v === "string") value = v; + else if (typeof v === "number" || typeof v === "boolean") value = String(v); + // null/undefined/objects fall through as "" and clear the key + if (value === "") continue; + cleaned[resolveKey(k)] = validateAndCoerce(k, value); + } + return cleaned; +} + +/** + * Build the config-UI http server. Exported for tests. The handler enforces: + * - 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 { + return http.createServer(async (req, res) => { + try { + const host = (req.headers.host || "").split(":")[0]; + if (host !== "127.0.0.1" && host !== "localhost") { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("forbidden host\n"); + return; + } + + const u = new URL(req.url ?? "/", "http://127.0.0.1"); + if (u.searchParams.get("token") !== token) { + res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("unauthorized\n"); + return; + } + + const method = req.method ?? "GET"; + const path = u.pathname; + + if (path === "/" && method === "GET") { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(PAGE_HTML); + return; + } + + if (path === "/api/config" && method === "GET") { + const profiles = readConfigProfiles(); + sendJson(res, 200, { + configFile: getConfigPath(), + keys: VALID_KEYS, + secretKeys: [...SECRET_KEYS], + activeProfile, + default: profiles.default, + named: profiles.named, + }); + return; + } + + if (path === "/api/profile" && 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; data?: unknown }; + if (!body.data || typeof body.data !== "object" || Array.isArray(body.data)) { + sendJson(res, 400, { error: "missing or invalid 'data'" }); + return; + } + let normalized: string | undefined; + let cleaned: Record; + try { + normalized = normalizeConfigName(body.name); + cleaned = buildProfilePatch(body.data as Record); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + return; + } + await writeConfigFile(cleaned, normalized); + sendJson(res, 200, { saved: cleaned }); + return; + } + + if (path === "/api/profile" && method === "DELETE") { + let normalized: string | undefined; + try { + normalized = normalizeConfigName(u.searchParams.get("name") ?? undefined); + } 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; + } + + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("not found\n"); + } catch { + if (!res.headersSent) res.writeHead(500); + res.end(); + } + }); +} + +export default defineCommand({ + description: "Open a local web UI to manage config profiles", + auth: "none", + usageArgs: "[--port ] [--no-open]", + flags: FLAGS, + exampleArgs: ["", "--port 8787", "--config staging --no-open"], + async run(ctx) { + const { settings, flags } = ctx; + const format = detectOutputFormat(settings.output); + + if (settings.dryRun) { + emitResult( + { + host: "127.0.0.1", + port: flags.port ?? "random free port", + config_file: getConfigPath(), + routes: [ + "GET / -> web UI", + "GET /api/config -> read all profiles", + "POST /api/profile -> save a profile", + "DELETE /api/profile -> delete a named profile", + ], + }, + format, + ); + return; + } + + const token = randomBytes(16).toString("hex"); + const activeProfile = settings.configName ?? null; + const server = createConfigUiServer(token, activeProfile); + + let port: number; + try { + port = await listenLocalServer(server, flags.port ?? 0); + } catch (err) { + throw new BailianError( + `Could not bind to 127.0.0.1 (no free port or permission denied): ${errMessage(err)}`, + ExitCode.USAGE, + ); + } + + const url = `http://127.0.0.1:${port}/?token=${token}`; + + if (!flags.noOpen) { + try { + await openInBrowser(url); + emitBare("Opened the config UI in your default browser."); + } catch { + emitBare("Could not open the browser automatically. Open the URL below manually."); + } + } + emitBare(`Config UI running at ${url}`); + emitBare("Note: credentials are shown in cleartext in the browser (localhost only)."); + emitBare("Press Ctrl+C to stop."); + + await new Promise((resolve) => { + const shutdown = () => server.close(() => resolve()); + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + server.once("close", () => resolve()); + }); + }, +}); diff --git a/packages/commands/src/commands/shared/local-server.ts b/packages/commands/src/commands/shared/local-server.ts new file mode 100644 index 0000000..ffbf5c1 --- /dev/null +++ b/packages/commands/src/commands/shared/local-server.ts @@ -0,0 +1,37 @@ +import { execFile } from "node:child_process"; +import http from "node:http"; + +/** + * Bind an http server to a loopback-only TCP port and resolve the chosen port. + * `port = 0` (default) lets the OS pick a free port. Always binds 127.0.0.1 so + * the server is never reachable off the local machine. + */ +export function listenLocalServer(server: http.Server, port = 0): Promise { + return new Promise((resolve, reject) => { + const onErr = (e: Error) => reject(e); + server.once("error", onErr); + server.listen({ port, host: "127.0.0.1", exclusive: true }, () => { + server.off("error", onErr); + const addr = server.address(); + if (!addr || typeof addr === "string") { + reject(new Error("Expected TCP socket address")); + return; + } + resolve(addr.port); + }); + }); +} + +/** Open a URL in the user's default browser (best-effort, cross-platform). */ +export function openInBrowser(url: string): Promise { + const platform = process.platform; + const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; + const args = platform === "win32" ? ["/c", "start", "", url] : [url]; + + return new Promise((resolve, reject) => { + execFile(cmd, args, { windowsHide: true }, (err) => { + if (err) reject(err); + else resolve(); + }); + }); +} diff --git a/packages/commands/src/index.ts b/packages/commands/src/index.ts index 65bf5df..cd3b7a0 100644 --- a/packages/commands/src/index.ts +++ b/packages/commands/src/index.ts @@ -19,6 +19,7 @@ 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 configUi } from "./commands/config/ui.ts"; export { default as update } from "./commands/update.ts"; export { default as appCall } from "./commands/app/call.ts"; export { default as appList } from "./commands/app/list.ts"; diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts new file mode 100644 index 0000000..d2fbc49 --- /dev/null +++ b/packages/commands/tests/config-ui.test.ts @@ -0,0 +1,134 @@ +import http from "node:http"; +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 { createConfigUiServer } from "../src/commands/config/ui.ts"; + +const TOKEN = "test-token"; + +interface HttpResult { + status: number; + json: any; + text: string; +} + +function httpJson( + port: number, + method: string, + path: string, + opts?: { body?: unknown; headers?: Record }, +): Promise { + return new Promise((resolve, reject) => { + const payload = opts?.body !== undefined ? JSON.stringify(opts.body) : undefined; + const headers: Record = { ...opts?.headers }; + if (payload) headers["Content-Type"] = "application/json"; + const req = http.request({ host: "127.0.0.1", port, method, path, headers }, (res) => { + let d = ""; + res.on("data", (c) => (d += c)); + res.on("end", () => { + let json: unknown = null; + try { + json = d ? JSON.parse(d) : null; + } catch { + json = null; + } + resolve({ status: res.statusCode ?? 0, json, text: d }); + }); + }); + req.on("error", reject); + if (payload) req.write(payload); + req.end(); + }); +} + +/** 隔离临时配置目录 + 启动 UI server,跑完清理。 */ +async function withServer( + activeProfile: string | null, + fn: (port: number) => Promise, +): Promise { + const saved = process.env.BAILIAN_CONFIG_DIR; + const dir = mkdtempSync(join(tmpdir(), "bl-ui-")); + process.env.BAILIAN_CONFIG_DIR = dir; + const server = createConfigUiServer(TOKEN, activeProfile); + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())); + const addr = server.address(); + const port = addr && typeof addr === "object" ? addr.port : 0; + try { + await fn(port); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + if (saved === undefined) delete process.env.BAILIAN_CONFIG_DIR; + else process.env.BAILIAN_CONFIG_DIR = saved; + rmSync(dir, { recursive: true, force: true }); + } +} + +test("GET /api/config 返回全部 profile 且密钥明文回传、activeProfile 反映 --config", async () => { + await withServer("dev", async (port) => { + await writeConfigFile({ api_key: "sk-default", output: "json" }); + await writeConfigFile({ api_key: "sk-dev", access_token: "tok-dev" }, "dev"); + + const res = await httpJson(port, "GET", `/api/config?token=${TOKEN}`); + expect(res.status).toBe(200); + expect(res.json.activeProfile).toBe("dev"); + expect(res.json.default).toMatchObject({ api_key: "sk-default", output: "json" }); + expect(res.json.named.dev).toMatchObject({ api_key: "sk-dev", access_token: "tok-dev" }); + expect(res.json.secretKeys).toContain("api_key"); + }); +}); + +test("鉴权:错误 token 401、非 loopback Host 403", async () => { + await withServer(null, async (port) => { + const bad = await httpJson(port, "GET", `/api/config?token=wrong`); + expect(bad.status).toBe(401); + + const badHost = await httpJson(port, "GET", `/api/config?token=${TOKEN}`, { + headers: { Host: "evil.com" }, + }); + expect(badHost.status).toBe(403); + }); +}); + +test("POST /api/profile 写命名 profile(timeout 强制为 number),空串清除键", async () => { + await withServer(null, async (port) => { + const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { name: "stage", data: { api_key: "sk-stage", timeout: "90" } }, + }); + expect(save.status).toBe(200); + expect(readConfigFile("stage")).toMatchObject({ api_key: "sk-stage", timeout: 90 }); + + // 空串清除 api_key(整块替换) + const clear = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { name: "stage", data: { api_key: "", timeout: "120" } }, + }); + expect(clear.status).toBe(200); + const after = readConfigFile("stage"); + expect(after.api_key).toBeUndefined(); + expect(after.timeout).toBe(120); + }); +}); + +test("POST /api/profile 非法 key 返回 400", async () => { + await withServer(null, async (port) => { + const res = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { name: "stage", data: { not_a_key: "x" } }, + }); + expect(res.status).toBe(400); + expect(String(res.json.error)).toMatch(/Invalid config key/); + }); +}); + +test("DELETE /api/profile 删命名 profile;缺 name 返回 400", async () => { + await withServer(null, 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); + expect(del.json.deleted).toBe(true); + expect(readConfigProfiles().named.stage).toBeUndefined(); + + const noName = await httpJson(port, "DELETE", `/api/profile?token=${TOKEN}`); + expect(noName.status).toBe(400); + }); +}); diff --git a/packages/commands/tests/e2e/config.e2e.test.ts b/packages/commands/tests/e2e/config.e2e.test.ts index 66c7122..fa9e46d 100644 --- a/packages/commands/tests/e2e/config.e2e.test.ts +++ b/packages/commands/tests/e2e/config.e2e.test.ts @@ -19,6 +19,26 @@ describe("e2e: config", () => { expect(stderr).toMatch(/set|--key|--value/i); }); + test("config ui --help 正常退出", async () => { + const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "ui", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/ui|--port|--no-open|web/i); + }); + + test("config ui --dry-run 打印计划不起服务", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ + "config", + "ui", + "--dry-run", + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ host?: string; routes?: string[] }>(stdout); + expect(data.host).toBe("127.0.0.1"); + expect(Array.isArray(data.routes)).toBe(true); + }); + test("config show --output json", async () => { const { stdout, stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [ "config", diff --git a/packages/commands/tests/e2e/topic-routes.ts b/packages/commands/tests/e2e/topic-routes.ts index faf1850..29b3126 100644 --- a/packages/commands/tests/e2e/topic-routes.ts +++ b/packages/commands/tests/e2e/topic-routes.ts @@ -15,6 +15,7 @@ export const TEXT_CHAT_ROUTES: E2eRouteExports = { "text chat": "textChat" }; export const CONFIG_ROUTES: E2eRouteExports = { "config show": "configShow", "config set": "configSet", + "config ui": "configUi", }; export const MEMORY_ROUTES: E2eRouteExports = { diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index f5e313a..9c40203 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -1,6 +1,7 @@ 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 { 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 b57ef08..6f9c241 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -73,6 +73,10 @@ export async function writeConfigFile( } Object.assign(raw, data); } + await writeRawConfigObject(raw); +} + +async function writeRawConfigObject(raw: Record): Promise { await ensureConfigDir(); const path = getConfigPath(); const tmp = path + ".tmp"; @@ -80,6 +84,39 @@ export async function writeConfigFile( renameSync(tmp, path); } +/** 全量配置快照:顶层默认配置 + 各命名 profile。 */ +export interface ConfigProfiles { + /** 顶层默认配置(parseConfigFile 过滤后)。 */ + default: ConfigFile; + /** 命名配置 name -> 配置。 */ + named: Record; +} + +/** + * 读取全部 profile:顶层默认配置与各命名 block。 + * 命名 block = raw 中不属于 `CONFIG_FILE_KEYS`、且值为普通对象的项。 + */ +export function readConfigProfiles(): ConfigProfiles { + const raw = readRawConfigObject(); + const named: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if ((CONFIG_FILE_KEYS as readonly string[]).includes(key)) continue; + if (value && typeof value === "object" && !Array.isArray(value)) { + named[key] = parseConfigFile(value); + } + } + return { default: parseConfigFile(raw), named }; +} + +/** 删除一个命名 profile block;存在才删并回写,返回是否有变更。 */ +export async function deleteConfigProfile(name: string): Promise { + const raw = readRawConfigObject(); + if (!(name in raw)) return false; + delete raw[name]; + await writeRawConfigObject(raw); + return true; +} + /** * 解析的三个来源,dispatch 边界一次构建。flags 收 Partial:ParsedFlags 里 switch 是 * 必填 boolean,收 Partial 让 pipeline 等无 flag 场景传 {} 即可。 diff --git a/packages/core/tests/config-store.test.ts b/packages/core/tests/config-store.test.ts index 17034c2..c21c657 100644 --- a/packages/core/tests/config-store.test.ts +++ b/packages/core/tests/config-store.test.ts @@ -9,6 +9,8 @@ import { normalizeConfigName, readConfigFile, writeConfigFile, + readConfigProfiles, + deleteConfigProfile, } from "../src/config/loader.ts"; import { getConfigPath } from "../src/config/paths.ts"; @@ -118,6 +120,28 @@ test("config name 校验拒绝路径穿越和 ConfigFile 字段冲突", () => { expect(() => normalizeConfigName("api_key")).toThrow(/conflicts with a config key/); }); +test("readConfigProfiles 分离 default 与 named,deleteConfigProfile 只删指定 block", async () => { + await inTempConfigDir(async () => { + await writeConfigFile({ api_key: "sk-default", output: "json" }); + await writeConfigFile({ api_key: "sk-prod" }, "prod"); + await writeConfigFile({ access_token: "tok-dev" }, "dev"); + + const profiles = readConfigProfiles(); + 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" }); + expect(profiles.named.dev).toMatchObject({ access_token: "tok-dev" }); + + expect(await deleteConfigProfile("prod")).toBe(true); + const after = readConfigProfiles(); + expect(after.named.prod).toBeUndefined(); + expect(after.named.dev).toMatchObject({ access_token: "tok-dev" }); + expect(after.default).toMatchObject({ api_key: "sk-default" }); + // 再次删除不存在的 block 返回 false + expect(await deleteConfigProfile("prod")).toBe(false); + }); +}); + test("buildSources 暴露命名 config 且 default 等价顶层", async () => { await inTempConfigDir(async () => { await writeConfigFile({ api_key: "sk-default", output: "json" }); diff --git a/skills/bailian-cli/reference/config.md b/skills/bailian-cli/reference/config.md index eb03de2..29a5139 100644 --- a/skills/bailian-cli/reference/config.md +++ b/skills/bailian-cli/reference/config.md @@ -7,10 +7,11 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Description | -| ---------------- | ----------------------------- | -| `bl config set` | Set a config value | -| `bl config show` | Display current configuration | +| Command | Description | +| ---------------- | --------------------------------------------- | +| `bl config set` | Set a config value | +| `bl config show` | Display current configuration | +| `bl config ui` | Open a local web UI to manage config profiles | ## Command details @@ -24,10 +25,10 @@ Index: [index.md](index.md) #### Flags -| Flag | Type | Required | Description | -| ----------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `--key ` | string | yes | Config key (base*url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, default*\*\_model, workspace_id) | -| `--value ` | string | yes | Value to set | +| Flag | Type | Required | Description | +| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--key ` | string | yes | Config key (base*url, output, output_dir, timeout, api_key, access_token, access_key_id, access_key_secret, security_token, default*\*\_model, workspace_id) | +| `--value ` | string | yes | Value to set | #### Examples @@ -64,3 +65,32 @@ bl config show ```bash bl config show --output json ``` + +### `bl config ui` + +| Field | Value | +| --------------- | --------------------------------------------- | +| **Name** | `config ui` | +| **Description** | Open a local web UI to manage config profiles | +| **Usage** | `bl config ui [--port ] [--no-open]` | + +#### Flags + +| Flag | Type | Required | Description | +| --------------- | ------ | -------- | --------------------------------------------- | +| `--port ` | number | no | Port to listen on (default: random free port) | +| `--no-open` | switch | no | Do not open the browser automatically | + +#### Examples + +```bash +bl config ui +``` + +```bash +bl config ui --port 8787 +``` + +```bash +bl config ui --config staging --no-open +``` diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index 6abdecf..5b9537c 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -20,6 +20,7 @@ Use this index for the full quick index and global flags. | `bl bootstrap` | Initialize Bailian workspace and activate postpaid services | [bootstrap.md](bootstrap.md) | | `bl config set` | Set a config value | [config.md](config.md) | | `bl config show` | Display current configuration | [config.md](config.md) | +| `bl config ui` | Open a local web UI to manage config profiles | [config.md](config.md) | | `bl console call` | Call a Bailian console API via the CLI gateway | [console.md](console.md) | | `bl dataset delete` | Delete a dataset file by ID | [dataset.md](dataset.md) | | `bl dataset get` | Get details of a single dataset file | [dataset.md](dataset.md) | @@ -98,7 +99,7 @@ Use this index for the full quick index and global flags. | `app` | `call`, `list` | [app.md](app.md) | | `auth` | `generate-access-token`, `login`, `logout`, `status` | [auth.md](auth.md) | | `bootstrap` | `(root)` | [bootstrap.md](bootstrap.md) | -| `config` | `set`, `show` | [config.md](config.md) | +| `config` | `set`, `show`, `ui` | [config.md](config.md) | | `console` | `call` | [console.md](console.md) | | `dataset` | `delete`, `get`, `list`, `upload`, `validate` | [dataset.md](dataset.md) | | `deploy` | `audio create`, `delete`, `get`, `image create`, `list`, `models`, `scale`, `text create`, `update` | [deploy.md](deploy.md) |