@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,870 @@
1
+ /**
2
+ * 模块职责:插件市场 —— 索引获取与缓存、插件安装、卸载与更新
3
+ * 依赖方向:依赖 http 客户端契约、logger 契约、`plugin/tar.ts` 与 `util/fs`;
4
+ * 不认识插件宿主,也不认识面板 —— 安装完成后由调用方决定何时加载
5
+ * 生命周期:随应用装配创建一次,索引缓存驻留内存并落盘一份,随应用停机一同丢弃
6
+ * 注意事项:四项与安全相关的约定,修改本文件前须一并考虑:
7
+ *
8
+ * 1) **写入范围限定在插件目录之内。** 插件名先经白名单校验,再经
9
+ * `joinWithin` 求值;两者任一不通过即拒绝,不做纠正后重试。
10
+ * 2) **先下载到临时目录,校验通过后再移入插件目录。** 中途失败时插件目录
11
+ * 保持原状,不会留下半个插件 —— 半个插件会在下次启动时被扫描到并加载失败。
12
+ * 3) **不自动执行包管理器。** 安装第三方代码的依赖等于执行其 install 脚本,
13
+ * 该动作须由用户在知情前提下自行发起;此处仅在返回值中标记需要安装依赖。
14
+ * 4) **索引为不可信输入。** 逐字段校验类型,缺字段的条目整条丢弃而非补默认值:
15
+ * 索引写错时应当表现为"该插件不出现在列表里",而不是出现一个装不上的条目。
16
+ */
17
+ import { execFile } from "node:child_process"
18
+ import { cp, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
19
+ import { dirname, join } from "node:path"
20
+ import type { HttpClient, Logger } from "@yunzai-ng/types"
21
+ import { isDirectory, isFile } from "../util/fs.js"
22
+ import { extractTarGz, joinWithin, singleRoot } from "./tar.js"
23
+
24
+ /** 合法插件名:字母或数字开头,其余可含字母、数字、点、下划线与连字符 */
25
+ const NAME_RE = /^[a-z\d][a-z\d._-]*$/i
26
+
27
+ /** 安装归档的体积上限 */
28
+ const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
29
+
30
+ /**
31
+ * 克隆与下载的超时毫秒
32
+ *
33
+ * 与索引请求分开取值:索引是一份几十 KB 的 JSON,超时设短便于尽快切换到下一个源;
34
+ * 而一次插件传输可达数十 MB,在窄带链路上按索引超时衡量会把正常的下载判为失败。
35
+ */
36
+ const TRANSFER_TIMEOUT_MS = 5 * 60 * 1000
37
+
38
+ /** 只读本地仓库的 git 命令超时毫秒;不含网络往返,故与传输超时分开取值 */
39
+ const GIT_LOCAL_TIMEOUT_MS = 10_000
40
+
41
+
42
+ /** 判定"看起来是一个插件"时接受的入口文件 */
43
+ const ENTRY_FILES: readonly string[] = ["index.js", "index.mjs", "index.cjs", "dist/index.js", "dist/index.mjs"]
44
+
45
+ /** 应用镜像前缀的主机白名单 */
46
+ const MIRRORED_HOSTS: readonly string[] = ["github.com", "raw.githubusercontent.com", "codeload.github.com"]
47
+
48
+ /** 安装来源 */
49
+ export interface MarketInstallSpec {
50
+ /** 取源方式 */
51
+ readonly type: "git" | "tarball"
52
+ /** 仓库地址或归档地址 */
53
+ readonly url: string
54
+ /** git 分支,缺省由远端决定 */
55
+ readonly branch?: string
56
+ }
57
+
58
+ /** 索引中的一个插件条目 */
59
+ export interface MarketEntry {
60
+ /** 插件名,同时是安装目录名与配置文件名 */
61
+ readonly name: string
62
+ /** 展示标题 */
63
+ readonly title: string
64
+ /** 一句话说明 */
65
+ readonly description: string
66
+ /** 作者 */
67
+ readonly author?: string
68
+ /** 索引声明的版本 */
69
+ readonly version?: string
70
+ /** 项目主页 */
71
+ readonly homepage?: string
72
+ /** 分类标签 */
73
+ readonly tags: readonly string[]
74
+ /** 是否为官方维护 */
75
+ readonly official: boolean
76
+ /** 要求的最低内核版本 */
77
+ readonly minCore?: string
78
+ /** 安装来源 */
79
+ readonly install: MarketInstallSpec
80
+ /** 该条目来自哪个索引地址 */
81
+ readonly source: string
82
+ }
83
+
84
+ /** 附带本地安装状态的条目 */
85
+ export interface MarketListing extends MarketEntry {
86
+ /** 插件目录下是否已存在同名目录 */
87
+ readonly installed: boolean
88
+ }
89
+
90
+ /** 一次索引获取的结果 */
91
+ export interface MarketSourceResult {
92
+ /** 索引地址 */
93
+ readonly url: string
94
+ /** 是否取到并解析成功 */
95
+ readonly ok: boolean
96
+ /** 失败原因 */
97
+ readonly error?: string
98
+ /** 该索引贡献的条目数 */
99
+ readonly count: number
100
+ }
101
+
102
+ /** 索引快照 */
103
+ export interface MarketSnapshot {
104
+ /** 获取时间戳 */
105
+ readonly fetchedAt: number
106
+ /** 是否来自缓存而非本次网络请求 */
107
+ readonly cached: boolean
108
+ /** 逐个索引的获取结果 */
109
+ readonly sources: readonly MarketSourceResult[]
110
+ /** 合并去重后的条目,按名称排序 */
111
+ readonly plugins: readonly MarketListing[]
112
+ }
113
+
114
+ /**
115
+ * 一次安装的取源方式
116
+ *
117
+ * `pull` 是更新独有的一种:目录已是一个 git 仓库,就地拉取而非重新下载。
118
+ * 与 `git` 分开记录,因为两者对使用者的可见后果不同 —— `pull` 保住了
119
+ * `node_modules` 与目录内的其他文件,`git` 是一份全新的目录。
120
+ */
121
+ export type InstallVia = MarketInstallSpec["type"] | "pull"
122
+
123
+ /** 一次安装的结果 */
124
+ export interface InstallResult {
125
+ /** 插件名 */
126
+ readonly name: string
127
+ /** 安装目录 */
128
+ readonly dir: string
129
+ /** 取源方式 */
130
+ readonly via: InstallVia
131
+ /** package.json 中声明的版本;无 package.json 时为索引声明的版本 */
132
+ readonly version: string
133
+ /** 是否声明了运行时依赖且尚未安装 */
134
+ readonly needsDependencies: boolean
135
+ /**
136
+ * 就地拉取时的旧版本号,仅 `via` 为 `pull` 时存在
137
+ *
138
+ * 供面板显示「1.0.0 → 1.1.0」。重新安装那条路上没有它:旧目录已被删除,
139
+ * 而在删除之前去读一遍它的 package.json 只为显示一个数字,不值得多一次失败点。
140
+ */
141
+ readonly fromVersion?: string
142
+ /**
143
+ * 就地拉取时是否确实有新提交
144
+ *
145
+ * `false` 表示已是最新。面板据此说「已是最新版本」而非「已更新」——
146
+ * 后者会让使用者以为拉到了东西,转而去找那个并不存在的变化。
147
+ */
148
+ readonly changed?: boolean
149
+ /**
150
+ * 此后的更新会走哪条路
151
+ *
152
+ * 由 `via` 纯推导:git 克隆与就地拉取都留下 `.git`,故此后可 `fetch` + `reset`,
153
+ * 那条路**保住包目录里的 `node_modules`**;归档装出来的目录没有 `.git`,每次更新都是
154
+ * 整目录重下重装,依赖跟着重装一遍。
155
+ *
156
+ * 这个差别在装完的那一刻不可见,却决定此后每一次更新的代价(依赖动辄几十兆),
157
+ * 故在装完就说出来 —— 而不是等使用者问「为什么我的更新比别人慢」。
158
+ */
159
+ readonly updatable: "pull" | "reinstall"
160
+ }
161
+
162
+ /** 市场行为的可配置项,由内核配置提供 */
163
+ export interface MarketSettings {
164
+ /** 索引地址列表,靠前者优先 */
165
+ readonly sources: readonly string[]
166
+ /** 镜像前缀,为空表示直连 */
167
+ readonly mirror: string
168
+ /** 索引缓存生存期毫秒 */
169
+ readonly cacheTtl: number
170
+ /** 单次网络请求超时毫秒 */
171
+ readonly timeout: number
172
+ }
173
+
174
+ /**
175
+ * 执行一次 git 命令的函数形态
176
+ *
177
+ * 抽成类型是为了让测试替换它。就地拉取那条路的正确性几乎全在**命令序列**上 ——
178
+ * `stash` 必须先于 `reset --hard`(次序反了就是数据丢失)、必须是 `fetch` 而非
179
+ * `pull`、`fetch` 的地址必须每次现算。这些都不是真网络测得出来的:一次真实的拉取
180
+ * 只告诉你"最后目录对了",而上述任一条错掉都仍可能得到一个对的目录,直到某个
181
+ * 使用者的改动被无声吃掉。故留这道缝,用例据此逐条钉住实际下发的参数。
182
+ * @param args 命令参数
183
+ * @param cwd 工作目录
184
+ * @param timeout 超时毫秒
185
+ * @returns 标准输出
186
+ */
187
+ export type GitRunner = (args: readonly string[], cwd: string, timeout: number) => Promise<string>
188
+
189
+ /** 构造插件市场所需的依赖 */
190
+ export interface MarketDeps {
191
+ /** HTTP 客户端 */
192
+ readonly http: HttpClient
193
+ /** 日志器 */
194
+ readonly logger: Logger
195
+ /** 插件安装目录 */
196
+ readonly pluginsDir: string
197
+ /** 临时目录,下载与解包在此完成 */
198
+ readonly tempDir: string
199
+ /** 索引缓存文件路径 */
200
+ readonly cacheFile: string
201
+ /** 读取当前配置 */
202
+ readonly settings: () => MarketSettings
203
+ /** 当前内核版本,用于 `minCore` 判定 */
204
+ readonly coreVersion: string
205
+ /** 执行 git 命令,缺省调用本机的 git */
206
+ readonly git?: GitRunner
207
+ }
208
+
209
+ /**
210
+ * 校验插件名
211
+ *
212
+ * 该校验先于路径拼接执行。路径越界校验能挡住 `../`,但挡不住 `.git`、`node_modules`
213
+ * 这类落在目录之内却会破坏运行环境的名称,因此此处采用白名单而非黑名单。
214
+ * @param name 待校验的名称
215
+ * @returns 名称本身
216
+ * @throws 名称为空、含非法字符或为保留名时
217
+ */
218
+ export function assertPluginName(name: string): string {
219
+ if (!NAME_RE.test(name)) throw new Error(`插件名不合法:${name}`)
220
+ if (name === "node_modules" || name.startsWith(".")) throw new Error(`插件名为保留名:${name}`)
221
+ return name
222
+ }
223
+
224
+ /**
225
+ * 比较两个版本号
226
+ *
227
+ * 仅比较点分数值段,忽略预发布标识。内核版本与 `minCore` 都由本项目自己产出,
228
+ * 不需要完整的 semver 语义,引入依赖的收益低于其体积。
229
+ * @param a 左操作数
230
+ * @param b 右操作数
231
+ * @returns a 大于 b 时为正,小于时为负,相等时为零
232
+ */
233
+ export function compareVersion(a: string, b: string): number {
234
+ const left = a.split("-")[0]?.split(".") ?? []
235
+ const right = b.split("-")[0]?.split(".") ?? []
236
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
237
+ const x = Number.parseInt(left[i] ?? "0", 10) || 0
238
+ const y = Number.parseInt(right[i] ?? "0", 10) || 0
239
+ if (x !== y) return x - y
240
+ }
241
+ return 0
242
+ }
243
+
244
+ /**
245
+ * 对白名单主机的地址应用镜像前缀
246
+ *
247
+ * 只处理白名单内的主机:镜像站点通常只代理 GitHub,把任意地址都套上前缀会让
248
+ * 自建源无法访问,且更难排查 —— 用户看到的会是镜像站的 404 而不是自己的地址写错。
249
+ * @param url 原始地址
250
+ * @param mirror 镜像前缀,空串表示直连
251
+ * @returns 应用镜像后的地址
252
+ */
253
+ export function applyMirror(url: string, mirror: string): string {
254
+ const prefix = mirror.trim().replace(/\/+$/, "")
255
+ if (prefix === "") return url
256
+ let host: string
257
+ try {
258
+ host = new URL(url).host
259
+ } catch {
260
+ return url
261
+ }
262
+ if (!MIRRORED_HOSTS.includes(host)) return url
263
+ return `${prefix}/${url}`
264
+ }
265
+
266
+ /**
267
+ * 把 git 仓库地址换算为 codeload 归档地址
268
+ *
269
+ * git 不可用时的退路。仅识别 GitHub:其归档地址可由仓库地址推导,而通用的
270
+ * git 服务端不存在统一的归档端点,推断得出的地址仅会导致一次无谓的失败请求。
271
+ * @param url 仓库地址
272
+ * @param branch 分支名,缺省取 HEAD
273
+ * @returns 归档地址;无法推导时 undefined
274
+ */
275
+ export function tarballFromGit(url: string, branch = "HEAD"): string | undefined {
276
+ const match = /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(url)
277
+ if (!match) return undefined
278
+ return `https://codeload.github.com/${match[1]}/${match[2]}/tar.gz/${branch}`
279
+ }
280
+
281
+ /**
282
+ * 取一个字符串字段
283
+ * @param raw 记录
284
+ * @param key 字段名
285
+ * @returns 去除首尾空白后的值;类型不符或为空时 undefined
286
+ */
287
+ function text(raw: Record<string, unknown>, key: string): string | undefined {
288
+ const value = raw[key]
289
+ if (typeof value !== "string") return undefined
290
+ const trimmed = value.trim()
291
+ return trimmed === "" ? undefined : trimmed
292
+ }
293
+
294
+ /**
295
+ * 解析安装来源
296
+ * @param raw 条目中的 `install` 字段
297
+ * @returns 安装来源;字段缺失或类型不符时 undefined
298
+ */
299
+ function parseInstall(raw: unknown): MarketInstallSpec | undefined {
300
+ if (typeof raw !== "object" || raw === null) return undefined
301
+ const record = raw as Record<string, unknown>
302
+ const type = text(record, "type")
303
+ const url = text(record, "url")
304
+ if (url === undefined) return undefined
305
+ if (!/^https?:\/\//.test(url)) return undefined
306
+ if (type !== "git" && type !== "tarball") return undefined
307
+ const branch = text(record, "branch")
308
+ return { type, url, ...(branch === undefined ? {} : { branch }) }
309
+ }
310
+
311
+ /**
312
+ * 把一条索引记录解析为条目
313
+ *
314
+ * 任一必填字段不合法即返回 undefined,由调用方整条丢弃。
315
+ * @param raw 索引记录
316
+ * @param source 该记录所属的索引地址
317
+ * @returns 条目;记录不合法时 undefined
318
+ */
319
+ function parseEntry(raw: unknown, source: string): MarketEntry | undefined {
320
+ if (typeof raw !== "object" || raw === null) return undefined
321
+ const record = raw as Record<string, unknown>
322
+ const name = text(record, "name")
323
+ if (name === undefined || !NAME_RE.test(name) || name.startsWith(".")) return undefined
324
+ const install = parseInstall(record.install)
325
+ if (install === undefined) return undefined
326
+ const tags = Array.isArray(record.tags) ? record.tags.filter((tag): tag is string => typeof tag === "string") : []
327
+ const author = text(record, "author")
328
+ const version = text(record, "version")
329
+ const homepage = text(record, "homepage")
330
+ const minCore = text(record, "minCore")
331
+ return {
332
+ name,
333
+ title: text(record, "title") ?? name,
334
+ description: text(record, "description") ?? "",
335
+ tags,
336
+ official: record.official === true,
337
+ install,
338
+ source,
339
+ ...(author === undefined ? {} : { author }),
340
+ ...(version === undefined ? {} : { version }),
341
+ ...(homepage === undefined ? {} : { homepage }),
342
+ ...(minCore === undefined ? {} : { minCore })
343
+ }
344
+ }
345
+
346
+ /**
347
+ * 解析一份索引文档
348
+ *
349
+ * 接受两种形状:`{ plugins: [...] }` 与顶层直接是数组。后者便于用户把一个手写的
350
+ * 数组挂到静态文件服务上作为私有源。
351
+ * @param raw 已解析的 JSON
352
+ * @param source 索引地址
353
+ * @returns 合法条目列表
354
+ * @throws 文档既不是数组也不含 `plugins` 数组时
355
+ */
356
+ export function parseIndex(raw: unknown, source: string): MarketEntry[] {
357
+ const list = Array.isArray(raw)
358
+ ? raw
359
+ : typeof raw === "object" && raw !== null && Array.isArray((raw as { plugins?: unknown }).plugins)
360
+ ? (raw as { plugins: unknown[] }).plugins
361
+ : undefined
362
+ if (list === undefined) throw new Error("索引格式不符:期望数组或含 plugins 数组的对象")
363
+ const entries: MarketEntry[] = []
364
+ for (const item of list) {
365
+ const entry = parseEntry(item, source)
366
+ if (entry !== undefined) entries.push(entry)
367
+ }
368
+ return entries
369
+ }
370
+
371
+ /**
372
+ * 调用本机 git 执行一次命令,`MarketDeps.git` 的缺省实现
373
+ *
374
+ * 参数以数组传递,不经 shell,因此索引里的地址不会被当作命令解释。
375
+ * `GIT_TERMINAL_PROMPT=0` 与 `GIT_ASKPASS` 一并关闭凭据提示:市场安装发生在
376
+ * 无人值守的请求处理过程中,弹出的提示无人应答,只会让请求挂到超时。
377
+ * @param args 命令参数
378
+ * @param cwd 工作目录
379
+ * @param timeout 超时毫秒
380
+ * @returns 标准输出
381
+ * @throws 命令不存在、超时或退出码非零时
382
+ */
383
+ const runGit: GitRunner = (args, cwd, timeout) => {
384
+ return new Promise((resolve, reject) => {
385
+ execFile(
386
+ "git",
387
+ [...args],
388
+ { cwd, timeout, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "echo" }, windowsHide: true },
389
+ (err, stdout, stderr) => {
390
+ if (err) reject(new Error(`git ${args[0]} 失败:${stderr.trim() || err.message}`))
391
+ else resolve(stdout)
392
+ }
393
+ )
394
+ })
395
+ }
396
+
397
+ /** 磁盘缓存的文档结构 */
398
+ interface CacheFile {
399
+ /** 获取时间戳 */
400
+ fetchedAt: number
401
+ /** 条目列表 */
402
+ entries: MarketEntry[]
403
+ }
404
+
405
+ /**
406
+ * 插件市场
407
+ *
408
+ * 索引缓存同时驻留内存与磁盘:内存缓存服务于同一次运行内的反复浏览,磁盘缓存
409
+ * 服务于重启后的首次打开 —— 面板在无网络时也应当能列出上次看到的插件,
410
+ * 而不是显示一个空列表。
411
+ */
412
+ export class PluginMarket {
413
+ /** 依赖 */
414
+ readonly #deps: MarketDeps
415
+
416
+ /** 内存中的索引条目 */
417
+ #entries: MarketEntry[] = []
418
+
419
+ /** 内存索引的获取时间,0 表示尚未取到 */
420
+ #fetchedAt = 0
421
+
422
+ /** 逐源结果,随索引一同更新 */
423
+ #sources: MarketSourceResult[] = []
424
+
425
+ /** 正在进行的索引获取,用于合并并发请求 */
426
+ #inflight: Promise<void> | undefined
427
+
428
+ /** git 可用性探测结果 */
429
+ #gitAvailable: boolean | undefined
430
+
431
+ /** 执行 git 命令,未注入时调用本机 git */
432
+ readonly #git: GitRunner
433
+
434
+ /**
435
+ * @param deps 依赖
436
+ */
437
+ constructor(deps: MarketDeps) {
438
+ this.#deps = deps
439
+ this.#git = deps.git ?? runGit
440
+ }
441
+
442
+ /**
443
+ * 列出市场中的插件
444
+ *
445
+ * 缓存未过期时不发起网络请求。安装状态每次都重新读取文件系统:用户可能在面板
446
+ * 之外手动删掉了插件目录,沿用缓存里的状态会让"卸载"按钮对着一个不存在的目录。
447
+ * @param force 忽略缓存,强制重新获取
448
+ * @returns 索引快照
449
+ */
450
+ async list(force = false): Promise<MarketSnapshot> {
451
+ const settings = this.#deps.settings()
452
+ const fresh = this.#fetchedAt > 0 && Date.now() - this.#fetchedAt < settings.cacheTtl
453
+ const cached = !force && fresh
454
+ if (force || !fresh) await this.#refresh(force)
455
+ const plugins: MarketListing[] = []
456
+ for (const entry of this.#entries) {
457
+ plugins.push({ ...entry, installed: await this.#isInstalled(entry.name) })
458
+ }
459
+ plugins.sort((a, b) => a.name.localeCompare(b.name))
460
+ return { fetchedAt: this.#fetchedAt, cached, sources: [...this.#sources], plugins }
461
+ }
462
+
463
+ /**
464
+ * 按名称取一个条目
465
+ * @param name 插件名
466
+ * @returns 条目;索引中没有时 undefined
467
+ */
468
+ async entry(name: string): Promise<MarketEntry | undefined> {
469
+ await this.list()
470
+ return this.#entries.find(item => item.name === name)
471
+ }
472
+
473
+ /**
474
+ * 合并并发的索引获取
475
+ *
476
+ * 面板打开时会同时请求概览与插件列表,两者都会触发获取。此处让后到者复用
477
+ * 同一次网络请求,而不是各发一遍。
478
+ * @param force 忽略磁盘缓存
479
+ * @returns 获取完成
480
+ */
481
+ async #refresh(force: boolean): Promise<void> {
482
+ if (this.#inflight !== undefined) return this.#inflight
483
+ const task = this.#fetchAll(force).finally(() => {
484
+ this.#inflight = undefined
485
+ })
486
+ this.#inflight = task
487
+ return task
488
+ }
489
+
490
+ /**
491
+ * 逐个索引获取并合并条目
492
+ *
493
+ * 同名插件以靠前的索引为准,因此用户可以把私有源放在官方源之前来覆盖某个条目。
494
+ * 全部索引都失败且没有缓存可用时,条目置空但仍记录获取时间:面板据 `sources`
495
+ * 展示失败原因,重试由用户显式触发,避免每次刷新页面都重复请求已知不可达的地址。
496
+ * @param force 忽略磁盘缓存
497
+ */
498
+ async #fetchAll(force: boolean): Promise<void> {
499
+ const { sources, mirror, timeout, cacheTtl } = this.#deps.settings()
500
+ if (!force && this.#fetchedAt === 0) {
501
+ const cache = await this.#loadCache()
502
+ if (cache !== undefined && Date.now() - cache.fetchedAt < cacheTtl) {
503
+ this.#entries = cache.entries
504
+ this.#fetchedAt = cache.fetchedAt
505
+ this.#sources = [{ url: this.#deps.cacheFile, ok: true, count: cache.entries.length }]
506
+ return
507
+ }
508
+ }
509
+ const results: MarketSourceResult[] = []
510
+ const merged = new Map<string, MarketEntry>()
511
+ for (const url of sources) {
512
+ try {
513
+ const raw = await this.#deps.http.get<unknown>(applyMirror(url, mirror), {
514
+ responseType: "json",
515
+ timeout,
516
+ retry: 1
517
+ })
518
+ const entries = parseIndex(raw, url)
519
+ for (const item of entries) if (!merged.has(item.name)) merged.set(item.name, item)
520
+ results.push({ url, ok: true, count: entries.length })
521
+ } catch (err) {
522
+ const error = err instanceof Error ? err.message : String(err)
523
+ results.push({ url, ok: false, error, count: 0 })
524
+ this.#deps.logger.warn(`插件市场索引获取失败 ${url}:${error}`)
525
+ }
526
+ }
527
+ this.#sources = results
528
+ if (merged.size === 0 && results.every(item => !item.ok)) {
529
+ const cache = await this.#loadCache()
530
+ if (cache !== undefined) {
531
+ this.#entries = cache.entries
532
+ this.#fetchedAt = cache.fetchedAt
533
+ this.#deps.logger.warn("插件市场全部索引不可达,沿用上次缓存")
534
+ return
535
+ }
536
+ }
537
+ this.#entries = [...merged.values()]
538
+ this.#fetchedAt = Date.now()
539
+ await this.#saveCache()
540
+ }
541
+
542
+ /**
543
+ * 读取磁盘缓存
544
+ *
545
+ * 缓存文件按不可信输入对待并重新经 `parseIndex` 校验:文件可能被用户手工改动,
546
+ * 也可能是旧版本写下的、字段形状已经不同的内容。
547
+ * @returns 缓存内容;文件不存在或不可解析时 undefined
548
+ */
549
+ async #loadCache(): Promise<CacheFile | undefined> {
550
+ try {
551
+ const raw = JSON.parse(await readFile(this.#deps.cacheFile, "utf8")) as unknown
552
+ if (typeof raw !== "object" || raw === null) return undefined
553
+ const record = raw as { fetchedAt?: unknown; entries?: unknown }
554
+ if (typeof record.fetchedAt !== "number") return undefined
555
+ const entries = parseIndex(record.entries ?? [], this.#deps.cacheFile)
556
+ return { fetchedAt: record.fetchedAt, entries }
557
+ } catch {
558
+ return undefined
559
+ }
560
+ }
561
+
562
+ /**
563
+ * 写入磁盘缓存
564
+ *
565
+ * 写入失败只记日志:缓存是加速手段,写不进去不应当让一次成功的索引获取失败。
566
+ */
567
+ async #saveCache(): Promise<void> {
568
+ const doc: CacheFile = { fetchedAt: this.#fetchedAt, entries: this.#entries }
569
+ try {
570
+ await mkdir(dirname(this.#deps.cacheFile), { recursive: true })
571
+ await writeFile(this.#deps.cacheFile, JSON.stringify(doc, undefined, 2), "utf8")
572
+ } catch (err) {
573
+ this.#deps.logger.debug(`插件市场缓存写入失败:${err instanceof Error ? err.message : String(err)}`)
574
+ }
575
+ }
576
+
577
+ /**
578
+ * 判断插件目录下是否已存在同名目录
579
+ * @param name 插件名
580
+ * @returns 是否已安装
581
+ */
582
+ async #isInstalled(name: string): Promise<boolean> {
583
+ try {
584
+ return await isDirectory(joinWithin(this.#deps.pluginsDir, name))
585
+ } catch {
586
+ return false
587
+ }
588
+ }
589
+
590
+ /**
591
+ * 安装一个插件
592
+ *
593
+ * 全过程在临时目录内完成,仅在校验通过后才移入插件目录,因此失败时插件目录
594
+ * 保持原状。安装完成后不加载插件:加载时机由调用方决定,面板据返回值提示用户
595
+ * 是否需要先安装依赖。
596
+ * @param name 插件名
597
+ * @param opts 可选参数
598
+ * @param opts.replace 目标已存在时先删除再安装
599
+ * @returns 安装结果
600
+ * @throws 名称不合法、索引中无此插件、内核版本不满足、目标已存在或取源失败时
601
+ */
602
+ async install(name: string, opts: { replace?: boolean } = {}): Promise<InstallResult> {
603
+ assertPluginName(name)
604
+ const entry = await this.entry(name)
605
+ if (entry === undefined) throw new Error(`插件市场中没有名为 ${name} 的插件`)
606
+ const target = joinWithin(this.#deps.pluginsDir, name)
607
+ const exists = await isDirectory(target)
608
+ if (exists && opts.replace !== true) throw new Error(`插件 ${name} 已安装,如需覆盖请先卸载`)
609
+ if (entry.minCore !== undefined && compareVersion(this.#deps.coreVersion, entry.minCore) < 0) {
610
+ throw new Error(`插件 ${name} 要求内核版本不低于 ${entry.minCore},当前为 ${this.#deps.coreVersion}`)
611
+ }
612
+
613
+ await mkdir(this.#deps.tempDir, { recursive: true })
614
+ const staging = await mkdtemp(join(this.#deps.tempDir, `market-${name}-`))
615
+ try {
616
+ const { via, root } = await this.#fetch(entry, staging)
617
+ await this.#assertLooksLikePlugin(root, name)
618
+ const manifest = await this.#manifest(root)
619
+ const version = manifest?.version ?? entry.version ?? "0.0.0"
620
+ const needsDependencies = Object.keys(manifest?.dependencies ?? {}).length > 0 && !(await isDirectory(join(root, "node_modules")))
621
+ if (exists) await rm(target, { recursive: true, force: true })
622
+ await mkdir(this.#deps.pluginsDir, { recursive: true })
623
+ await this.#move(root, target)
624
+ this.#deps.logger.info(`插件 ${name}@${version} 已安装至 ${target}`)
625
+ if (needsDependencies) this.#deps.logger.warn(`插件 ${name} 声明了运行时依赖,需在其目录内自行执行包管理器安装`)
626
+ // 带 `.git` 的目录此后可就地拉取;归档装出来的每次更新都要整目录重下
627
+ return { name, dir: target, via, version, needsDependencies, updatable: via === "git" ? "pull" : "reinstall" }
628
+ } finally {
629
+ await rm(staging, { recursive: true, force: true })
630
+ }
631
+ }
632
+
633
+ /**
634
+ * 卸载一个插件
635
+ *
636
+ * 只删除插件目录。配置文件与数据库另行存放,保留它们使得重新安装后原有配置仍然有效。
637
+ * @param name 插件名
638
+ * @returns 是否确实删除了目录
639
+ * @throws 名称不合法时
640
+ */
641
+ async remove(name: string): Promise<boolean> {
642
+ assertPluginName(name)
643
+ const target = joinWithin(this.#deps.pluginsDir, name)
644
+ if (!(await isDirectory(target))) return false
645
+ await rm(target, { recursive: true, force: true })
646
+ this.#deps.logger.info(`插件 ${name} 目录已删除:${target}`)
647
+ return true
648
+ }
649
+
650
+ /**
651
+ * 更新一个插件:目录已是 git 仓库时就地拉取,否则退回重新安装
652
+ * @param name 插件名
653
+ * @returns 安装结果
654
+ * @throws 与 `install` 相同;就地拉取失败时抛出 git 的错误
655
+ */
656
+ async update(name: string): Promise<InstallResult> {
657
+ /*
658
+ * 优先就地拉取,因为重新安装会删掉目录里那份 `node_modules`
659
+ *
660
+ * 那是使用者按提示自己装的,动辄几十兆;删掉意味着每更新一次插件就要在国内网络下
661
+ * 重装一遍依赖,而更新的内容通常只是几行代码。就地拉取还保住目录内其他不属于仓库的
662
+ * 东西(插件写在安装目录下的缓存、使用者放进去的资源)—— 那些本不该放在那里,
663
+ * 但目录是使用者的,删它得有个理由,而「顺便」不是理由。
664
+ *
665
+ * 退回重新安装的三种情形都无从拉取:目录不是 git 仓库、本机没有 git、来源不是 git。
666
+ */
667
+ assertPluginName(name)
668
+ const target = joinWithin(this.#deps.pluginsDir, name)
669
+ const pulled = await this.#tryPull(name, target)
670
+ return pulled ?? this.install(name, { replace: true })
671
+ }
672
+
673
+ /**
674
+ * 试着就地拉取一个插件
675
+ * @param name 插件名
676
+ * @param dir 插件安装目录
677
+ * @returns 拉取结果;不具备就地拉取条件(无 git、目录不是仓库、来源不是 git)时 undefined
678
+ * @throws 拉取过程本身失败时 —— 那意味着网络或仓库状态有问题,此时退回重新安装会把一次
679
+ * 可修复的失败变成一次目录删除
680
+ */
681
+ async #tryPull(name: string, dir: string): Promise<InstallResult | undefined> {
682
+ /*
683
+ * 两处刻意的做法
684
+ *
685
+ * **`fetch` + `reset --hard` 而非 `pull`。** `pull` 会试图合并,而目录里的改动多半不是
686
+ * 有意维护的分叉(编辑器留下的文件、上次安装的残留);一次合并冲突会让目录停在半新半旧
687
+ * 的状态,且要求使用者懂 git 才能收拾。`reset --hard` 的语义是「以远端为准」,与「更新
688
+ * 插件」这个动作一致。
689
+ *
690
+ * **改动先 stash 再 reset,顺序即安全性。** 那些改动有时是刻意为之(改了一处硬编码的
691
+ * 地址),故留一份可取回的副本并在日志里说明取回办法 —— 直接丢掉等于替使用者做了他没
692
+ * 同意的决定。反过来先 reset 后 stash 就是数据丢失。
693
+ */
694
+ if (!(await isDirectory(join(dir, ".git")))) return undefined
695
+ const entry = await this.entry(name)
696
+ if (entry === undefined) throw new Error(`插件市场中没有名为 ${name} 的插件`)
697
+ if (entry.install.type !== "git") return undefined
698
+ if (!(await this.#hasGit())) return undefined
699
+ if (entry.minCore !== undefined && compareVersion(this.#deps.coreVersion, entry.minCore) < 0) {
700
+ throw new Error(`插件 ${name} 要求内核版本不低于 ${entry.minCore},当前为 ${this.#deps.coreVersion}`)
701
+ }
702
+
703
+ const before = (await this.#manifest(dir))?.version
704
+ const head = async (): Promise<string> => (await this.#git(["rev-parse", "HEAD"], dir, GIT_LOCAL_TIMEOUT_MS)).trim()
705
+ const wasAt = await head()
706
+
707
+ // 使用者改过的文件先暂存。`--include-untracked` 一并收进去:新增的文件同样会被
708
+ // reset 之后的 checkout 撞上,而它更可能是使用者自己放的东西
709
+ const dirty = (await this.#git(["status", "--porcelain"], dir, GIT_LOCAL_TIMEOUT_MS)).trim() !== ""
710
+ if (dirty) {
711
+ await this.#git(["stash", "push", "--include-untracked", "-m", `yunzai-ng 更新前自动暂存 ${new Date().toISOString()}`], dir, GIT_LOCAL_TIMEOUT_MS)
712
+ this.#deps.logger.warn(`插件 ${name} 目录内有未提交的改动,已暂存。如需取回:在该目录执行 git stash pop`)
713
+ }
714
+
715
+ const { mirror } = this.#deps.settings()
716
+ const branch = entry.install.branch
717
+ /*
718
+ * 拉取时重新指定远端地址,不沿用目录里记着的那个
719
+ *
720
+ * 镜像前缀是可改的配置项:使用者装插件时用着某个镜像,此后换了一个,而目录里的
721
+ * origin 仍指向旧镜像。那个镜像可能已经关站,于是"更新"永远失败,且错误信息
722
+ * 指向一个使用者早已不用的地址。以索引里的地址为准,镜像每次现算。
723
+ */
724
+ const url = applyMirror(entry.install.url, mirror)
725
+ const ref = branch ?? "HEAD"
726
+ await this.#git(["fetch", "--depth", "1", url, ref], dir, TRANSFER_TIMEOUT_MS)
727
+ await this.#git(["reset", "--hard", "FETCH_HEAD"], dir, GIT_LOCAL_TIMEOUT_MS)
728
+
729
+ const nowAt = await head()
730
+ const changed = nowAt !== wasAt
731
+ const manifest = await this.#manifest(dir)
732
+ const version = manifest?.version ?? entry.version ?? "0.0.0"
733
+ const needsDependencies =
734
+ Object.keys(manifest?.dependencies ?? {}).length > 0 && !(await isDirectory(join(dir, "node_modules")))
735
+
736
+ if (changed) this.#deps.logger.info(`插件 ${name} 已就地更新至 ${version}(${wasAt.slice(0, 7)} → ${nowAt.slice(0, 7)})`)
737
+ else this.#deps.logger.info(`插件 ${name} 已是最新版本 ${version}`)
738
+ if (needsDependencies) this.#deps.logger.warn(`插件 ${name} 声明了运行时依赖,需在其目录内自行执行包管理器安装`)
739
+
740
+ return {
741
+ name,
742
+ dir,
743
+ via: "pull",
744
+ version,
745
+ needsDependencies,
746
+ changed,
747
+ // 就地拉取过一次,说明目录确实是 git 仓库,此后照旧走这条路
748
+ updatable: "pull",
749
+ ...(before === undefined ? {} : { fromVersion: before })
750
+ }
751
+ }
752
+
753
+ /**
754
+ * 把插件内容取到临时目录
755
+ *
756
+ * git 优先:克隆得到的目录带 `.git`,用户此后可以自行 `git pull`,也便于切换分支。
757
+ * git 不可用时退回归档下载,GitHub 仓库地址可换算出 codeload 归档地址,
758
+ * 其余来源若只提供 git 则明确报错,而不是静默失败。
759
+ * @param entry 索引条目
760
+ * @param staging 临时目录
761
+ * @returns 取源方式与插件内容的根目录
762
+ * @throws git 与归档两条路都不可用,或归档为空时
763
+ */
764
+ async #fetch(entry: MarketEntry, staging: string): Promise<{ via: MarketInstallSpec["type"]; root: string }> {
765
+ const { mirror } = this.#deps.settings()
766
+ if (entry.install.type === "git" && (await this.#hasGit())) {
767
+ const dest = join(staging, "repo")
768
+ const args = ["clone", "--depth", "1", "--single-branch"]
769
+ if (entry.install.branch !== undefined) args.push("--branch", entry.install.branch)
770
+ args.push(applyMirror(entry.install.url, mirror), dest)
771
+ await this.#git(args, staging, TRANSFER_TIMEOUT_MS)
772
+ return { via: "git", root: dest }
773
+ }
774
+ const url =
775
+ entry.install.type === "tarball"
776
+ ? entry.install.url
777
+ : tarballFromGit(entry.install.url, entry.install.branch)
778
+ if (url === undefined) throw new Error(`插件 ${entry.name} 仅提供 git 来源,而本机未安装 git`)
779
+ const bytes = await this.#deps.http.buffer(applyMirror(url, mirror), {
780
+ timeout: TRANSFER_TIMEOUT_MS,
781
+ retry: 1
782
+ })
783
+ if (bytes.byteLength > MAX_ARCHIVE_BYTES) {
784
+ throw new Error(`归档体积 ${bytes.byteLength} 字节超过上限 ${MAX_ARCHIVE_BYTES} 字节`)
785
+ }
786
+ const raw = join(staging, "raw")
787
+ const written = await extractTarGz(bytes, raw)
788
+ if (written.length === 0) throw new Error(`归档 ${url} 中没有可写入的文件`)
789
+ const top = singleRoot(written)
790
+ return { via: "tarball", root: top === undefined ? raw : join(raw, top) }
791
+ }
792
+
793
+ /**
794
+ * 探测本机 git 可用性
795
+ *
796
+ * 结果缓存到进程结束。用户在运行期间安装 git 属于少见情形,重启即可重新探测,
797
+ * 不值得为此在每次安装时多执行一次子进程。
798
+ *
799
+ * **先建出临时目录再探测。** 它是这条命令的 cwd,而子进程的 cwd 不存在时
800
+ * execFile 报 ENOENT —— 那会被下面的 catch 记成「本机没有 git」并缓存到进程结束,
801
+ * 于是此后每一次安装都退回归档下载,而真实原因与 git 毫无关系。`install` 恰好在
802
+ * 调用本方法之前建过该目录,`update` 的就地拉取那条路没有,故不能依赖调用顺序。
803
+ * @returns git 是否可用
804
+ */
805
+ async #hasGit(): Promise<boolean> {
806
+ if (this.#gitAvailable !== undefined) return this.#gitAvailable
807
+ try {
808
+ await mkdir(this.#deps.tempDir, { recursive: true })
809
+ await this.#git(["--version"], this.#deps.tempDir, GIT_LOCAL_TIMEOUT_MS)
810
+ this.#gitAvailable = true
811
+ } catch {
812
+ this.#gitAvailable = false
813
+ this.#deps.logger.debug("本机未检测到 git,插件安装改用归档下载")
814
+ }
815
+ return this.#gitAvailable
816
+ }
817
+
818
+ /**
819
+ * 校验取到的内容是否具备插件形态
820
+ *
821
+ * 该校验的目的是及早给出可理解的错误:地址写错时下载到的往往是一份文档仓库或
822
+ * 一个空目录,若不检查就移入插件目录,用户看到的会是下次启动时的一条加载失败。
823
+ * @param root 内容根目录
824
+ * @param name 插件名
825
+ * @throws 既无 package.json 也无入口文件时
826
+ */
827
+ async #assertLooksLikePlugin(root: string, name: string): Promise<void> {
828
+ if (await isFile(join(root, "package.json"))) return
829
+ for (const entry of ENTRY_FILES) if (await isFile(join(root, entry))) return
830
+ throw new Error(`取到的内容不像插件:${name} 目录内既无 package.json 也无入口文件`)
831
+ }
832
+
833
+ /**
834
+ * 读取 package.json 中与安装相关的字段
835
+ * @param root 内容根目录
836
+ * @returns 版本与依赖声明;无 package.json 或不可解析时 undefined
837
+ */
838
+ async #manifest(root: string): Promise<{ version?: string; dependencies?: Record<string, string> } | undefined> {
839
+ try {
840
+ const raw = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as unknown
841
+ if (typeof raw !== "object" || raw === null) return undefined
842
+ const record = raw as { version?: unknown; dependencies?: unknown }
843
+ const version = typeof record.version === "string" ? record.version : undefined
844
+ const dependencies =
845
+ typeof record.dependencies === "object" && record.dependencies !== null
846
+ ? (record.dependencies as Record<string, string>)
847
+ : undefined
848
+ return { ...(version === undefined ? {} : { version }), ...(dependencies === undefined ? {} : { dependencies }) }
849
+ } catch {
850
+ return undefined
851
+ }
852
+ }
853
+
854
+ /**
855
+ * 把临时目录中的内容移到插件目录
856
+ *
857
+ * `rename` 在跨设备时会失败(EXDEV)—— 用户可能把临时目录配到另一个分区,
858
+ * 此时退回递归复制。复制成本高于改名,因此仅作为兜底而非默认路径。
859
+ * @param from 源目录
860
+ * @param to 目标目录
861
+ */
862
+ async #move(from: string, to: string): Promise<void> {
863
+ try {
864
+ await rename(from, to)
865
+ } catch (err) {
866
+ if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err
867
+ await cp(from, to, { recursive: true })
868
+ }
869
+ }
870
+ }