feat(config): add "config ui" local web UI to manage config profiles

启动绑定 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 决定初始聚焦,密钥明文可编辑
This commit is contained in:
lisheng.lisheng
2026-07-13 14:20:32 +08:00
parent ac4dbb9e88
commit e0f3d450ae
16 changed files with 833 additions and 111 deletions
+2
View File
@@ -16,6 +16,7 @@ import {
visionDescribe,
configShow,
configSet,
configUi,
update,
appCall,
appList,
@@ -103,6 +104,7 @@ export const commands: Record<string, AnyCommand> = {
"vision describe": visionDescribe,
"config show": configShow,
"config set": configSet,
"config ui": configUi,
update,
"app call": appCall,
"app list": appList,
@@ -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<number> {
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<void> {
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;
+6 -75
View File
@@ -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<string, string> = {
"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: "<key>",
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: "<value>", 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<ConfigFile>);
if (!settings.quiet) {
@@ -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<string>([
"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<string, string> = {
"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;
}
@@ -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 = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>bailian-cli config</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 -apple-system, Segoe UI, Roboto, sans-serif; color: #1f2328; background: #f6f8fa; }
#app { display: flex; min-height: 100vh; }
#sidebar { width: 240px; background: #fff; border-right: 1px solid #d0d7de; padding: 16px; }
#sidebar h1 { font-size: 15px; margin: 0 0 12px; }
#profileList { list-style: none; margin: 0 0 12px; padding: 0; }
#profileList li { padding: 8px 10px; border-radius: 6px; cursor: pointer; word-break: break-all; }
#profileList li:hover { background: #f0f3f6; }
#profileList li.active { background: #0969da; color: #fff; }
main { flex: 1; padding: 24px 32px; max-width: 720px; }
#editorHead { display: flex; align-items: center; justify-content: space-between; }
h2 { font-size: 18px; margin: 0 0 4px; }
.row { display: flex; flex-direction: column; margin: 12px 0; }
.row label { font-weight: 600; margin-bottom: 4px; }
.inputwrap { display: flex; gap: 6px; }
input { flex: 1; padding: 7px 9px; border: 1px solid #d0d7de; border-radius: 6px; font: inherit; width: 100%; }
button { padding: 7px 12px; border: 1px solid #d0d7de; border-radius: 6px; background: #f6f8fa; cursor: pointer; font: inherit; }
button:hover { background: #eef1f4; }
#saveBtn { background: #1f883d; color: #fff; border-color: #1f883d; }
#saveBtn:hover { background: #1a7f37; }
.danger { color: #cf222e; }
.toggle { flex: none; }
.actions { margin-top: 20px; display: flex; align-items: center; gap: 12px; }
.muted { color: #656d76; font-size: 12px; word-break: break-all; }
.err { color: #cf222e; font-size: 12px; }
</style>
</head>
<body>
<div id="app">
<aside id="sidebar">
<h1>Config Profiles</h1>
<ul id="profileList"></ul>
<button id="newBtn">+ New profile</button>
<p id="cfgFile" class="muted"></p>
</aside>
<main id="editor">
<div id="editorHead">
<h2 id="currentName"></h2>
<button id="deleteBtn" class="danger">Delete</button>
</div>
<form id="form" onsubmit="return false"></form>
<div class="actions">
<button id="saveBtn">Save</button>
<span id="status" class="muted"></span>
</div>
</main>
</div>
<script>
var token = new URLSearchParams(location.search).get('token') || '';
var KEYS = [], SECRETS = [], DATA = { default: {}, named: {} }, CURRENT = '';
function api(path, opts) {
var sep = path.indexOf('?') >= 0 ? '&' : '?';
return fetch(path + sep + 'token=' + encodeURIComponent(token), opts || {});
}
function setStatus(msg, isErr) {
var el = document.getElementById('status');
el.textContent = msg || '';
el.className = isErr ? 'err' : 'muted';
}
function profileData(name) {
return name === '' ? DATA.default : (DATA.named[name] || {});
}
function load() {
api('/api/config').then(function (r) { return r.json(); }).then(function (j) {
KEYS = j.keys || [];
SECRETS = j.secretKeys || [];
DATA = { default: j.default || {}, named: j.named || {} };
document.getElementById('cfgFile').textContent = j.configFile || '';
CURRENT = (j.activeProfile && DATA.named[j.activeProfile]) ? j.activeProfile : '';
renderProfiles();
renderForm();
}).catch(function (e) { setStatus('Load failed: ' + e, true); });
}
function renderProfiles() {
var ul = document.getElementById('profileList');
ul.innerHTML = '';
var names = [''].concat(Object.keys(DATA.named));
names.forEach(function (name) {
var li = document.createElement('li');
li.textContent = name === '' ? 'default' : name;
if (name === CURRENT) li.className = 'active';
li.onclick = function () { CURRENT = name; renderProfiles(); renderForm(); setStatus(''); };
ul.appendChild(li);
});
}
function renderForm() {
var form = document.getElementById('form');
form.innerHTML = '';
document.getElementById('currentName').textContent = CURRENT === '' ? 'default (top-level)' : CURRENT;
document.getElementById('deleteBtn').style.display = CURRENT === '' ? 'none' : '';
var data = profileData(CURRENT);
KEYS.forEach(function (key) {
var row = document.createElement('div');
row.className = 'row';
var label = document.createElement('label');
label.textContent = key;
label.htmlFor = 'f_' + key;
var input = document.createElement('input');
input.id = 'f_' + key;
input.name = key;
var val = data[key];
input.value = (val === undefined || val === null) ? '' : String(val);
if (SECRETS.indexOf(key) >= 0) {
input.type = 'password';
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'toggle';
toggle.textContent = 'show';
toggle.onclick = function () {
if (input.type === 'password') { input.type = 'text'; toggle.textContent = 'hide'; }
else { input.type = 'password'; toggle.textContent = 'show'; }
};
var wrap = document.createElement('div');
wrap.className = 'inputwrap';
wrap.appendChild(input);
wrap.appendChild(toggle);
row.appendChild(label);
row.appendChild(wrap);
} else {
input.type = 'text';
row.appendChild(label);
row.appendChild(input);
}
form.appendChild(row);
});
}
function collect() {
var data = {};
KEYS.forEach(function (key) {
var el = document.getElementById('f_' + key);
data[key] = el ? el.value : '';
});
return data;
}
function save() {
var name = CURRENT;
var body = JSON.stringify({ name: name, data: collect() });
api('/api/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body })
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
if (!res.ok) { setStatus('Save failed: ' + ((res.j && res.j.error) || 'error'), true); return; }
var saved = res.j.saved || {};
if (name === '') DATA.default = saved; else DATA.named[name] = saved;
renderForm();
setStatus('Saved.');
})
.catch(function (e) { setStatus('Save failed: ' + e, true); });
}
function newProfile() {
var name = prompt('New profile name (letters, numbers, - or _):');
if (!name) return;
if (DATA.named[name] === undefined) DATA.named[name] = {};
CURRENT = name;
renderProfiles();
renderForm();
setStatus('Unsaved new profile. Fill fields and Save.');
}
function deleteProfile() {
if (CURRENT === '') return;
if (!confirm('Delete profile "' + CURRENT + '"?')) return;
api('/api/profile?name=' + encodeURIComponent(CURRENT), { method: 'DELETE' })
.then(function (r) {
if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || 'error'); });
return r.json();
})
.then(function () {
delete DATA.named[CURRENT];
CURRENT = '';
renderProfiles();
renderForm();
setStatus('Deleted.');
})
.catch(function (e) { setStatus('Delete failed: ' + e, true); });
}
document.getElementById('saveBtn').onclick = save;
document.getElementById('newBtn').onclick = newProfile;
document.getElementById('deleteBtn').onclick = deleteProfile;
load();
</script>
</body>
</html>
`;
+237
View File
@@ -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: "<port>",
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<string> {
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<string, unknown>): Record<string, string | number> {
const cleaned: Record<string, string | number> = {};
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<string, string | number>;
try {
normalized = normalizeConfigName(body.name);
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
} 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 <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<void>((resolve) => {
const shutdown = () => server.close(() => resolve());
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
server.once("close", () => resolve());
});
},
});
@@ -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<number> {
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<void> {
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();
});
});
}
+1
View File
@@ -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";
+134
View File
@@ -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<string, string> },
): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const payload = opts?.body !== undefined ? JSON.stringify(opts.body) : undefined;
const headers: Record<string, string> = { ...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<void>,
): Promise<void> {
const saved = process.env.BAILIAN_CONFIG_DIR;
const dir = mkdtempSync(join(tmpdir(), "bl-ui-"));
process.env.BAILIAN_CONFIG_DIR = dir;
const server = createConfigUiServer(TOKEN, activeProfile);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const addr = server.address();
const port = addr && typeof addr === "object" ? addr.port : 0;
try {
await fn(port);
} finally {
await new Promise<void>((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);
});
});
@@ -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",
@@ -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 = {
+1
View File
@@ -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";
+37
View File
@@ -73,6 +73,10 @@ export async function writeConfigFile(
}
Object.assign(raw, data);
}
await writeRawConfigObject(raw);
}
async function writeRawConfigObject(raw: Record<string, unknown>): Promise<void> {
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<string, ConfigFile>;
}
/**
* 读取全部 profile:顶层默认配置与各命名 block。
* 命名 block = raw 中不属于 `CONFIG_FILE_KEYS`、且值为普通对象的项。
*/
export function readConfigProfiles(): ConfigProfiles {
const raw = readRawConfigObject();
const named: Record<string, ConfigFile> = {};
for (const [key, value] of Object.entries(raw)) {
if ((CONFIG_FILE_KEYS as readonly string[]).includes(key)) continue;
if (value && typeof value === "object" && !Array.isArray(value)) {
named[key] = parseConfigFile(value);
}
}
return { default: parseConfigFile(raw), named };
}
/** 删除一个命名 profile block;存在才删并回写,返回是否有变更。 */
export async function deleteConfigProfile(name: string): Promise<boolean> {
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 场景传 {} 即可。
+24
View File
@@ -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" });
+38 -8
View File
@@ -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 <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 <value>` | string | yes | Value to set |
| Flag | Type | Required | Description |
| ----------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--key <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 <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 <port>] [--no-open]` |
#### Flags
| Flag | Type | Required | Description |
| --------------- | ------ | -------- | --------------------------------------------- |
| `--port <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
```
+2 -1
View File
@@ -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) |