@koishi-ce/plugin-config 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.
@@ -0,0 +1,390 @@
1
+ /**
2
+ * 配置写入器(服务端共享实现)。
3
+ *
4
+ * 本模块是 config 插件的核心:以 `config` 数据服务的形式把 loader 中的
5
+ * 应用配置(koishi.yml / koishi.json)推送到浏览器端控制台,并把前端
6
+ * 发来的 `manager/*` 事件(重载、停用、移除、改名、拖拽移动等)落实为
7
+ * 对内存中配置对象的修改,再由 loader.writeConfig() 持久化回配置文件。
8
+ *
9
+ * 由于 YAML/JSON 对象的键顺序即插件在配置文件中的书写顺序,本模块中的
10
+ * 多个辅助函数(insertKey / rename / dropKey)都围绕"在保持键顺序的
11
+ * 前提下增删改键"这一目标实现。
12
+ */
13
+ import { DataService } from "@koishi-ce/console";
14
+ import { type Context, Logger, remove } from "@koishi-ce/koishi";
15
+ import { Loader, type LoaderScope } from "@koishi-ce/loader";
16
+
17
+ // 声明合并:浏览器端通过 WebSocket 发送的 manager/* 事件及其载荷类型。
18
+ // 服务端在构造函数中为每个事件注册监听器,见下方 ConfigWriter 构造函数。
19
+ declare module "@koishi-ce/console" {
20
+ interface Events {
21
+ "manager/app-reload"(config: unknown): void;
22
+ "manager/teleport"(
23
+ source: string,
24
+ key: string,
25
+ target: string,
26
+ index: number,
27
+ ): void;
28
+ "manager/reload"(parent: string, key: string, config: unknown): void;
29
+ "manager/unload"(
30
+ parent: string,
31
+ key: string,
32
+ config: unknown,
33
+ index?: number,
34
+ ): void;
35
+ "manager/remove"(parent: string, key: string): void;
36
+ "manager/meta"(ident: string, config: unknown): void;
37
+ }
38
+ }
39
+
40
+ const logger = new Logger("loader");
41
+
42
+ /**
43
+ * 把 temp 中的键插入到 object 的开头(原有 rest 键依次后移),用于在
44
+ * 不破坏其它键相对顺序的前提下完成"插入到指定位置"的配置操作。
45
+ *
46
+ * 实现方式是先把 rest 中的键全部搬到 temp 里,再用 Object.assign 合并回来。
47
+ *
48
+ * @param object 目标配置对象(会被原地修改)
49
+ * @param temp 待插入的键值对
50
+ * @param rest 需要让位的既有键名列表
51
+ */
52
+ function insertKey(
53
+ object: Record<string, unknown>,
54
+ temp: Record<string, unknown>,
55
+ rest: string[],
56
+ ) {
57
+ for (const key of rest) {
58
+ temp[key] = object[key];
59
+ delete object[key];
60
+ }
61
+ Object.assign(object, temp);
62
+ }
63
+
64
+ /**
65
+ * 在对象中把键 old 重命名为 neo(并可顺带替换其值),且保持键原有的位置。
66
+ *
67
+ * @param object 目标配置对象
68
+ * @param old 旧键名(可为 `"~" + name` 形式的停用态键)
69
+ * @param neo 新键名
70
+ * @param value 新键对应的值
71
+ */
72
+ function rename(
73
+ object: Record<string, unknown>,
74
+ old: string,
75
+ neo: string,
76
+ value: unknown,
77
+ ) {
78
+ const keys = Object.keys(object);
79
+ const index = keys.findIndex((key) => key === old || key === `~${old}`);
80
+ const rest = index < 0 ? [] : keys.slice(index + 1);
81
+ const temp = { [neo]: value };
82
+ delete object[old];
83
+ delete object[`~${old}`];
84
+ insertKey(object, temp, rest);
85
+ }
86
+
87
+ /**
88
+ * 从 plugins 配置中摘除指定的键(兼容 `~` 前缀的停用态键),返回以该键
89
+ * 为唯一成员的对象,便于随后通过 insertKey 插入到别处(teleport 场景)。
90
+ *
91
+ * @param plugins 插件配置对象
92
+ * @param name 插件键名
93
+ */
94
+ function dropKey(plugins: Record<string, unknown>, name: string) {
95
+ if (!(name in plugins)) {
96
+ name = `~${name}`;
97
+ }
98
+ const value = plugins[name];
99
+ delete plugins[name];
100
+ return { [name]: value };
101
+ }
102
+
103
+ /**
104
+ * 配置写入器服务。以 `config` 为服务名向控制台暴露当前应用配置,
105
+ * 并接收前端 `manager/*` 事件来修改配置文件。所有事件均要求
106
+ * authority 达到 4(管理员)。
107
+ */
108
+ export class ConfigWriter extends DataService<Context.Config> {
109
+ protected loader: Loader;
110
+
111
+ constructor(ctx: Context) {
112
+ super(ctx, "config", { authority: 4 });
113
+ this.loader = ctx.loader;
114
+
115
+ // 全局配置保存:整份替换 koishi 配置后触发应用全量重载
116
+ ctx.console.addListener(
117
+ "manager/app-reload",
118
+ (config) => {
119
+ // 前端提交的整份应用配置(全局设置表单的产物),按声明收窄
120
+ return this.reloadApp(config as Context.Config);
121
+ },
122
+ { authority: 4 },
123
+ );
124
+
125
+ // 其余五个事件签名各异,这里统一注册:转发到同名方法并统一错误处理
126
+ for (const key of [
127
+ "teleport",
128
+ "reload",
129
+ "unload",
130
+ "remove",
131
+ "meta",
132
+ ] as const) {
133
+ ctx.console.addListener(
134
+ `manager/${key}`,
135
+ async (...args: unknown[]) => {
136
+ try {
137
+ // 五个方法签名各异,统一视为泛化调用目标后 apply
138
+ const action = this[key] as (...args: unknown[]) => Promise<void>;
139
+ await action.apply(this, args);
140
+ } catch (error) {
141
+ logger.error(error);
142
+ throw new Error("failed");
143
+ }
144
+ },
145
+ { authority: 4 },
146
+ );
147
+ }
148
+
149
+ ctx.on("config", () => this.refresh());
150
+ }
151
+
152
+ /**
153
+ * 递归过滤并整理某层上下文的插件配置,得到可安全下发给前端的副本。
154
+ *
155
+ * - 过滤掉内部保留键(`$` 开头,如 `$folded`);
156
+ * - 剔除 `$if` 条件不成立的插件项(停用插件以 `~` 前缀 + `$if-disabled`
157
+ * 表示,`isTruthyLike` 为假说明该项已不被加载);
158
+ * - 对 `group:xxx` 分组键递归处理其子插件。
159
+ *
160
+ * @param plugins 某层上下文的 plugins 配置对象
161
+ * @param ctx 该层对应的运行时上下文(用于查找 fork 记录)
162
+ */
163
+ getGroup(plugins: Record<string, unknown> | undefined, ctx: Context) {
164
+ const result = { ...plugins };
165
+ for (const key in plugins ?? {}) {
166
+ if (key.startsWith("$")) continue;
167
+ // 配置值本质是任意 JSON 对象(插件配置或嵌套分组),此处按字典收窄使用
168
+ const value = plugins?.[key] as Record<string, unknown> | undefined;
169
+ const name = (key.split(":", 1)[0] ?? "").replace(/^~/, "");
170
+
171
+ if (!this.loader.isTruthyLike(value?.["$if"])) {
172
+ // $if-disabled 的插件不应显示在配置树中
173
+ // https://github.com/koishijs/webui/issues/249
174
+ delete result[key];
175
+ continue;
176
+ }
177
+
178
+ // 处理插件分组:分组本身是个子上下文,需要递归
179
+ const fork = (ctx.scope as LoaderScope)[Loader.kRecord]?.[key];
180
+ if (!fork) continue;
181
+ if (name === "group") {
182
+ result[key] = this.getGroup(value, fork.ctx);
183
+ }
184
+ }
185
+ return result;
186
+ }
187
+
188
+ /** 数据服务读取入口:返回去除敏感内部键后的整份应用配置。 */
189
+ override async get() {
190
+ const result: Context.Config = { ...this.loader.config };
191
+ result.plugins = this.getGroup(result.plugins, this.loader.entry);
192
+ return result;
193
+ }
194
+
195
+ /**
196
+ * 保存全局设置:用前端提交的整份配置替换当前配置(保留原有的 plugins
197
+ * 部分),写回配置文件后执行全量重载(进程内重启全部插件)。
198
+ *
199
+ * @param config 前端提交的应用级配置(不含 plugins)
200
+ */
201
+ async reloadApp(config: Context.Config) {
202
+ delete (config as Record<string, unknown>)["$paths"];
203
+ const plugins = this.loader.config.plugins;
204
+ this.loader.config = config;
205
+ // exactOptionalPropertyTypes 禁止向可选属性显式赋 undefined,
206
+ // 用 Object.assign 保持"以旧 plugins(含 undefined)整体覆盖"的原语义
207
+ Object.assign(this.loader.config, { plugins });
208
+ await this.loader.writeConfig();
209
+ this.loader.fullReload();
210
+ }
211
+
212
+ /**
213
+ * 根据路径标识查找对应的 fork 作用域。
214
+ *
215
+ * 前端用"分组路径"(如 `group:abc` 的 `abc`)标识每个上下文:空串代表
216
+ * 根作用域,否则在所有已注册插件的子作用域中按 fork.key 匹配。
217
+ *
218
+ * @param ident 分组路径(空串表示根)
219
+ * @returns 对应的 ForkScope,找不到时返回 undefined
220
+ */
221
+ private resolveFork(ident: string) {
222
+ if (!ident) return this.loader.entry.scope;
223
+ for (const main of this.ctx.registry.values()) {
224
+ for (const fork of main.children) {
225
+ // fork 的 key 由 loader 写入(ForkScope 类型上没有该属性)
226
+ if ((fork as LoaderScope).key === ident) return fork;
227
+ }
228
+ }
229
+ return;
230
+ }
231
+
232
+ /**
233
+ * 在配置文件中按路径标识递归定位插件条目。
234
+ *
235
+ * @param ident 插件的路径标识(键去掉 `name:` 前缀后的部分)
236
+ * @param config 当前层级的 plugins 配置(递归时传入分组内部)
237
+ * @returns [所在配置对象, 完整键名] 二元组
238
+ * @throws 找不到时抛出 "plugin not found"
239
+ */
240
+ private resolveConfig(
241
+ ident: string,
242
+ config: Record<string, unknown> | undefined = this.loader.config.plugins,
243
+ ): [Record<string, unknown>, string] {
244
+ if (config) {
245
+ for (const key in config) {
246
+ const name = key.split(":", 1)[0] ?? "";
247
+ if (key.slice(name.length + 1) === ident) return [config, key];
248
+ if (name === "group" || name === "~group") {
249
+ try {
250
+ return this.resolveConfig(
251
+ ident,
252
+ config[key] as Record<string, unknown> | undefined,
253
+ );
254
+ } catch {}
255
+ }
256
+ }
257
+ }
258
+ throw new Error("plugin not found");
259
+ }
260
+
261
+ /**
262
+ * 更新插件的元数据键(`$` 开头的内部控制字段,如 `$label`、`$collapsed`)。
263
+ *
264
+ * config 中值为 null 的键表示删除;其余键覆盖写入并置于该插件配置的
265
+ * 开头位置。仅写盘(writeConfig(true) 触发静默刷新),不重载插件。
266
+ *
267
+ * @param ident 插件路径标识
268
+ * @param config 待合并的元数据键值对
269
+ */
270
+ async meta(ident: string, config: Record<string, unknown>) {
271
+ const [parent, key] = this.resolveConfig(ident);
272
+ const target = parent[key] as Record<string, unknown>;
273
+ for (const key of Object.keys(config)) {
274
+ delete target[key];
275
+ if (config[key] === null) {
276
+ delete config[key];
277
+ }
278
+ }
279
+ insertKey(target, config, Object.keys(target));
280
+ await this.loader.writeConfig(true);
281
+ }
282
+
283
+ /**
284
+ * (重新)加载插件:用提交的配置替换内存中的插件配置,先热重载再写盘。
285
+ *
286
+ * @param parent 所属分组的路径标识(空串为根)
287
+ * @param key 插件键名(形如 `name:ident`)
288
+ * @param config 新的插件配置
289
+ */
290
+ async reload(parent: string, key: string, config: unknown) {
291
+ const scope = this.resolveFork(parent);
292
+ if (!scope) throw new Error("plugin not found");
293
+ await this.loader.reload(scope.ctx, key, config);
294
+ rename(scope.config, key, key, config);
295
+ await this.loader.writeConfig();
296
+ }
297
+
298
+ /**
299
+ * 停用插件:卸载运行时并把配置键改写为 `~` 前缀(保留配置以便再启用)。
300
+ *
301
+ * 传入 index 时(克隆配置场景)把停用键插入到指定位置而非原位重命名。
302
+ *
303
+ * @param parent 所属分组的路径标识
304
+ * @param key 插件键名
305
+ * @param config 停用时保留的插件配置
306
+ * @param index 可选的目标插入位置(在父配置的键序列中的序号)
307
+ */
308
+ async unload(
309
+ parent: string,
310
+ key: string,
311
+ config: Record<string, unknown> = {},
312
+ index?: number,
313
+ ) {
314
+ const scope = this.resolveFork(parent);
315
+ if (!scope) throw new Error("plugin not found");
316
+ this.loader.unload(scope.ctx, key);
317
+ if (index) {
318
+ const rest = Object.keys(scope.config).slice(index);
319
+ insertKey(scope.config, { [`~${key}`]: config }, rest);
320
+ } else {
321
+ rename(scope.config, key, `~${key}`, config);
322
+ }
323
+ await this.loader.writeConfig();
324
+ }
325
+
326
+ /**
327
+ * 移除插件:卸载运行时并从配置文件中彻底删除对应键(含 `~` 停用态)。
328
+ *
329
+ * @param parent 所属分组的路径标识
330
+ * @param key 插件键名
331
+ */
332
+ async remove(parent: string, key: string) {
333
+ const scope = this.resolveFork(parent);
334
+ if (!scope) throw new Error("plugin not found");
335
+ this.loader.unload(scope.ctx, key);
336
+ const config = scope.config as Record<string, unknown>;
337
+ delete config[key];
338
+ delete config[`~${key}`];
339
+ await this.loader.writeConfig();
340
+ }
341
+
342
+ /**
343
+ * 拖拽移动插件:把插件从 source 分组搬到 target 分组的 index 位置。
344
+ *
345
+ * 分两步:
346
+ * 1. 迁移运行时 fork(改挂父作用域、迁移 disposables、修正原型链,
347
+ * 若注入的服务在两个分组中取值不同则重启该 fork);
348
+ * 2. 迁移配置键(dropKey 摘出后 insertKey 插入目标位置)。
349
+ *
350
+ * @param source 源分组的路径标识(空串为根)
351
+ * @param key 被移动的插件键名
352
+ * @param target 目标分组的路径标识
353
+ * @param index 目标插入位置(目标分组键序列中的序号)
354
+ */
355
+ async teleport(source: string, key: string, target: string, index: number) {
356
+ const parentS = this.resolveFork(source);
357
+ const parentT = this.resolveFork(target);
358
+ if (!parentS || !parentT) throw new Error("plugin not found");
359
+
360
+ // 第一步:迁移运行时 fork(仅在跨分组时需要)
361
+ const fork = (parentS as LoaderScope)[Loader.kRecord]?.[key];
362
+ if (fork && parentS !== parentT) {
363
+ const sourceRecord = ((parentS as LoaderScope)[Loader.kRecord] ??=
364
+ Object.create(null));
365
+ delete sourceRecord[key];
366
+ const targetRecord = ((parentT as LoaderScope)[Loader.kRecord] ??=
367
+ Object.create(null));
368
+ targetRecord[key] = fork;
369
+ remove(parentS.disposables, fork.dispose);
370
+ parentT.disposables.push(fork.dispose);
371
+ fork.parent = parentT.ctx;
372
+ Object.setPrototypeOf(fork.ctx, parentT.ctx);
373
+ fork.ctx.emit("internal/fork", fork);
374
+ // scope 实例是转发到 config 的 Proxy,用 Reflect.get 保持原读取语义
375
+ if (
376
+ Object.keys(fork.runtime.inject).some(
377
+ (name) => Reflect.get(parentS, name) !== Reflect.get(parentT, name),
378
+ )
379
+ ) {
380
+ fork.restart();
381
+ }
382
+ }
383
+
384
+ // 第二步:迁移配置键,把摘出的键插入目标分组对应位置后写盘
385
+ const temp = dropKey(parentS.config, key);
386
+ const rest = Object.keys(parentT.config).slice(index);
387
+ insertKey(parentT.config, temp, rest);
388
+ await this.loader.writeConfig();
389
+ }
390
+ }