@yunzai-ng/core 0.1.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.
Files changed (357) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +51 -0
  3. package/dist/adapter/accounts.d.ts +174 -0
  4. package/dist/adapter/accounts.d.ts.map +1 -0
  5. package/dist/adapter/accounts.js +653 -0
  6. package/dist/adapter/accounts.js.map +1 -0
  7. package/dist/adapter/bots.d.ts +142 -0
  8. package/dist/adapter/bots.d.ts.map +1 -0
  9. package/dist/adapter/bots.js +454 -0
  10. package/dist/adapter/bots.js.map +1 -0
  11. package/dist/adapter/host.d.ts +114 -0
  12. package/dist/adapter/host.d.ts.map +1 -0
  13. package/dist/adapter/host.js +90 -0
  14. package/dist/adapter/host.js.map +1 -0
  15. package/dist/adapter/login.d.ts +174 -0
  16. package/dist/adapter/login.d.ts.map +1 -0
  17. package/dist/adapter/login.js +345 -0
  18. package/dist/adapter/login.js.map +1 -0
  19. package/dist/adapter/registry.d.ts +102 -0
  20. package/dist/adapter/registry.d.ts.map +1 -0
  21. package/dist/adapter/registry.js +150 -0
  22. package/dist/adapter/registry.js.map +1 -0
  23. package/dist/config/core-config.d.ts +151 -0
  24. package/dist/config/core-config.d.ts.map +1 -0
  25. package/dist/config/core-config.js +338 -0
  26. package/dist/config/core-config.js.map +1 -0
  27. package/dist/config/schema.d.ts +449 -0
  28. package/dist/config/schema.d.ts.map +1 -0
  29. package/dist/config/schema.js +871 -0
  30. package/dist/config/schema.js.map +1 -0
  31. package/dist/config/store.d.ts +184 -0
  32. package/dist/config/store.d.ts.map +1 -0
  33. package/dist/config/store.js +428 -0
  34. package/dist/config/store.js.map +1 -0
  35. package/dist/config/yaml.d.ts +23 -0
  36. package/dist/config/yaml.d.ts.map +1 -0
  37. package/dist/config/yaml.js +108 -0
  38. package/dist/config/yaml.js.map +1 -0
  39. package/dist/http/client.d.ts +82 -0
  40. package/dist/http/client.d.ts.map +1 -0
  41. package/dist/http/client.js +658 -0
  42. package/dist/http/client.js.map +1 -0
  43. package/dist/index.d.ts +77 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +94 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/kernel/app.d.ts +244 -0
  48. package/dist/kernel/app.d.ts.map +1 -0
  49. package/dist/kernel/app.js +654 -0
  50. package/dist/kernel/app.js.map +1 -0
  51. package/dist/kernel/kv-sink.d.ts +34 -0
  52. package/dist/kernel/kv-sink.d.ts.map +1 -0
  53. package/dist/kernel/kv-sink.js +25 -0
  54. package/dist/kernel/kv-sink.js.map +1 -0
  55. package/dist/kernel/policy.d.ts +77 -0
  56. package/dist/kernel/policy.d.ts.map +1 -0
  57. package/dist/kernel/policy.js +99 -0
  58. package/dist/kernel/policy.js.map +1 -0
  59. package/dist/kernel/runtime.d.ts +117 -0
  60. package/dist/kernel/runtime.d.ts.map +1 -0
  61. package/dist/kernel/runtime.js +197 -0
  62. package/dist/kernel/runtime.js.map +1 -0
  63. package/dist/kernel/sql-sink.d.ts +36 -0
  64. package/dist/kernel/sql-sink.d.ts.map +1 -0
  65. package/dist/kernel/sql-sink.js +114 -0
  66. package/dist/kernel/sql-sink.js.map +1 -0
  67. package/dist/logger/format.d.ts +67 -0
  68. package/dist/logger/format.d.ts.map +1 -0
  69. package/dist/logger/format.js +218 -0
  70. package/dist/logger/format.js.map +1 -0
  71. package/dist/logger/index.d.ts +97 -0
  72. package/dist/logger/index.d.ts.map +1 -0
  73. package/dist/logger/index.js +363 -0
  74. package/dist/logger/index.js.map +1 -0
  75. package/dist/logger/rotate.d.ts +47 -0
  76. package/dist/logger/rotate.d.ts.map +1 -0
  77. package/dist/logger/rotate.js +242 -0
  78. package/dist/logger/rotate.js.map +1 -0
  79. package/dist/message/segment.d.ts +255 -0
  80. package/dist/message/segment.d.ts.map +1 -0
  81. package/dist/message/segment.js +510 -0
  82. package/dist/message/segment.js.map +1 -0
  83. package/dist/message/split.d.ts +34 -0
  84. package/dist/message/split.d.ts.map +1 -0
  85. package/dist/message/split.js +79 -0
  86. package/dist/message/split.js.map +1 -0
  87. package/dist/message/target.d.ts +29 -0
  88. package/dist/message/target.d.ts.map +1 -0
  89. package/dist/message/target.js +34 -0
  90. package/dist/message/target.js.map +1 -0
  91. package/dist/pipeline/cooldown.d.ts +84 -0
  92. package/dist/pipeline/cooldown.d.ts.map +1 -0
  93. package/dist/pipeline/cooldown.js +94 -0
  94. package/dist/pipeline/cooldown.js.map +1 -0
  95. package/dist/pipeline/dispatch.d.ts +98 -0
  96. package/dist/pipeline/dispatch.d.ts.map +1 -0
  97. package/dist/pipeline/dispatch.js +323 -0
  98. package/dist/pipeline/dispatch.js.map +1 -0
  99. package/dist/pipeline/event.d.ts +130 -0
  100. package/dist/pipeline/event.d.ts.map +1 -0
  101. package/dist/pipeline/event.js +538 -0
  102. package/dist/pipeline/event.js.map +1 -0
  103. package/dist/pipeline/middleware.d.ts +63 -0
  104. package/dist/pipeline/middleware.d.ts.map +1 -0
  105. package/dist/pipeline/middleware.js +174 -0
  106. package/dist/pipeline/middleware.js.map +1 -0
  107. package/dist/pipeline/prompt.d.ts +80 -0
  108. package/dist/pipeline/prompt.d.ts.map +1 -0
  109. package/dist/pipeline/prompt.js +155 -0
  110. package/dist/pipeline/prompt.js.map +1 -0
  111. package/dist/pipeline/router.d.ts +98 -0
  112. package/dist/pipeline/router.d.ts.map +1 -0
  113. package/dist/pipeline/router.js +489 -0
  114. package/dist/pipeline/router.js.map +1 -0
  115. package/dist/platform/detect.d.ts +24 -0
  116. package/dist/platform/detect.d.ts.map +1 -0
  117. package/dist/platform/detect.js +178 -0
  118. package/dist/platform/detect.js.map +1 -0
  119. package/dist/platform/paths.d.ts +38 -0
  120. package/dist/platform/paths.d.ts.map +1 -0
  121. package/dist/platform/paths.js +129 -0
  122. package/dist/platform/paths.js.map +1 -0
  123. package/dist/platform/system.d.ts +71 -0
  124. package/dist/platform/system.d.ts.map +1 -0
  125. package/dist/platform/system.js +242 -0
  126. package/dist/platform/system.js.map +1 -0
  127. package/dist/plugin/context.d.ts +92 -0
  128. package/dist/plugin/context.d.ts.map +1 -0
  129. package/dist/plugin/context.js +573 -0
  130. package/dist/plugin/context.js.map +1 -0
  131. package/dist/plugin/define.d.ts +92 -0
  132. package/dist/plugin/define.d.ts.map +1 -0
  133. package/dist/plugin/define.js +82 -0
  134. package/dist/plugin/define.js.map +1 -0
  135. package/dist/plugin/discover.d.ts +106 -0
  136. package/dist/plugin/discover.d.ts.map +1 -0
  137. package/dist/plugin/discover.js +254 -0
  138. package/dist/plugin/discover.js.map +1 -0
  139. package/dist/plugin/events.d.ts +109 -0
  140. package/dist/plugin/events.d.ts.map +1 -0
  141. package/dist/plugin/events.js +209 -0
  142. package/dist/plugin/events.js.map +1 -0
  143. package/dist/plugin/hooks.d.ts +266 -0
  144. package/dist/plugin/hooks.d.ts.map +1 -0
  145. package/dist/plugin/hooks.js +84 -0
  146. package/dist/plugin/hooks.js.map +1 -0
  147. package/dist/plugin/host.d.ts +160 -0
  148. package/dist/plugin/host.d.ts.map +1 -0
  149. package/dist/plugin/host.js +546 -0
  150. package/dist/plugin/host.js.map +1 -0
  151. package/dist/plugin/market.d.ts +264 -0
  152. package/dist/plugin/market.d.ts.map +1 -0
  153. package/dist/plugin/market.js +700 -0
  154. package/dist/plugin/market.js.map +1 -0
  155. package/dist/plugin/services.d.ts +116 -0
  156. package/dist/plugin/services.d.ts.map +1 -0
  157. package/dist/plugin/services.js +213 -0
  158. package/dist/plugin/services.js.map +1 -0
  159. package/dist/plugin/tar.d.ts +65 -0
  160. package/dist/plugin/tar.d.ts.map +1 -0
  161. package/dist/plugin/tar.js +277 -0
  162. package/dist/plugin/tar.js.map +1 -0
  163. package/dist/render/registry.d.ts +127 -0
  164. package/dist/render/registry.d.ts.map +1 -0
  165. package/dist/render/registry.js +306 -0
  166. package/dist/render/registry.js.map +1 -0
  167. package/dist/scheduler/index.d.ts +67 -0
  168. package/dist/scheduler/index.d.ts.map +1 -0
  169. package/dist/scheduler/index.js +279 -0
  170. package/dist/scheduler/index.js.map +1 -0
  171. package/dist/server/api.d.ts +179 -0
  172. package/dist/server/api.d.ts.map +1 -0
  173. package/dist/server/api.js +602 -0
  174. package/dist/server/api.js.map +1 -0
  175. package/dist/server/auth.d.ts +83 -0
  176. package/dist/server/auth.d.ts.map +1 -0
  177. package/dist/server/auth.js +178 -0
  178. package/dist/server/auth.js.map +1 -0
  179. package/dist/server/browse.d.ts +60 -0
  180. package/dist/server/browse.d.ts.map +1 -0
  181. package/dist/server/browse.js +187 -0
  182. package/dist/server/browse.js.map +1 -0
  183. package/dist/server/files.d.ts +27 -0
  184. package/dist/server/files.d.ts.map +1 -0
  185. package/dist/server/files.js +76 -0
  186. package/dist/server/files.js.map +1 -0
  187. package/dist/server/index.d.ts +189 -0
  188. package/dist/server/index.d.ts.map +1 -0
  189. package/dist/server/index.js +1123 -0
  190. package/dist/server/index.js.map +1 -0
  191. package/dist/server/table.d.ts +105 -0
  192. package/dist/server/table.d.ts.map +1 -0
  193. package/dist/server/table.js +289 -0
  194. package/dist/server/table.js.map +1 -0
  195. package/dist/store/index.d.ts +50 -0
  196. package/dist/store/index.d.ts.map +1 -0
  197. package/dist/store/index.js +120 -0
  198. package/dist/store/index.js.map +1 -0
  199. package/dist/store/json.d.ts +73 -0
  200. package/dist/store/json.d.ts.map +1 -0
  201. package/dist/store/json.js +174 -0
  202. package/dist/store/json.js.map +1 -0
  203. package/dist/store/kv.d.ts +126 -0
  204. package/dist/store/kv.d.ts.map +1 -0
  205. package/dist/store/kv.js +232 -0
  206. package/dist/store/kv.js.map +1 -0
  207. package/dist/store/level.d.ts +103 -0
  208. package/dist/store/level.d.ts.map +1 -0
  209. package/dist/store/level.js +119 -0
  210. package/dist/store/level.js.map +1 -0
  211. package/dist/store/memory.d.ts +61 -0
  212. package/dist/store/memory.d.ts.map +1 -0
  213. package/dist/store/memory.js +94 -0
  214. package/dist/store/memory.js.map +1 -0
  215. package/dist/store/sql.d.ts +142 -0
  216. package/dist/store/sql.d.ts.map +1 -0
  217. package/dist/store/sql.js +246 -0
  218. package/dist/store/sql.js.map +1 -0
  219. package/dist/testing/fake.d.ts +109 -0
  220. package/dist/testing/fake.d.ts.map +1 -0
  221. package/dist/testing/fake.js +196 -0
  222. package/dist/testing/fake.js.map +1 -0
  223. package/dist/testing/index.d.ts +16 -0
  224. package/dist/testing/index.d.ts.map +1 -0
  225. package/dist/testing/index.js +16 -0
  226. package/dist/testing/index.js.map +1 -0
  227. package/dist/testing/mock-adapter.d.ts +205 -0
  228. package/dist/testing/mock-adapter.d.ts.map +1 -0
  229. package/dist/testing/mock-adapter.js +309 -0
  230. package/dist/testing/mock-adapter.js.map +1 -0
  231. package/dist/util/deep.d.ts +108 -0
  232. package/dist/util/deep.d.ts.map +1 -0
  233. package/dist/util/deep.js +275 -0
  234. package/dist/util/deep.js.map +1 -0
  235. package/dist/util/defer.d.ts +107 -0
  236. package/dist/util/defer.d.ts.map +1 -0
  237. package/dist/util/defer.js +163 -0
  238. package/dist/util/defer.js.map +1 -0
  239. package/dist/util/dispose.d.ts +88 -0
  240. package/dist/util/dispose.d.ts.map +1 -0
  241. package/dist/util/dispose.js +143 -0
  242. package/dist/util/dispose.js.map +1 -0
  243. package/dist/util/duration.d.ts +28 -0
  244. package/dist/util/duration.d.ts.map +1 -0
  245. package/dist/util/duration.js +77 -0
  246. package/dist/util/duration.js.map +1 -0
  247. package/dist/util/fs.d.ts +110 -0
  248. package/dist/util/fs.d.ts.map +1 -0
  249. package/dist/util/fs.js +269 -0
  250. package/dist/util/fs.js.map +1 -0
  251. package/dist/util/id.d.ts +49 -0
  252. package/dist/util/id.d.ts.map +1 -0
  253. package/dist/util/id.js +87 -0
  254. package/dist/util/id.js.map +1 -0
  255. package/dist/util/lru.d.ts +74 -0
  256. package/dist/util/lru.d.ts.map +1 -0
  257. package/dist/util/lru.js +156 -0
  258. package/dist/util/lru.js.map +1 -0
  259. package/dist/util/queue.d.ts +64 -0
  260. package/dist/util/queue.d.ts.map +1 -0
  261. package/dist/util/queue.js +147 -0
  262. package/dist/util/queue.js.map +1 -0
  263. package/dist/util/text.d.ts +86 -0
  264. package/dist/util/text.d.ts.map +1 -0
  265. package/dist/util/text.js +182 -0
  266. package/dist/util/text.js.map +1 -0
  267. package/package.json +44 -0
  268. package/src/adapter/accounts.ts +758 -0
  269. package/src/adapter/bots.ts +582 -0
  270. package/src/adapter/host.ts +224 -0
  271. package/src/adapter/login.ts +481 -0
  272. package/src/adapter/registry.ts +202 -0
  273. package/src/config/core-config.test.ts +60 -0
  274. package/src/config/core-config.ts +401 -0
  275. package/src/config/schema.test.ts +179 -0
  276. package/src/config/schema.ts +1036 -0
  277. package/src/config/store.test.ts +245 -0
  278. package/src/config/store.ts +506 -0
  279. package/src/config/yaml.ts +119 -0
  280. package/src/http/client.test.ts +568 -0
  281. package/src/http/client.ts +777 -0
  282. package/src/index.ts +106 -0
  283. package/src/kernel/app.test.ts +267 -0
  284. package/src/kernel/app.ts +843 -0
  285. package/src/kernel/kv-sink.ts +58 -0
  286. package/src/kernel/policy.ts +120 -0
  287. package/src/kernel/runtime.test.ts +542 -0
  288. package/src/kernel/runtime.ts +307 -0
  289. package/src/kernel/sql-sink.ts +154 -0
  290. package/src/logger/format.ts +259 -0
  291. package/src/logger/index.ts +427 -0
  292. package/src/logger/rotate.ts +265 -0
  293. package/src/message/segment.test.ts +335 -0
  294. package/src/message/segment.ts +569 -0
  295. package/src/message/split.ts +97 -0
  296. package/src/message/target.ts +48 -0
  297. package/src/pipeline/cooldown.ts +136 -0
  298. package/src/pipeline/dispatch.ts +419 -0
  299. package/src/pipeline/event.ts +735 -0
  300. package/src/pipeline/middleware.test.ts +426 -0
  301. package/src/pipeline/middleware.ts +199 -0
  302. package/src/pipeline/prompt.ts +212 -0
  303. package/src/pipeline/router.test.ts +432 -0
  304. package/src/pipeline/router.ts +575 -0
  305. package/src/platform/detect.ts +178 -0
  306. package/src/platform/paths.ts +147 -0
  307. package/src/platform/system.test.ts +102 -0
  308. package/src/platform/system.ts +289 -0
  309. package/src/plugin/context.test.ts +459 -0
  310. package/src/plugin/context.ts +757 -0
  311. package/src/plugin/define.test.ts +120 -0
  312. package/src/plugin/define.ts +142 -0
  313. package/src/plugin/discover.test.ts +253 -0
  314. package/src/plugin/discover.ts +353 -0
  315. package/src/plugin/events.test.ts +162 -0
  316. package/src/plugin/events.ts +266 -0
  317. package/src/plugin/hooks.ts +373 -0
  318. package/src/plugin/host.test.ts +696 -0
  319. package/src/plugin/host.ts +738 -0
  320. package/src/plugin/market.test.ts +781 -0
  321. package/src/plugin/market.ts +870 -0
  322. package/src/plugin/services.test.ts +135 -0
  323. package/src/plugin/services.ts +261 -0
  324. package/src/plugin/tar.test.ts +158 -0
  325. package/src/plugin/tar.ts +309 -0
  326. package/src/render/registry.test.ts +127 -0
  327. package/src/render/registry.ts +439 -0
  328. package/src/scheduler/index.ts +357 -0
  329. package/src/server/api.test.ts +802 -0
  330. package/src/server/api.ts +869 -0
  331. package/src/server/auth.ts +201 -0
  332. package/src/server/browse.test.ts +193 -0
  333. package/src/server/browse.ts +240 -0
  334. package/src/server/files.ts +97 -0
  335. package/src/server/index.test.ts +566 -0
  336. package/src/server/index.ts +1386 -0
  337. package/src/server/table.ts +351 -0
  338. package/src/store/index.ts +156 -0
  339. package/src/store/json.ts +198 -0
  340. package/src/store/kv.test.ts +307 -0
  341. package/src/store/kv.ts +256 -0
  342. package/src/store/level.ts +167 -0
  343. package/src/store/memory.ts +109 -0
  344. package/src/store/sql.test.ts +204 -0
  345. package/src/store/sql.ts +324 -0
  346. package/src/testing/fake.ts +297 -0
  347. package/src/testing/index.ts +15 -0
  348. package/src/testing/mock-adapter.ts +588 -0
  349. package/src/util/deep.ts +272 -0
  350. package/src/util/defer.ts +211 -0
  351. package/src/util/dispose.ts +169 -0
  352. package/src/util/duration.ts +82 -0
  353. package/src/util/fs.ts +279 -0
  354. package/src/util/id.ts +94 -0
  355. package/src/util/lru.ts +185 -0
  356. package/src/util/queue.ts +167 -0
  357. package/src/util/text.ts +189 -0
@@ -0,0 +1,506 @@
1
+ /**
2
+ * 模块职责:配置仓库(YAML 落盘、schema 校验、细粒度变更通知、外部改动热加载)
3
+ * 依赖方向:依赖 config/schema、config/yaml、util/{fs,deep}、类型包
4
+ * 生命周期:应用级单例;`dispose()` 关闭文件监听
5
+ * 注意事项:四条行为约定 ——
6
+ *
7
+ * **变更通知按叶子路径。** `diffPaths` 算出实际变化的路径,只通知关注它的订阅者,
8
+ * 而不是一有变动就全量重算。
9
+ *
10
+ * **写入用 `atomicWrite`**(临时文件 + rename):直接写会在断电或强杀进程时
11
+ * 留下一份截断的 YAML。
12
+ *
13
+ * **配置写坏了不崩。** 记录错误、退回上一份可用配置(首次加载则用缺省值),
14
+ * 并**绝不覆盖**使用者那份坏文件 —— 他要照着报错自己改。
15
+ *
16
+ * **新增配置项会补进既有文件。** 校验通过后按当前 schema 重写,新选项连同
17
+ * 中文注释一并补入,幂等。否则升级之后新选项在使用者的配置里根本看不见。
18
+ */
19
+ import { watch, type FSWatcher } from "node:fs"
20
+ import { basename, join } from "node:path"
21
+ import type { ConfigChange, ConfigHandle, DeepPartial, DeepReadonly, Disposer, Logger, SchemaDescriptor } from "@yunzai-ng/types"
22
+ import { atomicWrite, ensureDir, readText } from "../util/fs.js"
23
+ import { deepClone, deepMerge, diffPaths, pathAffects, type PlainObject } from "../util/deep.js"
24
+ import { SchemaError, type Schema, type SchemaIssue } from "./schema.js"
25
+ import { parseYaml, serializeYaml } from "./yaml.js"
26
+
27
+ /** 配置名的合法形式:小写字母开头,可含数字、点、横线、下划线 */
28
+ const NAME_RE = /^[a-z][a-z0-9._-]*$/i
29
+
30
+ /** 外部改动的合并窗口(毫秒):编辑器保存常触发多次 fs 事件 */
31
+ const RELOAD_DEBOUNCE = 200
32
+
33
+ /** 配置仓库参数 */
34
+ export interface ConfigStoreOptions {
35
+ /** 配置目录(绝对路径) */
36
+ dir: string
37
+ /** 日志器 */
38
+ logger: Logger
39
+ /** 是否监听文件外部改动,缺省 true */
40
+ watch?: boolean
41
+ }
42
+
43
+ /** 声明配置时的附加信息 */
44
+ export interface DefineConfigOptions {
45
+ /** 文件头注释里显示的名字,缺省用配置名 */
46
+ title?: string
47
+ /** 额外的文件头说明行 */
48
+ notes?: string[]
49
+ }
50
+
51
+ /** 配置文件的对外快照信息(WebUI 列表用) */
52
+ export interface ConfigSummary {
53
+ /** 配置名 */
54
+ name: string
55
+ /** 文件绝对路径 */
56
+ file: string
57
+ /** 显示标题 */
58
+ title: string
59
+ /** 表单描述 */
60
+ schema: SchemaDescriptor
61
+ }
62
+
63
+ /**
64
+ * 单个配置文件的句柄
65
+ *
66
+ * 由 `ConfigStore.define()` 创建,插件通过 `ctx.config` 拿到它。
67
+ */
68
+ export class ConfigFile<T> implements ConfigHandle<T> {
69
+ /** 配置名 */
70
+ readonly name: string
71
+ /** 文件绝对路径 */
72
+ readonly file: string
73
+ /** 表单描述 */
74
+ readonly schema: SchemaDescriptor
75
+ /** 显示标题 */
76
+ readonly title: string
77
+
78
+ /** 校验用的 schema */
79
+ readonly #validator: Schema<T>
80
+ /** 日志器 */
81
+ readonly #logger: Logger
82
+ /** 文件头注释 */
83
+ readonly #header: string[]
84
+ /** 当前值 */
85
+ #value: T
86
+ /** 变更订阅者 */
87
+ readonly #subscribers = new Set<(change: ConfigChange<T>) => void>()
88
+ /** 按路径订阅者 */
89
+ readonly #watchers = new Set<{ path: string; cb: (change: ConfigChange<T>) => void }>()
90
+ /** 最近一次由自己写出的文本,用于识别"这次改动是我自己造成的" */
91
+ #lastWritten: string | undefined
92
+
93
+ /**
94
+ * @param params 构造参数
95
+ */
96
+ constructor(params: {
97
+ /** 配置名 */
98
+ name: string
99
+ /** 文件路径 */
100
+ file: string
101
+ /** 校验 schema */
102
+ validator: Schema<T>
103
+ /** 初始值 */
104
+ value: T
105
+ /** 日志器 */
106
+ logger: Logger
107
+ /** 显示标题 */
108
+ title: string
109
+ /** 文件头注释 */
110
+ header: string[]
111
+ }) {
112
+ this.name = params.name
113
+ this.file = params.file
114
+ this.title = params.title
115
+ this.#validator = params.validator
116
+ this.schema = params.validator.describe()
117
+ this.#value = params.value
118
+ this.#logger = params.logger
119
+ this.#header = params.header
120
+ }
121
+
122
+ /**
123
+ * 取当前配置快照
124
+ * @returns 只读快照;未发生变更时多次调用返回同一对象
125
+ */
126
+ get(): DeepReadonly<T> {
127
+ return this.#value as unknown as DeepReadonly<T>
128
+ }
129
+
130
+ /**
131
+ * 局部更新并落盘
132
+ * @param patch 深合并的补丁
133
+ * @param source 变更来源,WebUI 保存时传 `"webui"`,缺省 `"api"`
134
+ * @returns 更新后的快照
135
+ * @throws SchemaError 校验失败,此时原配置不变、文件不变
136
+ */
137
+ async patch(patch: DeepPartial<T>, source: ConfigChange<T>["source"] = "api"): Promise<DeepReadonly<T>> {
138
+ const merged = deepMerge(this.#value as unknown as PlainObject, patch as unknown as PlainObject)
139
+ return this.#commit(merged, source)
140
+ }
141
+
142
+ /**
143
+ * 整体替换并落盘
144
+ * @param value 完整配置
145
+ * @param source 变更来源,缺省 `"api"`
146
+ * @returns 更新后的快照
147
+ * @throws SchemaError 校验失败
148
+ */
149
+ async replace(value: T, source: ConfigChange<T>["source"] = "api"): Promise<DeepReadonly<T>> {
150
+ return this.#commit(value, source)
151
+ }
152
+
153
+ /**
154
+ * 恢复默认值并落盘
155
+ * @param source 变更来源,缺省 `"api"`
156
+ * @returns 更新后的快照
157
+ */
158
+ async reset(source: ConfigChange<T>["source"] = "api"): Promise<DeepReadonly<T>> {
159
+ return this.#commit(this.#validator.defaults(), source)
160
+ }
161
+
162
+ /**
163
+ * 订阅变更
164
+ * @param cb 回调;抛错只记日志,不影响其他订阅者
165
+ * @returns 取消订阅
166
+ */
167
+ onChange(cb: (change: ConfigChange<T>) => void): Disposer {
168
+ this.#subscribers.add(cb)
169
+ return () => void this.#subscribers.delete(cb)
170
+ }
171
+
172
+ /**
173
+ * 只订阅某个路径(及其子路径)的变更
174
+ *
175
+ * 这是"细粒度失效"的入口:渲染器仅关注 `render.*`,即不会被 `bot.*` 的
176
+ * 变动通知。父子路径双向匹配 —— 修改 `bot` 会通知订阅 `bot.masterQQ` 的一方,
177
+ * 反之亦然。
178
+ * @param path 点分路径,空串等价于 `onChange`
179
+ * @param cb 回调
180
+ * @returns 取消订阅
181
+ */
182
+ watch(path: string, cb: (change: ConfigChange<T>) => void): Disposer {
183
+ const entry = { path, cb }
184
+ this.#watchers.add(entry)
185
+ return () => void this.#watchers.delete(entry)
186
+ }
187
+
188
+ /**
189
+ * 从磁盘加载
190
+ *
191
+ * 出错时保留当前值并返回问题列表,**不会**抛错也**不会**覆盖磁盘文件。
192
+ * @param source 变更来源,用于变更事件
193
+ * @param createIfMissing 文件不存在时是否写出默认配置
194
+ * @returns 校验问题列表(可能只含 warn)
195
+ */
196
+ async load(source: ConfigChange<T>["source"], createIfMissing: boolean): Promise<SchemaIssue[]> {
197
+ const text = await readText(this.file)
198
+
199
+ if (text === undefined) {
200
+ const value = this.#validator.defaults()
201
+ this.#value = value
202
+ if (createIfMissing) await this.#write(value)
203
+ return []
204
+ }
205
+
206
+ // 自己刚写出去的内容,跳过重复解析
207
+ if (text === this.#lastWritten) return []
208
+
209
+ let raw: unknown
210
+ try {
211
+ raw = parseYaml(text)
212
+ } catch (err) {
213
+ this.#logger.error(`配置 ${this.name} 的 YAML 语法有误,已沿用上一份可用配置`, err)
214
+ return [{ path: "", message: err instanceof Error ? err.message : String(err), severity: "error" }]
215
+ }
216
+
217
+ const result = this.#validator.safeParse(raw)
218
+ if (!result.ok) {
219
+ this.#logger.error(`配置 ${this.name} 校验失败,已沿用上一份可用配置:\n${formatIssues(result.issues)}`)
220
+ return result.issues
221
+ }
222
+
223
+ for (const issue of result.issues) {
224
+ this.#logger.warn(`配置 ${this.name} 的 ${issue.path}:${issue.message}`)
225
+ }
226
+
227
+ const prev = this.#value
228
+ this.#value = result.value
229
+
230
+ // 按当前 schema 回写:补上新增选项与注释。序列化是确定性的,故此操作幂等。
231
+ const canonical = this.#render(result.value)
232
+ if (canonical !== text) await this.#write(result.value)
233
+
234
+ const paths = diffPaths(prev, result.value)
235
+ if (paths.length > 0) this.#emit(prev, result.value, paths, source)
236
+
237
+ return result.issues
238
+ }
239
+
240
+ /**
241
+ * 校验、落盘、通知
242
+ * @param candidate 候选值
243
+ * @param source 变更来源
244
+ * @returns 新快照
245
+ * @throws SchemaError 校验失败
246
+ */
247
+ async #commit(candidate: unknown, source: ConfigChange<T>["source"]): Promise<DeepReadonly<T>> {
248
+ const result = this.#validator.safeParse(candidate)
249
+ if (!result.ok) throw new SchemaError(result.issues)
250
+
251
+ const prev = this.#value
252
+ const next = result.value
253
+ const paths = diffPaths(prev, next)
254
+
255
+ // 无实质变化就不写盘,避免 WebUI 里点一下保存就产生一次磁盘写入与一轮通知
256
+ if (paths.length === 0) return this.get()
257
+
258
+ this.#value = next
259
+ await this.#write(next)
260
+ this.#emit(prev, next, paths, source)
261
+ return this.get()
262
+ }
263
+
264
+ /**
265
+ * 序列化为 YAML 文本
266
+ * @param value 配置值
267
+ * @returns YAML 文本
268
+ */
269
+ #render(value: T): string {
270
+ return serializeYaml(value, { header: this.#header, descriptor: this.schema })
271
+ }
272
+
273
+ /**
274
+ * 原子写盘
275
+ * @param value 配置值
276
+ */
277
+ async #write(value: T): Promise<void> {
278
+ const text = this.#render(value)
279
+ this.#lastWritten = text
280
+ await atomicWrite(this.file, text)
281
+ }
282
+
283
+ /**
284
+ * 派发变更事件
285
+ * @param prev 旧值
286
+ * @param next 新值
287
+ * @param paths 变化路径
288
+ * @param source 变更来源
289
+ */
290
+ #emit(prev: T, next: T, paths: string[], source: ConfigChange<T>["source"]): void {
291
+ const change: ConfigChange<T> = {
292
+ prev: prev as unknown as DeepReadonly<T>,
293
+ next: next as unknown as DeepReadonly<T>,
294
+ paths,
295
+ source
296
+ }
297
+
298
+ for (const cb of this.#subscribers) this.#invoke(cb, change)
299
+ for (const entry of this.#watchers) {
300
+ if (paths.some(p => pathAffects(p, entry.path))) this.#invoke(entry.cb, change)
301
+ }
302
+ }
303
+
304
+ /**
305
+ * 调用订阅回调并隔离错误
306
+ * @param cb 回调
307
+ * @param change 变更事件
308
+ */
309
+ #invoke(cb: (change: ConfigChange<T>) => void, change: ConfigChange<T>): void {
310
+ try {
311
+ cb(change)
312
+ } catch (err) {
313
+ this.#logger.error(`配置 ${this.name} 的变更回调抛错`, err)
314
+ }
315
+ }
316
+ }
317
+
318
+ /**
319
+ * 把校验问题列成多行文本
320
+ * @param issues 问题列表
321
+ * @returns 多行文本
322
+ */
323
+ function formatIssues(issues: readonly SchemaIssue[]): string {
324
+ return issues.map(i => ` · ${i.path === "" ? "(根)" : i.path}:${i.message}`).join("\n")
325
+ }
326
+
327
+ /**
328
+ * 配置仓库
329
+ *
330
+ * 一个进程一个实例,管住 `config/` 目录下所有 YAML 与唯一一个目录监听器。
331
+ */
332
+ export class ConfigStore {
333
+ /** 配置目录 */
334
+ readonly #dir: string
335
+ /** 日志器 */
336
+ readonly #logger: Logger
337
+ /** 是否监听外部改动 */
338
+ readonly #watchEnabled: boolean
339
+ /** 名字 → 配置文件 */
340
+
341
+ readonly #files = new Map<string, ConfigFile<any>>()
342
+ /** 目录监听器 */
343
+ #watcher: FSWatcher | undefined
344
+ /** 重载去抖定时器 */
345
+ readonly #timers = new Map<string, NodeJS.Timeout>()
346
+
347
+ /**
348
+ * @param opts 仓库参数
349
+ */
350
+ constructor(opts: ConfigStoreOptions) {
351
+ this.#dir = opts.dir
352
+ this.#logger = opts.logger.child({ scope: "config" })
353
+ this.#watchEnabled = opts.watch !== false
354
+ }
355
+
356
+ /** 配置目录 */
357
+ get dir(): string {
358
+ return this.#dir
359
+ }
360
+
361
+ /**
362
+ * 声明一份配置
363
+ *
364
+ * 同名重复声明会抛错 —— 两个插件抢同一个文件是必须暴露的 bug,
365
+ * 而不是让后者悄悄覆盖前者。
366
+ * @param name 配置名,同时是文件名(`<name>.yaml`)
367
+ * @param validator schema
368
+ * @param opts 附加信息
369
+ * @returns 配置句柄
370
+ * @throws 名字非法或重复声明时
371
+ */
372
+ async define<T>(name: string, validator: Schema<T>, opts: DefineConfigOptions = {}): Promise<ConfigFile<T>> {
373
+ if (!NAME_RE.test(name)) throw new Error(`配置名 ${name} 不合法:需以字母开头,只含字母数字与 . - _`)
374
+ if (this.#files.has(name)) throw new Error(`配置 ${name} 已被声明,请换个名字`)
375
+
376
+ await ensureDir(this.#dir)
377
+
378
+ const title = opts.title ?? name
379
+ const header = [
380
+ `Yunzai NG 配置:${title}`,
381
+ "本文件由 schema 自动生成:保存时会重建注释,但不会丢弃任何配置值。",
382
+ "可以直接手改,保存后框架会自动重新加载;改错了会在日志里指出具体字段。",
383
+ ...(opts.notes ?? [])
384
+ ]
385
+
386
+ const file = new ConfigFile<T>({
387
+ name,
388
+ file: join(this.#dir, `${name}.yaml`),
389
+ validator,
390
+ value: validator.defaults(),
391
+ logger: this.#logger,
392
+ title,
393
+ header
394
+ })
395
+
396
+ this.#files.set(name, file)
397
+ await file.load("default", true)
398
+ return file
399
+ }
400
+
401
+ /**
402
+ * 取已声明的配置
403
+ * @param name 配置名
404
+ * @returns 配置句柄;未声明时 undefined
405
+ */
406
+
407
+ /**
408
+ *
409
+ */
410
+ get(name: string): ConfigFile<any> | undefined {
411
+ return this.#files.get(name)
412
+ }
413
+
414
+ /**
415
+ * 移除一份配置声明(插件卸载时调用)
416
+ *
417
+ * 只解除内存中的登记,不删磁盘文件 —— 插件重装后配置还在。
418
+ * @param name 配置名
419
+ */
420
+ remove(name: string): void {
421
+ this.#files.delete(name)
422
+ }
423
+
424
+ /**
425
+ * 列出全部配置
426
+ * @returns 配置摘要数组,按名字排序
427
+ */
428
+ list(): ConfigSummary[] {
429
+ return [...this.#files.values()]
430
+ .map(file => ({ name: file.name, file: file.file, title: file.title, schema: file.schema }))
431
+ .sort((a, b) => a.name.localeCompare(b.name))
432
+ }
433
+
434
+ /**
435
+ * 开始监听配置目录
436
+ *
437
+ * 监听**目录**而不是每个文件:原子写用的是 rename,被替换的文件 inode 会变,
438
+ * 盯着文件的 watcher 在 Windows 上会失效。顺带只需一个监听器。
439
+ * @returns 取消监听
440
+ */
441
+ startWatching(): Disposer {
442
+ if (!this.#watchEnabled || this.#watcher) return () => undefined
443
+
444
+ try {
445
+ // persistent: false —— 配置监听不应该拖着进程不让退出
446
+ this.#watcher = watch(this.#dir, { persistent: false }, (_event, filename) => {
447
+ if (!filename) return
448
+ const base = basename(String(filename))
449
+ const match = /^(.+)\.ya?ml$/i.exec(base)
450
+ if (!match) return // 原子写留下的 .tmp-xxxx 之类,忽略
451
+ this.#scheduleReload(match[1]!)
452
+ })
453
+ this.#watcher.on("error", err => this.#logger.warn("配置目录监听出错,热加载已停止", err))
454
+ } catch (err) {
455
+ this.#logger.warn("无法监听配置目录,配置将只在启动时加载", err)
456
+ }
457
+
458
+ return () => this.dispose()
459
+ }
460
+
461
+ /** 停止监听并清理定时器 */
462
+ dispose(): void {
463
+ for (const timer of this.#timers.values()) clearTimeout(timer)
464
+ this.#timers.clear()
465
+ this.#watcher?.close()
466
+ this.#watcher = undefined
467
+ }
468
+
469
+ /**
470
+ * 去抖后重载某个配置
471
+ * @param name 配置名
472
+ */
473
+ #scheduleReload(name: string): void {
474
+ const file = this.#files.get(name)
475
+ if (!file) return
476
+
477
+ const existing = this.#timers.get(name)
478
+ if (existing) clearTimeout(existing)
479
+
480
+ const timer = setTimeout(() => {
481
+ this.#timers.delete(name)
482
+ void file
483
+ .load("file", false)
484
+ .then(issues => {
485
+ if (issues.some(i => i.severity === "error")) return
486
+ this.#logger.info(`配置 ${name} 已重新加载`)
487
+ })
488
+ .catch((err: unknown) => this.#logger.error(`重新加载配置 ${name} 失败`, err))
489
+ }, RELOAD_DEBOUNCE)
490
+
491
+ if (typeof timer.unref === "function") timer.unref()
492
+ this.#timers.set(name, timer)
493
+ }
494
+ }
495
+
496
+ /**
497
+ * 创建配置仓库
498
+ * @param opts 仓库参数
499
+ * @returns 配置仓库
500
+ */
501
+ export function createConfigStore(opts: ConfigStoreOptions): ConfigStore {
502
+ return new ConfigStore(opts)
503
+ }
504
+
505
+ /** 深拷贝一份配置快照,供需要可变副本的场景使用 */
506
+ export const cloneConfig = deepClone
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 模块职责:把配置对象序列化成**带中文注释**的 YAML,以及从 YAML 反序列化
3
+ * 依赖方向:依赖 yaml 包与类型包
4
+ * 生命周期:纯函数
5
+ * 注意事项:注释由 schema 的 `title`/`description`/枚举候选自动生成,不另维护一份
6
+ * YAML 模板 —— 两份东西的默认值会各自漂移,而使用者拿到的注释会常年过期。
7
+ *
8
+ * 代价是**保存时会重写整个文件,用户手写的注释不被保留**。这是有意的
9
+ * 取舍:配置的主编辑面是 WebUI,文件是产物;换来的是注释永远与当前版本
10
+ * 一致。文件里的**值**当然完整保留。
11
+ */
12
+ import { Document, isMap, isSeq, parse, type Node } from "yaml"
13
+ import type { SchemaDescriptor } from "@yunzai-ng/types"
14
+
15
+ /** 序列化选项 */
16
+ export interface SerializeOptions {
17
+ /** 文件头注释(每行前会自动加 `# `) */
18
+ header?: string[]
19
+ /** 表单描述,用于生成字段注释 */
20
+ descriptor?: SchemaDescriptor
21
+ }
22
+
23
+ /**
24
+ * 解析 YAML 文本
25
+ * @param text YAML 文本
26
+ * @returns 解析结果;空文本返回 undefined
27
+ * @throws 语法错误时抛出,错误信息里带行号
28
+ */
29
+ export function parseYaml(text: string): unknown {
30
+ if (text.trim() === "") return undefined
31
+ return parse(text, { merge: true })
32
+ }
33
+
34
+ /**
35
+ * 把配置对象序列化成带注释的 YAML
36
+ * @param value 配置值
37
+ * @param opts 序列化选项
38
+ * @returns YAML 文本(以换行结尾)
39
+ */
40
+ export function serializeYaml(value: unknown, opts: SerializeOptions = {}): string {
41
+ const doc = new Document(value)
42
+
43
+ if (opts.header && opts.header.length > 0) {
44
+ doc.commentBefore = opts.header.map(line => ` ${line}`).join("\n")
45
+ }
46
+ if (opts.descriptor && doc.contents) {
47
+ annotate(doc.contents, opts.descriptor)
48
+ }
49
+
50
+ // lineWidth: 0 关闭自动折行 —— 折行后的长 URL、cookie 在文本编辑器里很难改
51
+ return doc.toString({ lineWidth: 0, indent: 2, nullStr: "~" })
52
+ }
53
+
54
+ /**
55
+ * 给 YAML 节点递归挂注释
56
+ * @param node YAML 节点
57
+ * @param descriptor 对应的表单描述
58
+ */
59
+ function annotate(node: Node, descriptor: SchemaDescriptor): void {
60
+ if (isMap(node) && descriptor.properties) {
61
+ for (const item of node.items) {
62
+ const key = item.key
63
+ if (typeof key !== "object" || key === null || !("value" in key)) continue
64
+ const name = String((key as { value: unknown }).value)
65
+ const child = descriptor.properties[name]
66
+ if (!child) continue
67
+
68
+ const comment = buildComment(child)
69
+ if (comment) (key as { commentBefore?: string }).commentBefore = comment
70
+
71
+ const valueNode = item.value
72
+ if (valueNode && typeof valueNode === "object") annotate(valueNode as Node, child)
73
+ }
74
+ return
75
+ }
76
+
77
+ if (isSeq(node) && descriptor.items) {
78
+ // 只给第一个元素挂注释:每一项都重复一遍会把列表淹没
79
+ const first = node.items[0]
80
+ if (first && typeof first === "object") annotate(first as Node, descriptor.items)
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 由字段描述生成注释文本
86
+ * @param descriptor 字段描述
87
+ * @returns 注释文本;无可写内容时返回 undefined
88
+ */
89
+ function buildComment(descriptor: SchemaDescriptor): string | undefined {
90
+ const lines: string[] = []
91
+
92
+ if (descriptor.title) lines.push(descriptor.title)
93
+ if (descriptor.description) lines.push(...descriptor.description.split("\n"))
94
+
95
+ if (descriptor.enum && descriptor.enum.length > 0) {
96
+ const items = descriptor.enum.map(item => {
97
+ const label = item.label ?? item.description
98
+ return label ? `${String(item.value)}(${label})` : String(item.value)
99
+ })
100
+ lines.push(`可选值:${items.join(" / ")}`)
101
+ }
102
+
103
+ if (descriptor.widget === "duration") lines.push("格式:毫秒数,或 30s / 5m / 2h / 7d")
104
+ if (descriptor.widget === "cron") lines.push("格式:cron 表达式,如 0 0 8 * * *")
105
+ if (descriptor.secret) lines.push("敏感信息,请勿分享本文件")
106
+
107
+ if (descriptor.min !== undefined || descriptor.max !== undefined) {
108
+ const range =
109
+ descriptor.min !== undefined && descriptor.max !== undefined
110
+ ? `${descriptor.min} ~ ${descriptor.max}`
111
+ : descriptor.min !== undefined
112
+ ? `≥ ${descriptor.min}`
113
+ : `≤ ${descriptor.max}`
114
+ lines.push(`取值范围:${range}`)
115
+ }
116
+
117
+ if (lines.length === 0) return undefined
118
+ return lines.map(line => ` ${line}`).join("\n")
119
+ }