llm-api-gateway-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
package/lib/settings.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置持久化与校验(R2)
|
|
3
|
+
*
|
|
4
|
+
* 在这之前,「用户想改的东西」散在三处:命令行 flag、环境变量、以及浏览器 localStorage。
|
|
5
|
+
* 清一次缓存就丢,换台机器就没了,而且用户说不清「我到底改的是哪一个、为什么没生效」。
|
|
6
|
+
*
|
|
7
|
+
* 这个模块定三件事,且只此一份:
|
|
8
|
+
* 1. **唯一白名单** —— 哪些键能改、取值范围是什么;未知键明确报错并列出可用键;
|
|
9
|
+
* 2. **唯一优先级** —— `--flag` > 环境变量 > `config.json` > 内置默认(沿用 lib/config.js 的既有口径,不引入第二个真相);
|
|
10
|
+
* 3. **唯一实现** —— Web 的 `/api/settings` 与 CLI 的 `gateway-agent config` 共用它,
|
|
11
|
+
* 不会出现「页面能改、命令行改不了」或者两边校验规则不一致。
|
|
12
|
+
*
|
|
13
|
+
* 红线:**密钥永远不落盘**。这里没有 `key` 这个字段,并且会拒绝任何像密钥的键或值。
|
|
14
|
+
* 密钥来源仍然是 `lib/config.js` 的 resolveKey(--key > SK / GATEWAY_KEY / OPENAI_API_KEY / … > .env)。
|
|
15
|
+
*
|
|
16
|
+
* 默认值一律等于现状代码里的常量 —— 加配置不改变默认行为。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import { readFileSync, mkdirSync } from 'node:fs';
|
|
22
|
+
|
|
23
|
+
import { DEFAULT_BASE_URL } from './common.js';
|
|
24
|
+
import { MODES, AGENT_LIMITS } from './agent.js';
|
|
25
|
+
import { TTL_DAYS, MAX_TASKS, defaultStoreRoot } from './taskstore.js';
|
|
26
|
+
import { TASK_SESSION_MAX_BYTES, HISTORY_MAX_CHARS, HISTORY_KEEP_RECENT_TURNS } from './tasksession.js';
|
|
27
|
+
import { writeJsonAtomic } from './jsonstore.js';
|
|
28
|
+
|
|
29
|
+
export const SETTINGS_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
/** 配置文件路径的环境变量覆盖(便携安装、多环境并存、测试都要用) */
|
|
32
|
+
export const CONFIG_ENV = 'LLM_GATEWAY_CONFIG';
|
|
33
|
+
|
|
34
|
+
/** 配置文件放哪:数据根目录(`~/.llm-api-gateway-cli`)下的 config.json,可用 LLM_GATEWAY_CONFIG 改 */
|
|
35
|
+
export function settingsFilePath(env = process.env, home = os.homedir()) {
|
|
36
|
+
const explicit = String(env?.[CONFIG_ENV] ?? '').trim();
|
|
37
|
+
if (explicit) return path.resolve(explicit);
|
|
38
|
+
return path.join(defaultStoreRoot(env, home), 'config.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ---------- 类型校验 ---------- */
|
|
42
|
+
|
|
43
|
+
const isHttpUrl = (v) => /^https?:\/\/[^\s]+$/i.test(v);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 配置白名单。字段含义:
|
|
47
|
+
* type 取值类型(string / string? / path / path? / int / int? / num? / bool / enum)
|
|
48
|
+
* def 内置默认;函数表示「每次解析时才算」(如 lastWorkDir = 进程 cwd)
|
|
49
|
+
* env 对应的环境变量
|
|
50
|
+
* flag 对应的命令行选项(只用于展示与来源判定,解析仍在各 CLI 的 parseArgs)
|
|
51
|
+
* arg parseArgs 产物里的键名
|
|
52
|
+
* min/max 取值范围(int)
|
|
53
|
+
*/
|
|
54
|
+
export const SETTINGS_SCHEMA = [
|
|
55
|
+
{ key: 'model', type: 'string', def: 'qwen3:8b', env: 'GATEWAY_MODEL', flag: '-m/--model', arg: 'model', min: 1, max: 200, desc: '默认模型名' },
|
|
56
|
+
{
|
|
57
|
+
key: 'baseUrl',
|
|
58
|
+
type: 'string',
|
|
59
|
+
def: DEFAULT_BASE_URL,
|
|
60
|
+
env: 'GATEWAY_BASE_URL',
|
|
61
|
+
flag: '--base-url',
|
|
62
|
+
arg: 'base-url',
|
|
63
|
+
min: 1,
|
|
64
|
+
max: 500,
|
|
65
|
+
validate: (v) => (isHttpUrl(v) ? null : '需要 http:// 或 https:// 开头的地址'),
|
|
66
|
+
desc: '网关地址',
|
|
67
|
+
},
|
|
68
|
+
{ key: 'temperature', type: 'num?', def: null, flag: '--temperature', arg: 'temperature', min: 0, max: 2, desc: '采样温度(不设则由上游定)' },
|
|
69
|
+
{ key: 'maxTokens', type: 'int?', def: null, flag: '--max-tokens', arg: 'max-tokens', min: 1, max: 1000000, desc: '最大生成 token 数(不设则由上游定)' },
|
|
70
|
+
{ key: 'system', type: 'string', def: '', flag: '-s/--system', arg: 'system', max: 20000, desc: '附加系统提示词' },
|
|
71
|
+
{ key: 'mode', type: 'enum', values: MODES, def: 'manual', env: 'TASK_MODE', flag: '--mode', arg: 'mode', desc: '新任务的默认审批模式' },
|
|
72
|
+
{
|
|
73
|
+
key: 'maxSteps',
|
|
74
|
+
type: 'int',
|
|
75
|
+
def: AGENT_LIMITS.MAX_STEPS,
|
|
76
|
+
env: 'TASK_MAX_STEPS',
|
|
77
|
+
flag: '--max-steps',
|
|
78
|
+
arg: 'max-steps',
|
|
79
|
+
min: 1,
|
|
80
|
+
max: 1000,
|
|
81
|
+
desc: '单个任务最多几轮模型调用',
|
|
82
|
+
},
|
|
83
|
+
{ key: 'lastWorkDir', type: 'string', def: () => process.cwd(), flag: '-C/--cwd', arg: 'cwd', max: 4096, desc: '下次启动的默认工作目录' },
|
|
84
|
+
{ key: 'store.dir', type: 'path?', def: null, env: 'TASK_STORE_DIR', flag: '--store', arg: 'store', desc: '任务存储目录', restart: true },
|
|
85
|
+
{ key: 'store.sessionDir', type: 'path?', def: null, flag: '--session-dir', arg: 'session-dir', desc: 'CLI 会话落盘目录', restart: true },
|
|
86
|
+
{ key: 'retention.days', type: 'int', def: TTL_DAYS, min: 1, max: 3650, desc: '任务保留天数(滑动 TTL)', restart: true },
|
|
87
|
+
{ key: 'retention.maxTasks', type: 'int', def: MAX_TASKS, min: 1, max: 10000, desc: '最多保留多少条任务', restart: true },
|
|
88
|
+
{
|
|
89
|
+
key: 'session.maxBytes',
|
|
90
|
+
type: 'int',
|
|
91
|
+
def: TASK_SESSION_MAX_BYTES,
|
|
92
|
+
env: 'TASK_SESSION_MAX_BYTES',
|
|
93
|
+
min: 64 * 1024,
|
|
94
|
+
max: 512 * 1024 * 1024,
|
|
95
|
+
desc: '单个任务会话文件的落盘硬闸(字节)',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
key: 'history.maxChars',
|
|
99
|
+
type: 'int',
|
|
100
|
+
def: HISTORY_MAX_CHARS,
|
|
101
|
+
env: 'TASK_HISTORY_MAX_CHARS',
|
|
102
|
+
min: 1000,
|
|
103
|
+
max: 2000000,
|
|
104
|
+
desc: '发给模型的工具历史预算(字符)',
|
|
105
|
+
},
|
|
106
|
+
{ key: 'history.keepRecentTurns', type: 'int', def: HISTORY_KEEP_RECENT_TURNS, min: 0, max: 50, desc: '保全文的最近轮数,永不压缩' },
|
|
107
|
+
{ key: 'history.readFileDigest', type: 'bool', def: true, desc: '是否注入「已读文件清单」' },
|
|
108
|
+
{
|
|
109
|
+
key: 'ui.persist',
|
|
110
|
+
type: 'enum',
|
|
111
|
+
values: ['server', 'browser'],
|
|
112
|
+
def: 'server',
|
|
113
|
+
desc: '界面偏好存哪儿:server(清缓存不丢)/ browser(每个浏览器各自)',
|
|
114
|
+
},
|
|
115
|
+
{ key: 'compat.legacyTaskApi', type: 'bool', def: true, desc: '是否继续接受旧的无状态 /api/task(已废弃)' },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 界面状态(不是「偏好」,是页面自己的记忆)。
|
|
120
|
+
* 允许读写,但**不由 `config` 命令管理** —— 用户手改 activeId 没有意义。
|
|
121
|
+
*/
|
|
122
|
+
export const UI_STATE_SCHEMA = [
|
|
123
|
+
{ key: 'ui.showReasoning', type: 'bool', def: true, desc: '是否显示思维链' },
|
|
124
|
+
{ key: 'ui.activeId', type: 'id?', def: null, desc: '最后打开的任务 id' },
|
|
125
|
+
{ key: 'ui.collapsedDirs', type: 'strList', def: [], max: 500, desc: '侧边栏折叠的工作目录' },
|
|
126
|
+
{ key: 'ui.collapsedTasks', type: 'strList', def: [], max: 2000, desc: '侧边栏折叠的子任务' },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const BY_KEY = new Map([...SETTINGS_SCHEMA, ...UI_STATE_SCHEMA].map((e) => [e.key, e]));
|
|
130
|
+
const SETTING_KEYS = SETTINGS_SCHEMA.map((e) => e.key);
|
|
131
|
+
const UI_KEYS = new Set(UI_STATE_SCHEMA.map((e) => e.key));
|
|
132
|
+
const SECRET_KEY_RE = /(^|[._-])(api[_-]?key|key|token|secret|password|passwd|credential|auth)([._-]|$)/;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 键名归一化后再判密钥:`authToken` 这种驼峰要有边界,
|
|
136
|
+
* 但又不能把 `maxTokens` 误判成密钥 —— 所以先把驼峰拆成下划线再按词边界匹配。
|
|
137
|
+
*/
|
|
138
|
+
const normalizeKeyName = (key) => String(key).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
139
|
+
/** 值也要看:`config set system sk-xxxx` 同样是在把密钥写进磁盘 */
|
|
140
|
+
const SECRET_VALUE_RE = /sk-[A-Za-z0-9_-]{16,}/;
|
|
141
|
+
|
|
142
|
+
/** 这个键名看起来像密钥吗(config set key … 会被它拦下) */
|
|
143
|
+
export function isSecretKey(key) {
|
|
144
|
+
return SECRET_KEY_RE.test(normalizeKeyName(key));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 是「用户可配」的键吗(界面状态不算:它由页面维护,config 命令不该碰) */
|
|
148
|
+
export function isSettingKey(key) {
|
|
149
|
+
return SETTING_KEYS.includes(key);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 是界面状态键吗(PUT /api/settings 收,`config set` 不收) */
|
|
153
|
+
export function isUiStateKey(key) {
|
|
154
|
+
return UI_KEYS.has(key);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 拼一份「可用键」清单,用于报错时告诉用户到底能写什么 */
|
|
158
|
+
export function availableKeysText() {
|
|
159
|
+
return SETTINGS_SCHEMA.map((e) => e.key).join('、');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 按点号路径取值/赋值/删除('session.maxBytes' → { session: { maxBytes } }) */
|
|
163
|
+
export function getByPath(obj, key) {
|
|
164
|
+
let cur = obj;
|
|
165
|
+
for (const part of String(key).split('.')) {
|
|
166
|
+
if (cur === null || typeof cur !== 'object') return undefined;
|
|
167
|
+
cur = cur[part];
|
|
168
|
+
}
|
|
169
|
+
return cur;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function setByPath(obj, key, value) {
|
|
173
|
+
const parts = String(key).split('.');
|
|
174
|
+
let cur = obj;
|
|
175
|
+
for (const part of parts.slice(0, -1)) {
|
|
176
|
+
if (cur[part] === null || typeof cur[part] !== 'object' || Array.isArray(cur[part])) cur[part] = {};
|
|
177
|
+
cur = cur[part];
|
|
178
|
+
}
|
|
179
|
+
cur[parts[parts.length - 1]] = value;
|
|
180
|
+
return obj;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function delByPath(obj, key) {
|
|
184
|
+
const parts = String(key).split('.');
|
|
185
|
+
const chain = [];
|
|
186
|
+
let cur = obj;
|
|
187
|
+
for (const part of parts.slice(0, -1)) {
|
|
188
|
+
if (cur === null || typeof cur !== 'object') return false;
|
|
189
|
+
chain.push([cur, part]);
|
|
190
|
+
cur = cur[part];
|
|
191
|
+
}
|
|
192
|
+
if (cur === null || typeof cur !== 'object') return false;
|
|
193
|
+
const last = parts[parts.length - 1];
|
|
194
|
+
if (!(last in cur)) return false;
|
|
195
|
+
delete cur[last];
|
|
196
|
+
// 顺手把因此变空的父对象也删掉:否则文件里会留下一串 "history": {},
|
|
197
|
+
// 用户手看配置文件时容易以为那里还有东西
|
|
198
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
199
|
+
const [parent, name] = chain[i];
|
|
200
|
+
const child = parent[name];
|
|
201
|
+
if (child && typeof child === 'object' && !Array.isArray(child) && Object.keys(child).length === 0) delete parent[name];
|
|
202
|
+
else break;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 内置默认值(函数形式的现算) */
|
|
208
|
+
export function defaultValueOf(entry) {
|
|
209
|
+
return typeof entry.def === 'function' ? entry.def() : entry.def;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const NULLABLE = new Set(['string?', 'int?', 'num?', 'path?', 'id?']);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 把「一个键 + 原始值」转成落盘值,顺便做范围与形状校验。
|
|
216
|
+
* raw 可能是 CLI 传来的字符串,也可能是 JSON 里的数字/布尔/数组。
|
|
217
|
+
*
|
|
218
|
+
* @returns {{ok:true, value:*}|{ok:false, error:string}}
|
|
219
|
+
*/
|
|
220
|
+
export function parseSettingValue(key, raw) {
|
|
221
|
+
if (isSecretKey(key)) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
error: `「${key}」看起来是密钥类配置:密钥不写进 config.json(它有单独的 credentials.json,仅本用户可读)。请用 Web 界面「设置」或 gateway-agent config set key sk-xxx,也可以用 --key、环境变量 SK / GATEWAY_KEY / OPENAI_API_KEY,或 .env 文件。`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const entry = BY_KEY.get(key);
|
|
228
|
+
if (!entry) return { ok: false, error: unknownKeyError(key) };
|
|
229
|
+
|
|
230
|
+
const type = entry.type;
|
|
231
|
+
const nullable = NULLABLE.has(type);
|
|
232
|
+
const isStringy = type === 'string' || type === 'string?' || type === 'path' || type === 'path?' || type === 'id?';
|
|
233
|
+
|
|
234
|
+
// 空 / null 的统一处理(string 类型上 min 表示「最短长度」,给了 min 就不许空)
|
|
235
|
+
if (raw === null || raw === undefined || (typeof raw === 'string' && raw.trim() === '')) {
|
|
236
|
+
if (nullable) return { ok: true, value: type === 'strList' ? [] : null };
|
|
237
|
+
if (type === 'strList') return { ok: true, value: [] };
|
|
238
|
+
if (type === 'string' && !entry.min) return { ok: true, value: '' };
|
|
239
|
+
return { ok: false, error: `${key} 不能为空(内置默认是 ${JSON.stringify(defaultValueOf(entry))})` };
|
|
240
|
+
}
|
|
241
|
+
if (typeof raw === 'string' && /^(null|none)$/i.test(raw.trim()) && nullable) return { ok: true, value: null };
|
|
242
|
+
|
|
243
|
+
if (isStringy) {
|
|
244
|
+
const v = String(raw).trim();
|
|
245
|
+
if (entry.max && v.length > entry.max) return { ok: false, error: `${key} 太长(${v.length} 字符,上限 ${entry.max})` };
|
|
246
|
+
if (entry.min && v.length < entry.min) return { ok: false, error: `${key} 太短(${v.length} 字符,至少 ${entry.min})` };
|
|
247
|
+
if (SECRET_VALUE_RE.test(v)) return { ok: false, error: `${key} 的取值看起来是 sk- 密钥 —— 密钥不写进 config.json,请用 Web 界面「设置」或 gateway-agent config set key。` };
|
|
248
|
+
if (type === 'id?' && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v)) {
|
|
249
|
+
return { ok: false, error: `${key} 需要是 UUID,收到 ${v}` };
|
|
250
|
+
}
|
|
251
|
+
const bad = entry.validate ? entry.validate(v) : null;
|
|
252
|
+
if (bad) return { ok: false, error: `${key} 取值非法:${bad}(收到 ${v})` };
|
|
253
|
+
return { ok: true, value: v };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (type === 'bool') {
|
|
257
|
+
if (typeof raw === 'boolean') return { ok: true, value: raw };
|
|
258
|
+
const s = String(raw).trim().toLowerCase();
|
|
259
|
+
if (['true', '1', 'yes', 'on'].includes(s)) return { ok: true, value: true };
|
|
260
|
+
if (['false', '0', 'no', 'off'].includes(s)) return { ok: true, value: false };
|
|
261
|
+
return { ok: false, error: `${key} 只接受 true / false,收到 ${raw}` };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (type === 'int' || type === 'int?' || type === 'num?') {
|
|
265
|
+
const n = Number(typeof raw === 'string' ? raw.trim() : raw);
|
|
266
|
+
if (!Number.isFinite(n)) return { ok: false, error: `${key} 需要数字,收到 ${raw}` };
|
|
267
|
+
if (type !== 'num?' && !Number.isInteger(n)) return { ok: false, error: `${key} 需要整数,收到 ${raw}` };
|
|
268
|
+
if (entry.min !== undefined && n < entry.min) return { ok: false, error: `${key} 不能小于 ${entry.min}(收到 ${n})` };
|
|
269
|
+
if (entry.max !== undefined && n > entry.max) return { ok: false, error: `${key} 不能大于 ${entry.max}(收到 ${n})` };
|
|
270
|
+
return { ok: true, value: n };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (type === 'enum') {
|
|
274
|
+
const v = String(raw).trim();
|
|
275
|
+
if (!entry.values.includes(v)) return { ok: false, error: `${key} 只能是 ${entry.values.join(' / ')},收到 ${v}` };
|
|
276
|
+
return { ok: true, value: v };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (type === 'strList') {
|
|
280
|
+
const arr = Array.isArray(raw) ? raw : String(raw).split(',');
|
|
281
|
+
const out = arr.map((x) => String(x).trim()).filter(Boolean);
|
|
282
|
+
if (out.length > (entry.max || 500)) return { ok: false, error: `${key} 最多 ${entry.max} 项,收到 ${out.length} 项` };
|
|
283
|
+
return { ok: true, value: out };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { ok: false, error: `内部错误:${key} 的类型 ${type} 没有处理分支` };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 编辑距离:用来给拼错的键名提个「你是不是想写 X」。超过 max 就提前返回 */
|
|
290
|
+
function editDistance(a, b, max = 3) {
|
|
291
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
292
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
293
|
+
for (let i = 1; i <= a.length; i++) {
|
|
294
|
+
const cur = [i];
|
|
295
|
+
for (let j = 1; j <= b.length; j++) {
|
|
296
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
297
|
+
}
|
|
298
|
+
if (Math.min(...cur) > max) return max + 1;
|
|
299
|
+
prev = cur;
|
|
300
|
+
}
|
|
301
|
+
return prev[b.length];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 未知键的报错:除了列清单,还尽量给个「你是不是想写 X」 */
|
|
305
|
+
export function unknownKeyError(key) {
|
|
306
|
+
const want = normalizeKeyName(key);
|
|
307
|
+
const scored = SETTINGS_SCHEMA.map((e) => {
|
|
308
|
+
const full = normalizeKeyName(e.key);
|
|
309
|
+
const wantLeaf = want.split('.').pop();
|
|
310
|
+
const leaf = full.split('.').pop();
|
|
311
|
+
// 整串像、或者「前缀一致 + 叶子名像」都算 —— 后者才能认出 session.mxBytes → session.maxBytes
|
|
312
|
+
const sameParent = want.split('.').slice(0, -1).join('.') === full.split('.').slice(0, -1).join('.');
|
|
313
|
+
const d = Math.min(editDistance(want, full, 3), sameParent ? editDistance(wantLeaf, leaf, 3) : 9);
|
|
314
|
+
return { key: e.key, d };
|
|
315
|
+
})
|
|
316
|
+
.filter((x) => x.d <= 2)
|
|
317
|
+
.sort((a, b) => a.d - b.d)
|
|
318
|
+
.slice(0, 3);
|
|
319
|
+
|
|
320
|
+
const hint = scored.length ? `你是不是想写:${scored.map((x) => x.key).join('、')}?` : '';
|
|
321
|
+
return `未知配置项「${key}」。可用配置项:${availableKeysText()}。${hint}`.trim();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/* ---------- 读 ---------- */
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 读配置文件。**坏文件不抛异常**:用户手改错了不该让服务起不来,
|
|
328
|
+
* 返回 error 让调用方打一条警告然后照常跑。
|
|
329
|
+
*/
|
|
330
|
+
export function readSettingsFile(file = settingsFilePath()) {
|
|
331
|
+
let raw;
|
|
332
|
+
try {
|
|
333
|
+
raw = readFileSync(file, 'utf8');
|
|
334
|
+
} catch (e) {
|
|
335
|
+
if (e.code === 'ENOENT') return { file, exists: false, data: {}, error: null };
|
|
336
|
+
return { file, exists: false, data: {}, error: `读不到配置文件 ${file}:${e.message}` };
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
const data = JSON.parse(raw);
|
|
340
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('顶层必须是对象');
|
|
341
|
+
return { file, exists: true, data, error: null };
|
|
342
|
+
} catch (e) {
|
|
343
|
+
return {
|
|
344
|
+
file,
|
|
345
|
+
exists: true,
|
|
346
|
+
data: {},
|
|
347
|
+
error: `配置文件不是合法 JSON(${file}):${e.message}。已忽略它并沿用默认值,修好后重启即可。`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* 按 `--flag > 环境变量 > config.json > 默认` 解析出最终值与**每一项的来源**。
|
|
354
|
+
* 来源是关键:用户改了没生效时,一眼看出是被环境变量还是 flag 覆盖了。
|
|
355
|
+
*
|
|
356
|
+
* @returns {{values:object, sources:object, warnings:string[], file:string, exists:boolean}}
|
|
357
|
+
*/
|
|
358
|
+
export function resolveSettings({ file = settingsFilePath(), env = process.env, args = null } = {}) {
|
|
359
|
+
const read = readSettingsFile(file);
|
|
360
|
+
const values = {};
|
|
361
|
+
const sources = {};
|
|
362
|
+
const warnings = [];
|
|
363
|
+
if (read.error) warnings.push(read.error);
|
|
364
|
+
|
|
365
|
+
for (const entry of SETTINGS_SCHEMA) {
|
|
366
|
+
const flagRaw = args && entry.arg ? args[entry.arg] : undefined;
|
|
367
|
+
const envRaw = entry.env ? env?.[entry.env] : undefined;
|
|
368
|
+
const fileRaw = read.data ? getByPath(read.data, entry.key) : undefined;
|
|
369
|
+
|
|
370
|
+
let raw;
|
|
371
|
+
let source;
|
|
372
|
+
if (flagRaw !== undefined && flagRaw !== true && String(flagRaw).trim() !== '') {
|
|
373
|
+
raw = flagRaw;
|
|
374
|
+
source = 'flag';
|
|
375
|
+
} else if (envRaw !== undefined && String(envRaw).trim() !== '') {
|
|
376
|
+
raw = envRaw;
|
|
377
|
+
source = 'env';
|
|
378
|
+
} else if (fileRaw !== undefined) {
|
|
379
|
+
raw = fileRaw;
|
|
380
|
+
source = 'file';
|
|
381
|
+
} else {
|
|
382
|
+
raw = defaultValueOf(entry);
|
|
383
|
+
source = 'default';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const parsed = parseSettingValue(entry.key, raw);
|
|
387
|
+
if (parsed.ok) {
|
|
388
|
+
setByPath(values, entry.key, parsed.value);
|
|
389
|
+
sources[entry.key] = source;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
// 坏值降级到默认:宁可用默认值跑起来,也不要因为一个手滑的字符让整个服务起不来
|
|
393
|
+
const fallback = defaultValueOf(entry);
|
|
394
|
+
warnings.push(`${entry.key} 的取值非法(${JSON.stringify(raw)}):${parsed.error} 已改用默认值 ${JSON.stringify(fallback)}。`);
|
|
395
|
+
setByPath(values, entry.key, fallback);
|
|
396
|
+
sources[entry.key] = 'default';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { values, sources, warnings, file: read.file, exists: read.exists };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* `config list` 与 Web 设置面板共用的行:键、当前值、来源、默认、说明,
|
|
404
|
+
* 外加**渲染所需的类型信息**(type / 枚举取值 / 范围)。
|
|
405
|
+
*
|
|
406
|
+
* 类型必须带上:页面要按类型决定给复选框、下拉还是数字框,而它不该再抄一份白名单
|
|
407
|
+
* (见 lib/hub.js 的 settingsPayload 注释)。少这几个字段,页面就只能靠猜值类型,
|
|
408
|
+
* 枚举与字符串根本分不出来。
|
|
409
|
+
*/
|
|
410
|
+
export function listSettings(resolved) {
|
|
411
|
+
return SETTINGS_SCHEMA.map((entry) => ({
|
|
412
|
+
key: entry.key,
|
|
413
|
+
value: getByPath(resolved.values, entry.key),
|
|
414
|
+
source: resolved.sources[entry.key],
|
|
415
|
+
def: defaultValueOf(entry),
|
|
416
|
+
env: entry.env || '',
|
|
417
|
+
flag: entry.flag || '',
|
|
418
|
+
desc: entry.desc,
|
|
419
|
+
restart: Boolean(entry.restart),
|
|
420
|
+
// ↓ 渲染用:type 决定控件形状,values 是枚举候选,min/max 是数字范围(string 的 min 是长度)
|
|
421
|
+
type: entry.type,
|
|
422
|
+
values: entry.values || null,
|
|
423
|
+
min: entry.min ?? null,
|
|
424
|
+
max: entry.max ?? null,
|
|
425
|
+
}));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* ---------- 写 ---------- */
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* 写入若干项(键为点号路径)。**原子写**(tmp → rename)。
|
|
432
|
+
*
|
|
433
|
+
* 文件已损坏时**拒绝覆盖**:那份内容再错也是用户手写的,直接盖掉等于替他删了东西。
|
|
434
|
+
* 报错里给出路径,让他自己修或删。
|
|
435
|
+
*
|
|
436
|
+
* `allowUiState` 只给 Web 侧开:界面状态(activeId / 折叠项)本来就是页面在写;
|
|
437
|
+
* CLI 的 `config` 命令不该让用户手改这些东西。
|
|
438
|
+
*/
|
|
439
|
+
export function writeSettings(changes, { file = settingsFilePath(), allowUiState = false } = {}) {
|
|
440
|
+
const read = readSettingsFile(file);
|
|
441
|
+
if (read.error) {
|
|
442
|
+
throw new Error(`${read.error}\n请先修好或删除该文件再执行(不会自动覆盖你的内容)。`);
|
|
443
|
+
}
|
|
444
|
+
const data = read.exists ? JSON.parse(JSON.stringify(read.data)) : {};
|
|
445
|
+
const applied = [];
|
|
446
|
+
const errors = [];
|
|
447
|
+
|
|
448
|
+
// 先全部校验再落盘:一半成功一半失败最难排查
|
|
449
|
+
const parsedList = [];
|
|
450
|
+
for (const [key, raw] of Object.entries(changes || {})) {
|
|
451
|
+
if (!allowUiState && isUiStateKey(key)) {
|
|
452
|
+
errors.push(`${key} 是界面状态(页面自己维护的),不由 config 命令管理。`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const parsed = parseSettingValue(key, raw);
|
|
456
|
+
if (!parsed.ok) errors.push(parsed.error);
|
|
457
|
+
else parsedList.push([key, parsed.value]);
|
|
458
|
+
}
|
|
459
|
+
if (errors.length) {
|
|
460
|
+
const err = new Error(errors.join('\n'));
|
|
461
|
+
err.validation = true;
|
|
462
|
+
throw err;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
for (const [key, value] of parsedList) {
|
|
466
|
+
setByPath(data, key, value);
|
|
467
|
+
applied.push(key);
|
|
468
|
+
}
|
|
469
|
+
data.version = SETTINGS_VERSION;
|
|
470
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
471
|
+
writeJsonAtomic(file, data, { pretty: true });
|
|
472
|
+
return { file, data, applied };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** 删掉若干项(回到「用默认值」) */
|
|
476
|
+
export function unsetSettings(keys, { file = settingsFilePath(), allowUiState = false } = {}) {
|
|
477
|
+
const list = Array.isArray(keys) ? keys : [keys];
|
|
478
|
+
const read = readSettingsFile(file);
|
|
479
|
+
if (read.error) throw new Error(`${read.error}\n请先修好或删除该文件再执行。`);
|
|
480
|
+
const data = read.exists ? JSON.parse(JSON.stringify(read.data)) : {};
|
|
481
|
+
const removed = [];
|
|
482
|
+
const errors = [];
|
|
483
|
+
for (const key of list) {
|
|
484
|
+
if (isSecretKey(key)) {
|
|
485
|
+
errors.push(`「${key}」不在配置文件里(密钥本来就不落盘),无需删除。`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (!allowUiState && isUiStateKey(key)) {
|
|
489
|
+
errors.push(`${key} 是界面状态(页面自己维护的),不由 config 命令管理。`);
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (!BY_KEY.has(key)) {
|
|
493
|
+
errors.push(unknownKeyError(key));
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (delByPath(data, key)) removed.push(key);
|
|
497
|
+
}
|
|
498
|
+
if (errors.length) {
|
|
499
|
+
const err = new Error(errors.join('\n'));
|
|
500
|
+
err.validation = true;
|
|
501
|
+
throw err;
|
|
502
|
+
}
|
|
503
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
504
|
+
writeJsonAtomic(file, data, { pretty: true });
|
|
505
|
+
return { file, data, removed };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 一次 PUT 里哪些键改了要重启才生效(store.dir / retention.* 这类在启动时就被读走了)。
|
|
510
|
+
* 明说比让用户反复试要省事。
|
|
511
|
+
*/
|
|
512
|
+
export function restartKeysIn(appliedKeys) {
|
|
513
|
+
return appliedKeys.filter((k) => {
|
|
514
|
+
const entry = BY_KEY.get(k);
|
|
515
|
+
return Boolean(entry?.restart);
|
|
516
|
+
});
|
|
517
|
+
}
|