mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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 场景传 {} 即可。
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user