@zhin.js/adapter 1.1.0 → 1.1.2

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.
@@ -0,0 +1,559 @@
1
+ /**
2
+ * createEndpointCommands — 适配器 endpoint 管理命令套件(list / add / remove)。
3
+ *
4
+ * 把 QQ 适配器独有的 `qq endpoint` 命令族泛化为任意适配器可复用的套件:
5
+ *
6
+ * - `<adapter> endpoint list`:运行中的 endpoints(adapter create 注册的 runtime state)
7
+ * + zhin.config.yml 配置里的 `plugins.<adapterKey>.endpoints`。
8
+ * - `<adapter> endpoint add <name> <key=value...>`:手动录入字段,
9
+ * 凭据类字段(env: true)值写入 .env(`<ADAPTER>_<NAME>_<FIELD>` 大写键),
10
+ * yaml 中保存 `${REF}` 引用;其余字段内联写入。yaml 用 Document 节点级操作保留注释。
11
+ * - `<adapter> endpoint remove <name>`:从 `plugins.<adapterKey>.endpoints` 移除(重启生效)。
12
+ * - 权限:实例 config 声明了 master(顶层或 endpoints[i])时仅 master 可用 add/remove,
13
+ * 未配置放行(isEndpointOperator)。
14
+ * - 特殊 add 流程(如 QQ 扫码绑定)经 spec.bindFlow 钩子接管 add 命令。
15
+ *
16
+ * 接入步骤(以 telegram 为例):
17
+ * 1. plugin.ts setup 里 `context.resources.provide(telegramRuntimeStateToken, createEndpointRuntimeState())`,
18
+ * token 由 `defineEndpointRuntimeStateToken('telegram')` 创建。
19
+ * 2. adapters/telegram.ts create() 里 `context.use(token).endpoints.set(config.name, { name, mode })`。
20
+ * 3. src 下 `export const telegramEndpointCommands = createEndpointCommands({ adapterKey: 'telegram', ... }, defineCommand)`
21
+ * (defineCommand 由调用方从 @zhin.js/command 传入——provider 包之间禁止互相 import,
22
+ * 见 scripts/check-architecture-layers.mjs,故 defineCommand 走依赖注入)。
23
+ * 4. commands/endpoint/{list.ts, add/[name:string].ts, remove/[name:string].ts} 分别
24
+ * `export default telegramEndpointCommands.list|add|remove`。
25
+ *
26
+ * 注意:adapterKey 即实例 key(zhin.config.yml 的 plugins.<key>);多实例自定义 key 时
27
+ * 写回目标以 spec.adapterKey 为准(与 QQ 现状一致)。
28
+ */
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { createToken, type Token } from '@zhin.js/plugin-runtime';
32
+ import { isMap, isSeq, parseDocument, type YAMLSeq } from 'yaml';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // 权限:master 判定
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * endpoint 管理命令的操作者校验:实例配置声明了 master(顶层或任一端点项)时
40
+ * 仅 master 可执行管理命令;未配置则放行。
41
+ */
42
+ export function isEndpointOperator(config: unknown, input: unknown): boolean {
43
+ const cfg = (config ?? {}) as { master?: unknown; endpoints?: unknown };
44
+ const masters = new Set<string>();
45
+ const collect = (value: unknown) => {
46
+ if (value === undefined || value === null) return;
47
+ const text = String(value).trim();
48
+ if (text) masters.add(text);
49
+ };
50
+ collect(cfg.master);
51
+ if (Array.isArray(cfg.endpoints)) {
52
+ for (const entry of cfg.endpoints) {
53
+ collect((entry as { master?: unknown } | null | undefined)?.master);
54
+ }
55
+ }
56
+ if (masters.size === 0) return true;
57
+ const sender = String((input as { sender?: unknown } | null | undefined)?.sender ?? '').trim();
58
+ return !!sender && masters.has(sender);
59
+ }
60
+
61
+ /** add/remove 的拒绝文案(list 只读,不校验)。 */
62
+ export function endpointCommandForbidden(adapterDisplayName: string): string {
63
+ return `仅 master 可执行 ${adapterDisplayName} endpoint 管理命令`;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // 回复提取
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export type EndpointCommandReply = (text: string) => Promise<unknown>;
71
+
72
+ /**
73
+ * 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
74
+ */
75
+ export function extractEndpointCommandReply(input: unknown): EndpointCommandReply {
76
+ const reply = (input as { $reply?: unknown } | null | undefined)?.$reply;
77
+ if (typeof reply === 'function') {
78
+ return (text) => (reply as (content: string) => Promise<unknown>).call(input, text);
79
+ }
80
+ return async () => undefined;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // 运行时状态:adapter create() 注册的 running endpoints
85
+ // ---------------------------------------------------------------------------
86
+
87
+ export interface EndpointRunningInfo {
88
+ readonly name: string;
89
+ /** 连接模式(ws / wss / polling / socket-mode …),仅用于 list 展示。 */
90
+ readonly mode?: string;
91
+ }
92
+
93
+ export interface EndpointRuntimeState {
94
+ /** 当前 generation 已成功创建的 endpoint(name → 描述) */
95
+ readonly endpoints: Map<string, EndpointRunningInfo>;
96
+ }
97
+
98
+ export function createEndpointRuntimeState(): EndpointRuntimeState {
99
+ return { endpoints: new Map() };
100
+ }
101
+
102
+ /** 每个适配器在模块顶层调用一次,创建自己的 runtime state token。 */
103
+ export function defineEndpointRuntimeStateToken(adapterKey: string): Token<EndpointRuntimeState> {
104
+ return createToken<EndpointRuntimeState>(
105
+ `zhin.${adapterKey}.runtime-state`,
106
+ `${adapterKey} adapter runtime state (running endpoints)`,
107
+ );
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // .env 凭据持久化
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /** 项目根:ZHIN_PROJECT_ROOT 优先,缺省 process.cwd()(替代 legacy runtimeCwd) */
115
+ export function resolveProjectRoot(): string {
116
+ const envRoot = process.env.ZHIN_PROJECT_ROOT?.trim();
117
+ return path.resolve(envRoot || process.cwd());
118
+ }
119
+
120
+ function envSlug(text: string): string {
121
+ return text
122
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
123
+ .replace(/[^a-zA-Z0-9_]/g, '_')
124
+ .toUpperCase();
125
+ }
126
+
127
+ /** 派生 endpoint 凭据的 env 键:`${ADAPTER}_${NAME}_${FIELD}`(如 `TELEGRAM_MY_BOT_TOKEN`) */
128
+ export function buildEndpointEnvKey(
129
+ adapterKey: string,
130
+ endpointName: string,
131
+ fieldKey: string,
132
+ ): string {
133
+ return `${envSlug(adapterKey)}_${envSlug(endpointName)}_${envSlug(fieldKey)}`;
134
+ }
135
+
136
+ function escapeRegExp(text: string): string {
137
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
138
+ }
139
+
140
+ function upsertEnvLine(content: string, key: string, value: string): string {
141
+ const lineRe = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, 'm');
142
+ const newLine = `${key}=${value}`;
143
+ if (lineRe.test(content)) {
144
+ return content.replace(lineRe, newLine);
145
+ }
146
+ const trimmed = content.replace(/\s*$/, '');
147
+ if (trimmed.length === 0) {
148
+ return `${newLine}\n`;
149
+ }
150
+ return `${trimmed}\n${newLine}\n`;
151
+ }
152
+
153
+ /** 写入或更新 `.env` 中的键值,并同步到当前进程 `process.env` */
154
+ export function persistEndpointEnvValues(
155
+ values: Readonly<Record<string, string>>,
156
+ projectRoot?: string,
157
+ ): void {
158
+ const root = projectRoot ?? resolveProjectRoot();
159
+ const envPath = path.join(root, '.env');
160
+ let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf-8') : '';
161
+ for (const [key, value] of Object.entries(values)) {
162
+ content = upsertEnvLine(content, key, value);
163
+ process.env[key] = value;
164
+ }
165
+ fs.writeFileSync(envPath, content);
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // zhin.config.yml 读写(yaml Document 节点级操作,保留注释;仅支持 .yml/.yaml)
170
+ // ---------------------------------------------------------------------------
171
+
172
+ export interface ConfiguredEndpointEntry {
173
+ name: string;
174
+ [key: string]: unknown;
175
+ }
176
+
177
+ const CONFIG_BASENAME = 'zhin.config';
178
+ const YAML_EXTENSIONS = ['.yml', '.yaml'] as const;
179
+
180
+ /** 定位项目配置文件:ZHIN_CONFIG 指定优先,否则发现 zhin.config.yml/.yaml,都没有则默认新建 zhin.config.yml */
181
+ export function findEndpointConfigFile(adapterKey: string, projectRoot?: string): string {
182
+ const root = projectRoot ?? resolveProjectRoot();
183
+ const envConfig = process.env.ZHIN_CONFIG?.trim();
184
+ if (envConfig) return path.resolve(root, envConfig);
185
+ for (const ext of YAML_EXTENSIONS) {
186
+ const candidate = path.join(root, `${CONFIG_BASENAME}${ext}`);
187
+ if (fs.existsSync(candidate)) return candidate;
188
+ }
189
+ for (const ext of ['.json', '.toml', '.ts'] as const) {
190
+ const candidate = path.join(root, `${CONFIG_BASENAME}${ext}`);
191
+ if (fs.existsSync(candidate)) {
192
+ throw new Error(`暂不支持写入 ${ext} 配置文件,请手动在 ${CONFIG_BASENAME}${ext} 的 plugins.${adapterKey}.endpoints 中维护`);
193
+ }
194
+ }
195
+ return path.join(root, `${CONFIG_BASENAME}.yml`);
196
+ }
197
+
198
+ interface EndpointConfigDocument {
199
+ filePath: string;
200
+ doc: ReturnType<typeof parseDocument>;
201
+ }
202
+
203
+ function readConfigDocument(adapterKey: string, projectRoot?: string): EndpointConfigDocument {
204
+ const filePath = findEndpointConfigFile(adapterKey, projectRoot);
205
+ const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
206
+ const doc = parseDocument(content || '{}');
207
+ return { filePath, doc };
208
+ }
209
+
210
+ function writeConfigDocument({ filePath, doc }: EndpointConfigDocument): void {
211
+ fs.writeFileSync(filePath, doc.toString());
212
+ }
213
+
214
+ /** 读取 plugins.<adapterKey>.endpoints(plain JS);plugins/<adapterKey> 缺失或形态不符时返回 [] */
215
+ export function listConfiguredEndpoints(
216
+ adapterKey: string,
217
+ projectRoot?: string,
218
+ ): ConfiguredEndpointEntry[] {
219
+ const { doc } = readConfigDocument(adapterKey, projectRoot);
220
+ const plugins = doc.toJS()?.plugins;
221
+ if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return [];
222
+ const endpoints = (plugins as Record<string, { endpoints?: unknown }>)[adapterKey]?.endpoints;
223
+ if (!Array.isArray(endpoints)) return [];
224
+ return endpoints.filter(
225
+ (entry): entry is ConfiguredEndpointEntry =>
226
+ !!entry && typeof entry === 'object' && typeof (entry as { name?: unknown }).name === 'string',
227
+ );
228
+ }
229
+
230
+ function entryName(item: unknown): string | undefined {
231
+ if (!isMap(item)) return undefined;
232
+ const name = item.get('name');
233
+ return typeof name === 'string' && name ? name : undefined;
234
+ }
235
+
236
+ /**
237
+ * 确保 plugins.<adapterKey>.endpoints 存在并返回其 YAMLSeq(节点级操作,保留既有条目与注释)。
238
+ * `plugins: []`(legacy 空列表,Runtime 忽略)可直接替换为 map;非空数组拒绝写入。
239
+ */
240
+ function ensureEndpointsSeq(
241
+ doc: ReturnType<typeof parseDocument>,
242
+ adapterKey: string,
243
+ ): YAMLSeq {
244
+ const plugins = doc.get('plugins');
245
+ if (isSeq(plugins) && plugins.items.length > 0) {
246
+ throw new Error('配置的 plugins 是数组形态(legacy 插件名列表),请手动迁移为 map 后再试');
247
+ }
248
+ if (plugins !== undefined && !isMap(plugins) && !isSeq(plugins)) {
249
+ throw new Error('配置的 plugins 字段形态异常,请手动检查 zhin.config.yml');
250
+ }
251
+ if (!isMap(doc.get('plugins'))) {
252
+ // 注意:空对象 {} 不会被 doc.set 自动包装为 YAMLMap,必须显式 createNode
253
+ doc.set('plugins', doc.createNode({}));
254
+ }
255
+ const adapterNode = doc.getIn(['plugins', adapterKey]);
256
+ if (adapterNode !== undefined && !isMap(adapterNode)) {
257
+ throw new Error(`配置的 plugins.${adapterKey} 字段形态异常,请手动检查 zhin.config.yml`);
258
+ }
259
+ if (!isMap(doc.getIn(['plugins', adapterKey]))) {
260
+ doc.setIn(['plugins', adapterKey], doc.createNode({}));
261
+ }
262
+ const endpoints = doc.getIn(['plugins', adapterKey, 'endpoints']);
263
+ if (endpoints !== undefined && !isSeq(endpoints)) {
264
+ throw new Error(`配置的 plugins.${adapterKey}.endpoints 字段形态异常,请手动检查 zhin.config.yml`);
265
+ }
266
+ if (!isSeq(doc.getIn(['plugins', adapterKey, 'endpoints']))) {
267
+ doc.setIn(['plugins', adapterKey, 'endpoints'], doc.createNode([]));
268
+ }
269
+ return doc.getIn(['plugins', adapterKey, 'endpoints']) as YAMLSeq;
270
+ }
271
+
272
+ /** 追加 endpoint 到 plugins.<adapterKey>.endpoints;name 已存在时报错 */
273
+ export function addEndpointToConfig(
274
+ adapterKey: string,
275
+ entry: ConfiguredEndpointEntry,
276
+ projectRoot?: string,
277
+ ): string {
278
+ const document = readConfigDocument(adapterKey, projectRoot);
279
+ const seq = ensureEndpointsSeq(document.doc, adapterKey);
280
+ if (seq.items.some((item) => entryName(item) === entry.name)) {
281
+ throw new Error(`配置中已存在 ${adapterKey} endpoint「${entry.name}」,可先 ${adapterKey} endpoint remove ${entry.name} 再重新添加`);
282
+ }
283
+ seq.items.push(document.doc.createNode(entry));
284
+ writeConfigDocument(document);
285
+ return document.filePath;
286
+ }
287
+
288
+ /** 按 name 移除 plugins.<adapterKey>.endpoints 项;不存在返回 false */
289
+ export function removeEndpointFromConfig(
290
+ adapterKey: string,
291
+ name: string,
292
+ projectRoot?: string,
293
+ ): { removed: boolean; filePath: string } {
294
+ const document = readConfigDocument(adapterKey, projectRoot);
295
+ const seq = ensureEndpointsSeq(document.doc, adapterKey);
296
+ const next = seq.items.filter((item) => entryName(item) !== name);
297
+ if (next.length === seq.items.length) {
298
+ return { removed: false, filePath: document.filePath };
299
+ }
300
+ seq.items = next;
301
+ writeConfigDocument(document);
302
+ return { removed: true, filePath: document.filePath };
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // 命令套件
307
+ // ---------------------------------------------------------------------------
308
+
309
+ /** add 命令可录入的字段描述(与 schema.json 的 endpoints.items.properties 对齐)。 */
310
+ export interface EndpointFieldSpec {
311
+ /** 配置字段 key(如 token / access_token / url / baseUrl) */
312
+ readonly key: string;
313
+ /** add 时必填(schema 中 required 的凭据/连接字段) */
314
+ readonly required?: boolean;
315
+ /** 凭据类字段:值写入 .env,yaml 保存 ${REF} 引用;否则内联写入 yaml */
316
+ readonly env?: boolean;
317
+ /** 字段说明(用于用法提示) */
318
+ readonly description?: string;
319
+ }
320
+
321
+ export type EndpointCommandUse = <T>(token: Token<T>) => T;
322
+
323
+ /** bindFlow 钩子上下文:接管 add 命令的自定义绑定流程(如 QQ 扫码)。 */
324
+ export interface EndpointBindFlowContext {
325
+ /** 命令参数 name(未指定时为 undefined,流程可自行决定终名) */
326
+ readonly name?: string;
327
+ /** 向当前会话推送后续状态(二维码刷新 / 成功 / 失败) */
328
+ readonly reply: EndpointCommandReply;
329
+ readonly config: unknown;
330
+ readonly input: unknown;
331
+ readonly use: EndpointCommandUse;
332
+ }
333
+
334
+ export interface EndpointCommandsSpec {
335
+ /** 实例 key(zhin.config.yml 的 plugins.<key>,如 telegram / napcat) */
336
+ readonly adapterKey: string;
337
+ /** 展示名(QQ / Telegram / NapCat …),用于权限与列表文案 */
338
+ readonly adapterDisplayName: string;
339
+ /** add 命令可录入字段(bindFlow 接管 add 时仅用于展示) */
340
+ readonly fields?: readonly EndpointFieldSpec[];
341
+ /** 运行中 endpoints 数据源(可选):通常读 adapter create 注册的 runtime state */
342
+ readonly running?: (use: EndpointCommandUse) => Iterable<EndpointRunningInfo>;
343
+ /** list 中配置项的附加描述(如 entry => `appid: ${entry.appid}`) */
344
+ readonly describeEntry?: (entry: ConfiguredEndpointEntry) => string;
345
+ /** list 末尾的附加行(如 QQ 的进行中扫码提示);返回 undefined 不加行 */
346
+ readonly listFooter?: (use: EndpointCommandUse) => string | undefined;
347
+ /** 自定义 add 流程(扫码绑定等);提供时 add 命令忽略 kv 参数,交给钩子 */
348
+ readonly bindFlow?: (context: EndpointBindFlowContext) => Promise<string> | string;
349
+ /** add 命令描述覆盖 */
350
+ readonly addDescription?: string;
351
+ }
352
+
353
+ /**
354
+ * 命令定义的最小结构(与 @zhin.js/command 的 CommandDefinition 结构兼容)。
355
+ * provider 层不允许 import @zhin.js/command,故 defineCommand 由调用方注入,
356
+ * 这里只描述结构;适配器侧传入 defineCommand 后 TCommand 即 Readonly<CommandDefinition>。
357
+ *
358
+ * `params` 值域须与 CommandParameterValue 对齐(含 null / 结构化对象),
359
+ * 否则注入的 defineCommand 会因 TS 逆变检查失败(TS2345)。
360
+ */
361
+ export interface EndpointCommandContext {
362
+ readonly config: unknown;
363
+ /**
364
+ * 与 `@zhin.js/command` 的 `CommandContext.input` 对齐:IM 命中时有值,
365
+ * Host / 无消息路径可为 `undefined`。须保持可选,否则注入 `defineCommand` 会因
366
+ * execute 参数逆变检查失败(TS2345)。
367
+ */
368
+ readonly input?: unknown;
369
+ readonly args: readonly string[];
370
+ readonly params: Readonly<Record<
371
+ string,
372
+ string | number | boolean | Readonly<Record<string, unknown>> | null
373
+ >>;
374
+ readonly use: EndpointCommandUse;
375
+ }
376
+
377
+ export interface EndpointCommandDefinition {
378
+ readonly description?: string;
379
+ execute(context: EndpointCommandContext): unknown;
380
+ }
381
+
382
+ export interface EndpointCommands<TCommand = EndpointCommandDefinition> {
383
+ readonly list: TCommand;
384
+ readonly add: TCommand;
385
+ readonly remove: TCommand;
386
+ }
387
+
388
+ function endpointNameParam(params: Readonly<Record<string, unknown>>): string | undefined {
389
+ const name = params.name;
390
+ return typeof name === 'string' && name.trim() ? name.trim() : undefined;
391
+ }
392
+
393
+ /** list 文案:运行中 + 配置中两段,footer 可选。 */
394
+ export function formatEndpointList(
395
+ spec: Pick<EndpointCommandsSpec, 'adapterKey' | 'adapterDisplayName' | 'describeEntry'>,
396
+ source: {
397
+ readonly running: Iterable<EndpointRunningInfo>;
398
+ readonly configured: readonly ConfiguredEndpointEntry[];
399
+ readonly footer?: string;
400
+ },
401
+ ): string {
402
+ const running = [...source.running];
403
+ const lines: string[] = [];
404
+ lines.push(`【运行中的 ${spec.adapterDisplayName} endpoints】`);
405
+ if (running.length === 0) {
406
+ lines.push(' (无)');
407
+ } else {
408
+ for (const endpoint of running) {
409
+ lines.push(endpoint.mode ? ` - ${endpoint.name}(${endpoint.mode})` : ` - ${endpoint.name}`);
410
+ }
411
+ }
412
+ lines.push(`【配置中的 ${spec.adapterDisplayName} endpoints】(zhin.config.yml → plugins.${spec.adapterKey}.endpoints)`);
413
+ if (source.configured.length === 0) {
414
+ lines.push(' (无)');
415
+ } else {
416
+ for (const entry of source.configured) {
417
+ const detail = spec.describeEntry?.(entry);
418
+ lines.push(detail ? ` - ${entry.name}(${detail})` : ` - ${entry.name}`);
419
+ }
420
+ }
421
+ if (source.footer) lines.push(source.footer);
422
+ return lines.join('\n');
423
+ }
424
+
425
+ function addUsage(spec: EndpointCommandsSpec): string {
426
+ const fields = spec.fields ?? [];
427
+ const fieldText = fields.length === 0
428
+ ? ''
429
+ : `\n字段:${fields.map((field) => {
430
+ const marks = [
431
+ field.required ? '必填' : '',
432
+ field.env ? '写入 .env' : '',
433
+ field.description ?? '',
434
+ ].filter(Boolean).join(',');
435
+ return marks ? `${field.key}(${marks})` : field.key;
436
+ }).join('、')}`;
437
+ return `用法:${spec.adapterKey} endpoint add <name> <key=value...>${fieldText}`;
438
+ }
439
+
440
+ /** add(kv 模式)的完整业务逻辑:解析 kv → 凭据写 .env → 追加 yaml;返回回复文本。 */
441
+ export function addEndpointFromKeyValues(
442
+ spec: EndpointCommandsSpec,
443
+ name: string,
444
+ args: readonly string[],
445
+ projectRoot?: string,
446
+ ): string {
447
+ const fields = spec.fields ?? [];
448
+ const known = new Map(fields.map((field) => [field.key, field]));
449
+ const values = new Map<string, string>();
450
+ for (const arg of args) {
451
+ const eq = arg.indexOf('=');
452
+ if (eq <= 0) return `参数「${arg}」不是 key=value 形式。${addUsage(spec)}`;
453
+ const key = arg.slice(0, eq);
454
+ const value = arg.slice(eq + 1).trim();
455
+ const field = known.get(key);
456
+ if (!field) {
457
+ return `未知字段「${key}」,可用字段:${fields.map((item) => item.key).join('、')}`;
458
+ }
459
+ if (!value) return `字段「${key}」的值不能为空`;
460
+ values.set(key, value);
461
+ }
462
+ const missing = fields.filter((field) => field.required && !values.has(field.key));
463
+ if (missing.length > 0) {
464
+ return `缺少必填字段:${missing.map((field) => field.key).join('、')}。${addUsage(spec)}`;
465
+ }
466
+ const entry: ConfiguredEndpointEntry = { name };
467
+ const envValues: Record<string, string> = {};
468
+ for (const field of fields) {
469
+ const value = values.get(field.key);
470
+ if (value === undefined) continue;
471
+ if (field.env) {
472
+ const envKey = buildEndpointEnvKey(spec.adapterKey, name, field.key);
473
+ envValues[envKey] = value;
474
+ entry[field.key] = `\${${envKey}}`;
475
+ } else {
476
+ entry[field.key] = value;
477
+ }
478
+ }
479
+ try {
480
+ // 先写配置(重名等校验失败时不留孤儿 .env 键),再落 .env 凭据
481
+ const filePath = addEndpointToConfig(spec.adapterKey, entry, projectRoot);
482
+ if (Object.keys(envValues).length > 0) persistEndpointEnvValues(envValues, projectRoot);
483
+ return (
484
+ `✅ endpoint「${name}」已追加到 ${filePath} 的 plugins.${spec.adapterKey}.endpoints` +
485
+ `${Object.keys(envValues).length > 0 ? '(凭据已写入 .env)' : ''}。\n` +
486
+ '⚠️ 需重启 zhin 后新 endpoint 才会生效。'
487
+ );
488
+ } catch (error) {
489
+ return `添加失败:${error instanceof Error ? error.message : String(error)}`;
490
+ }
491
+ }
492
+
493
+ /** remove 的完整业务逻辑:从 yaml 移除;返回回复文本。 */
494
+ export function removeEndpointByName(
495
+ spec: Pick<EndpointCommandsSpec, 'adapterKey'>,
496
+ name: string,
497
+ projectRoot?: string,
498
+ ): string {
499
+ const trimmed = name.trim();
500
+ if (!trimmed) return `用法:${spec.adapterKey} endpoint remove <name>`;
501
+ try {
502
+ const { removed, filePath } = removeEndpointFromConfig(spec.adapterKey, trimmed, projectRoot);
503
+ if (!removed) {
504
+ return `配置中不存在 ${spec.adapterKey} endpoint「${trimmed}」(${filePath} → plugins.${spec.adapterKey}.endpoints)`;
505
+ }
506
+ return (
507
+ `已从 ${filePath} 的 plugins.${spec.adapterKey}.endpoints 移除「${trimmed}」。\n` +
508
+ '⚠️ 需重启 zhin 后生效(运行中的连接届时才会断开);.env 中的凭据键未删除,可手动清理。'
509
+ );
510
+ } catch (error) {
511
+ return `移除失败:${error instanceof Error ? error.message : String(error)}`;
512
+ }
513
+ }
514
+
515
+ /** 生成 `<adapter> endpoint` 的 list / add / remove 三个命令定义(见文件头接入步骤)。 */
516
+ export function createEndpointCommands<TCommand>(
517
+ spec: EndpointCommandsSpec,
518
+ defineCommand: (definition: EndpointCommandDefinition) => TCommand,
519
+ ): EndpointCommands<TCommand> {
520
+ const forbidden = endpointCommandForbidden(spec.adapterDisplayName);
521
+ return Object.freeze({
522
+ list: defineCommand({
523
+ description: `列出 ${spec.adapterDisplayName} endpoints(运行中 + zhin.config.yml 配置)`,
524
+ execute({ use }) {
525
+ return formatEndpointList(spec, {
526
+ running: spec.running?.(use) ?? [],
527
+ configured: listConfiguredEndpoints(spec.adapterKey),
528
+ footer: spec.listFooter?.(use),
529
+ });
530
+ },
531
+ }),
532
+ add: defineCommand({
533
+ description: spec.addDescription
534
+ ?? `手动添加 ${spec.adapterDisplayName} endpoint(凭据写入 .env 并追加到 zhin.config.yml,重启生效)`,
535
+ execute({ config, input, params, args, use }) {
536
+ if (!isEndpointOperator(config, input)) return forbidden;
537
+ const name = endpointNameParam(params);
538
+ if (spec.bindFlow) {
539
+ return spec.bindFlow({
540
+ name,
541
+ reply: extractEndpointCommandReply(input),
542
+ config,
543
+ input,
544
+ use,
545
+ });
546
+ }
547
+ if (!name) return addUsage(spec);
548
+ return addEndpointFromKeyValues(spec, name, args);
549
+ },
550
+ }),
551
+ remove: defineCommand({
552
+ description: `从 zhin.config.yml 的 plugins.${spec.adapterKey}.endpoints 移除指定 endpoint(重启生效)`,
553
+ execute({ config, input, params }) {
554
+ if (!isEndpointOperator(config, input)) return forbidden;
555
+ return removeEndpointByName(spec, String(params.name ?? ''));
556
+ },
557
+ }),
558
+ });
559
+ }