@koishi-ce/plugin-market 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2019-present Shigma and Koishijs contributors.
3
+ // Copyright (c) 2026-present Koishi-CE contributors.
4
+
5
+ /**
6
+ * bun 安装子进程的驱动与输出转发。
7
+ *
8
+ * Bun-first:CE 生态只存在 bun 这一种包管理器,直接驱动 bun 执行安装
9
+ * (上游经 which-pm-runs 探测 npm/yarn/bun,此处固定为 bun)。子进程本身的
10
+ * 创建见 ./proc.ts;本模块负责退出码归一化与日志转发,不持有任何服务状态。
11
+ */
12
+ import { Logger } from "@koishi-ce/koishi";
13
+ import { spawnBun } from "./proc.ts";
14
+
15
+ const logger = new Logger("market");
16
+
17
+ /**
18
+ * 在 cwd 下执行 `bun <args>`,返回退出码(spawn 失败与异常退出统一为非 0,
19
+ * 由调用方按失败处理)。
20
+ *
21
+ * 输出按行转发到 market 日志:未成行的尾段暂存到下一次数据到来时补齐,
22
+ * 避免半行日志刷屏。
23
+ */
24
+ export function runBun(
25
+ args: string[],
26
+ cwd: string,
27
+ ): Promise<number> {
28
+ return new Promise<number>((resolve) => {
29
+ const child = spawnBun(args, cwd);
30
+ child.on("exit", (code) => resolve(code ?? -1));
31
+ child.on("error", () => resolve(-1));
32
+
33
+ let stderr = "";
34
+ child.stderr?.on("data", (data) => {
35
+ stderr += data.toString();
36
+ const lines = stderr.split("\n");
37
+ stderr = lines.pop() ?? "";
38
+ for (const line of lines) {
39
+ logger.warn(line);
40
+ }
41
+ });
42
+
43
+ let stdout = "";
44
+ child.stdout?.on("data", (data) => {
45
+ stdout += data.toString();
46
+ const lines = stdout.split("\n");
47
+ stdout = lines.pop() ?? "";
48
+ for (const line of lines) {
49
+ logger.info(line);
50
+ }
51
+ });
52
+ });
53
+ }
@@ -0,0 +1,337 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2019-present Shigma and Koishijs contributors.
3
+ // Copyright (c) 2026-present Koishi-CE contributors.
4
+
5
+ /**
6
+ * 插件市场的安装服务(`installer` 服务,同时是本目录入口)。
7
+ *
8
+ * 本目录按职责拆分,本文件只保留服务本体(缓存、依赖汇总、安装编排与
9
+ * 重载判定):
10
+ * - `./manifest.ts` 项目根 package.json 的读写与护栏判定
11
+ * - `./versions.ts` 远端版本探测与兼容性过滤
12
+ * - `./exec.ts` bun 安装子进程的驱动与输出转发
13
+ * - `./integrity.ts` 安装完整性校验(isolated 布局链接探测)
14
+ * - `./registry-config.ts` 本机 npm registry 配置探测
15
+ * - `./proc.ts` bun 安装子进程的创建封装
16
+ */
17
+ import type {} from "@koishi-ce/console";
18
+ import {
19
+ type Context,
20
+ type Dict,
21
+ type HTTP,
22
+ Logger,
23
+ Schema,
24
+ Service,
25
+ Time,
26
+ valueMap,
27
+ } from "@koishi-ce/koishi";
28
+ import type {} from "@koishi-ce/loader";
29
+ import type {} from "@koishi-ce/plugin-market";
30
+ import {
31
+ isResidentInCache,
32
+ mapLimit,
33
+ type RemotePackage,
34
+ } from "@koishi-ce/registry";
35
+ import { satisfies, valid } from "semver";
36
+ import { runBun } from "./exec.ts";
37
+ import { findMissingDeps } from "./integrity.ts";
38
+ import {
39
+ type LocalPackage,
40
+ loadManifest,
41
+ writeManifest,
42
+ } from "./manifest.ts";
43
+ import {
44
+ getLocalRegistry,
45
+ NPM_OFFICIAL_REGISTRY,
46
+ } from "./registry-config.ts";
47
+ import {
48
+ fetchVersions,
49
+ getVersions,
50
+ type VersionMap,
51
+ } from "./versions.ts";
52
+
53
+ const logger = new Logger("market");
54
+
55
+ export interface Dependency {
56
+ /**
57
+ * requested semver range
58
+ * @example `^1.2.3` -> `1.2.3`
59
+ */
60
+ request: string;
61
+ /**
62
+ * installed package version
63
+ * @example `1.2.5`
64
+ */
65
+ resolved?: string | undefined;
66
+ /** whether it is a workspace package */
67
+ workspace?: boolean | undefined;
68
+ /** valid (unsupported) syntax */
69
+ invalid?: boolean | undefined;
70
+ /** latest version */
71
+ latest?: string | undefined;
72
+ }
73
+
74
+ class Installer extends Service {
75
+ declare http: HTTP;
76
+ declare endpoint: string | undefined;
77
+ public fullCache: Dict<VersionMap> = {};
78
+ public tempCache: Dict<VersionMap> = {};
79
+
80
+ private pkgTasks: Dict<Promise<VersionMap>> = {};
81
+ private manifest: LocalPackage;
82
+ declare private depTask: Promise<Dict<Dependency>>;
83
+ private flushData: () => void;
84
+
85
+ override config: Installer.Config;
86
+
87
+ constructor(ctx: Context, config: Installer.Config) {
88
+ super(ctx, "installer");
89
+ this.config = config;
90
+ this.manifest = loadManifest(this.cwd);
91
+ this.flushData = ctx.throttle(() => {
92
+ ctx
93
+ .get("console")
94
+ ?.broadcast("market/registry", this.tempCache);
95
+ this.tempCache = {};
96
+ }, 500);
97
+ }
98
+
99
+ get cwd() {
100
+ return this.ctx.baseDir;
101
+ }
102
+
103
+ override async start() {
104
+ const { endpoint, timeout } = this.config;
105
+ this.endpoint =
106
+ endpoint ??
107
+ getLocalRegistry(this.cwd) ??
108
+ NPM_OFFICIAL_REGISTRY;
109
+ const options: HTTP.Config = {};
110
+ if (this.endpoint) options.endpoint = this.endpoint;
111
+ if (timeout !== undefined) options.timeout = timeout;
112
+ this.http = this.ctx.http.extend(options);
113
+ }
114
+
115
+ resolveName(name: string) {
116
+ if (name.startsWith("@koishijs/plugin-")) return [name];
117
+ if (name.match(/(^|\/)koishi-plugin-/)) return [name];
118
+ if (name[0] === "@") {
119
+ const [left, right] = name.split("/");
120
+ return [`${left}/koishi-plugin-${right}`];
121
+ } else {
122
+ return [
123
+ `@koishijs/plugin-${name}`,
124
+ `koishi-plugin-${name}`,
125
+ ];
126
+ }
127
+ }
128
+
129
+ async findVersion(names: string[]) {
130
+ const entries = await Promise.all(
131
+ names.map(async (name) => {
132
+ try {
133
+ const versions = Object.entries(
134
+ await this.getPackage(name),
135
+ );
136
+ const [latest] = versions;
137
+ if (!latest) return undefined;
138
+ return { [name]: latest[0] };
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }),
143
+ );
144
+ return entries.find(
145
+ (entry): entry is Dict<string> => entry !== undefined,
146
+ );
147
+ }
148
+
149
+ private async _getPackage(name: string) {
150
+ try {
151
+ const versions = await fetchVersions(this.http, name);
152
+ this.fullCache[name] = this.tempCache[name] =
153
+ versions;
154
+ this.flushData();
155
+ return versions;
156
+ } catch (error) {
157
+ logger.warn(error);
158
+ return {};
159
+ }
160
+ }
161
+
162
+ setPackage(name: string, versions: RemotePackage[]) {
163
+ this.fullCache[name] = this.tempCache[name] =
164
+ getVersions(versions);
165
+ this.flushData();
166
+ this.pkgTasks[name] = Promise.resolve(
167
+ this.fullCache[name],
168
+ );
169
+ }
170
+
171
+ getPackage(name: string) {
172
+ return (this.pkgTasks[name] ||= this._getPackage(name));
173
+ }
174
+
175
+ private async _getDeps() {
176
+ const result = valueMap(
177
+ this.manifest.dependencies,
178
+ (request) => {
179
+ return {
180
+ request: request.replace(/^[~^]/, ""),
181
+ } as Dependency;
182
+ },
183
+ );
184
+ await mapLimit(
185
+ Object.keys(result),
186
+ 10,
187
+ async (name) => {
188
+ const dep = result[name];
189
+ if (!dep) return;
190
+ try {
191
+ // some dependencies may be left with no local installation
192
+ const meta = loadManifest(name);
193
+ dep.resolved = meta.version;
194
+ dep.workspace = meta.$workspace;
195
+ if (meta.$workspace) return;
196
+ } catch {}
197
+
198
+ if (!valid(dep.request)) {
199
+ dep.invalid = true;
200
+ }
201
+
202
+ const versions = await this.getPackage(name);
203
+ if (versions) dep.latest = Object.keys(versions)[0];
204
+ },
205
+ );
206
+ return result;
207
+ }
208
+
209
+ getDeps() {
210
+ return (this.depTask ||= this._getDeps());
211
+ }
212
+
213
+ refreshData() {
214
+ this.ctx.get("console")?.refresh("registry");
215
+ this.ctx.get("console")?.refresh("packages");
216
+ }
217
+
218
+ refresh(refresh = false) {
219
+ this.pkgTasks = {};
220
+ this.fullCache = {};
221
+ this.tempCache = {};
222
+ this.depTask = this._getDeps();
223
+ if (!refresh) return;
224
+ this.refreshData();
225
+ }
226
+
227
+ async exec(args: string[]) {
228
+ // Bun-first:CE 生态只存在 bun 这一种包管理器,直接驱动 bun 执行
229
+ // 安装(上游经 which-pm-runs 探测 npm/yarn/bun,此处固定为 bun)
230
+ args.unshift("install");
231
+ return runBun(args, this.cwd);
232
+ }
233
+
234
+ async override(deps: Dict<string | null>) {
235
+ this.manifest = await writeManifest(this.cwd, deps);
236
+ }
237
+
238
+ private _install() {
239
+ const args: string[] = [];
240
+ if (this.endpoint) {
241
+ args.push("--registry", this.endpoint);
242
+ }
243
+ return this.exec(args);
244
+ }
245
+
246
+ private _getLocalDeps(override: Dict<string | null>) {
247
+ return valueMap(override, (request, name) => {
248
+ const dep = { request } as Dependency;
249
+ try {
250
+ const meta = loadManifest(name);
251
+ dep.resolved = meta.version;
252
+ dep.workspace = meta.$workspace;
253
+ } catch {}
254
+ return dep;
255
+ });
256
+ }
257
+
258
+ async install(
259
+ deps: Dict<string | null>,
260
+ forced?: boolean,
261
+ ) {
262
+ const localDeps = this._getLocalDeps(deps);
263
+ await this.override(deps);
264
+
265
+ let shouldInstall = forced === true;
266
+ for (const name in deps) {
267
+ const request = deps[name];
268
+ const local = localDeps[name];
269
+ if (
270
+ local?.workspace ||
271
+ (request &&
272
+ local?.resolved &&
273
+ satisfies(local.resolved, request, {
274
+ includePrerelease: true,
275
+ }))
276
+ )
277
+ continue;
278
+ shouldInstall = true;
279
+ break;
280
+ }
281
+
282
+ if (shouldInstall) {
283
+ const code = await this._install();
284
+ if (code) return code;
285
+ // isolated 布局的增量安装可能漏建新包目录的依赖链接(机理
286
+ // 见 integrity.ts 模块注释):装完即校验,缺失时补跑一次
287
+ // 安装——bun 按磁盘实际状态重建链接,秒级自愈,无需人工
288
+ const missing = findMissingDeps(this.cwd, deps);
289
+ if (missing.length) {
290
+ logger.warn(
291
+ `检测到增量安装未建立部分依赖链接(isolated 布局已知缺陷),正在补装: ${missing.join("; ")}`,
292
+ );
293
+ const retry = await this._install();
294
+ if (retry) return retry;
295
+ if (findMissingDeps(this.cwd, deps).length) {
296
+ logger.warn(
297
+ "自动补装后依赖链接仍不完整,请在项目根目录运行 bun install --force 并重启 koishi",
298
+ );
299
+ }
300
+ }
301
+ }
302
+
303
+ this.refresh();
304
+ const newDeps = await this.getDeps();
305
+ for (const name in localDeps) {
306
+ const local = localDeps[name];
307
+ const newDep = newDeps[name];
308
+ if (!local || !newDep || local.workspace) continue;
309
+ if (newDep.resolved === local.resolved) continue;
310
+ // 版本变化且旧版本仍驻留内存时才需要整进程重载(判定不走
311
+ // 解析 API,规避 Bun 负缓存,见 isResidentInCache)
312
+ if (isResidentInCache(name))
313
+ this.ctx.loader.fullReload();
314
+ }
315
+ this.refreshData();
316
+
317
+ return 0;
318
+ }
319
+
320
+ // erasableSyntaxOnly 禁止含运行时值的 namespace,
321
+ // 原 namespace 内的 Config 常量移到此处的静态字段,对外形状不变
322
+ static Config: Schema<Installer.Config> = Schema.object({
323
+ endpoint: Schema.string().role("link"),
324
+ timeout: Schema.number()
325
+ .role("time")
326
+ .default(Time.second * 5),
327
+ }); // TODO .hidden()
328
+ }
329
+
330
+ declare namespace Installer {
331
+ export interface Config {
332
+ endpoint?: string;
333
+ timeout?: number;
334
+ }
335
+ }
336
+
337
+ export default Installer;
@@ -0,0 +1,104 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2019-present Shigma and Koishijs contributors.
3
+ // Copyright (c) 2026-present Koishi-CE contributors.
4
+
5
+ /**
6
+ * 安装完整性校验:Bun isolated 布局的增量安装缺陷防御。
7
+ *
8
+ * 背景(2026-09-07 实证):升级插件时包目录 hash 变化,而其依赖的解析
9
+ * 结果未变,bun 的增量安装跳过新包目录内的依赖链接建立——插件入口(顶层
10
+ * symlink)可加载,内部依赖的 require 却炸 MODULE_NOT_FOUND;此时补跑一次
11
+ * 普通 bun install,bun 按磁盘实际状态检测到包不完整即重建链接(秒级)。
12
+ * 本模块只负责「发现问题」,补救(补装、告警)由 installer 侧完成。
13
+ *
14
+ * 全程只走 existsSync / readFileSync / realpathSync,不触碰解析 API
15
+ * (防父目录快照缓存,与 registry 的 resolvePackageJson 同一纪律)。
16
+ */
17
+ import {
18
+ existsSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ } from "node:fs";
22
+ import { dirname, join, resolve } from "node:path";
23
+ import type { Dict } from "@koishi-ce/koishi";
24
+ import type { LocalPackage } from "./manifest.ts";
25
+
26
+ /**
27
+ * 从 fromDir 沿 node_modules 链纯 fs 探测依赖包,命中返回其真实目录。
28
+ * realpath 化是 isolated 布局的关键:顶层条目是 symlink,包的依赖链接
29
+ * 挂在 .bun 真实目录的同级 node_modules,从链接路径向上爬会错误地查
30
+ * 顶层(isolated 不提升传递依赖)。
31
+ */
32
+ function probePackage(
33
+ fromDir: string,
34
+ name: string,
35
+ ): string | undefined {
36
+ let dir = resolve(fromDir);
37
+ for (;;) {
38
+ const manifestPath = join(
39
+ dir,
40
+ "node_modules",
41
+ name,
42
+ "package.json",
43
+ );
44
+ if (existsSync(manifestPath)) {
45
+ try {
46
+ return dirname(realpathSync(manifestPath));
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ const parent = dirname(dir);
52
+ if (parent === dir) return undefined;
53
+ dir = parent;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 从本次安装的包出发递归纯 fs 探测依赖可达性,返回「包: 缺失依赖」描述
59
+ * 列表。peer / optional 依赖由宿主或按需提供不入校验;workspace / alias
60
+ * 等非 semver 声明的落盘名与键名无直接映射,同样跳过。
61
+ *
62
+ * @param root 项目根目录
63
+ * @param deps 依赖增删清单(值为 null 表示卸载该依赖)
64
+ */
65
+ export function findMissingDeps(
66
+ root: string,
67
+ deps: Dict<string | null>,
68
+ ): string[] {
69
+ const problems: string[] = [];
70
+ const visited = new Set<string>();
71
+ const walk = (dir: string, label: string) => {
72
+ if (visited.has(dir)) return;
73
+ visited.add(dir);
74
+ let manifest: LocalPackage;
75
+ try {
76
+ manifest = JSON.parse(
77
+ readFileSync(join(dir, "package.json"), "utf8"),
78
+ ) as LocalPackage;
79
+ } catch {
80
+ return;
81
+ }
82
+ for (const [dep, request] of Object.entries(
83
+ manifest.dependencies ?? {},
84
+ )) {
85
+ if (request.includes(":")) continue;
86
+ const depDir = probePackage(dir, dep);
87
+ if (!depDir) {
88
+ problems.push(`${label} 的依赖 ${dep} 未被链接`);
89
+ continue;
90
+ }
91
+ walk(depDir, `${label} → ${dep}`);
92
+ }
93
+ };
94
+ for (const name in deps) {
95
+ const request = deps[name];
96
+ if (!request || request.includes(":")) continue;
97
+ const dir = probePackage(root, name);
98
+ // 顶层包未落盘属于安装失败,由退出码与后续加载告警兜底,
99
+ // 不属于「包已装但链接缺失」的补装场景
100
+ if (!dir) continue;
101
+ walk(dir, name);
102
+ }
103
+ return problems;
104
+ }
@@ -0,0 +1,108 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2019-present Shigma and Koishijs contributors.
3
+ // Copyright (c) 2026-present Koishi-CE contributors.
4
+
5
+ /**
6
+ * 项目根 package.json(安装清单)的读写与护栏判定。
7
+ *
8
+ * 只做纯 fs 的清单操作,与 registry 的交互(版本探测、http 缓存)无关,
9
+ * 故独立于 installer 服务;依赖链接的探测属安装完整性校验,见
10
+ * ./integrity.ts。
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { resolve } from "node:path";
14
+ import {
15
+ type Dict,
16
+ defineProperty,
17
+ } from "@koishi-ce/koishi";
18
+ import {
19
+ type PackageJson,
20
+ resolvePackageJson,
21
+ } from "@koishi-ce/registry";
22
+
23
+ /** 本地 package.json 的解析结果;dependencies 由归一化保证必有 */
24
+ export interface LocalPackage extends PackageJson {
25
+ private?: boolean;
26
+ $workspace?: boolean;
27
+ dependencies: Record<string, string>;
28
+ }
29
+
30
+ /**
31
+ * 判断依赖声明是否受护栏保护、不可被安装清单覆盖或删除。两类:
32
+ * - `workspace:` 声明是本仓库(monorepo)对上游名的归属(如 koishi
33
+ * 裸名 shim,见 packages/shim/koishi);
34
+ * - `npm:@koishi-ce/...` alias 是下游脚手架生成项目对上游名的归属
35
+ * (如 "koishi": "npm:@koishi-ce/koishi-shim@^4.18.11",见
36
+ * packages/shim/koishi-shim)。
37
+ * 两者被覆盖或删除都会让 peer 解析失去归属,重新拉下 npm 官方包形成
38
+ * 第二份框架副本。
39
+ */
40
+ function isGuardedRequest(
41
+ request: string | undefined,
42
+ ): boolean {
43
+ return (
44
+ request?.startsWith("workspace:") === true ||
45
+ request?.startsWith("npm:@koishi-ce") === true
46
+ );
47
+ }
48
+
49
+ export function loadManifest(name: string): LocalPackage {
50
+ // resolvePackageJson 以纯 fs 探测为主路径:市场安装流程在包落盘前的
51
+ // 探测不能触碰解析 API,否则触发 Bun 的父目录快照缓存(装完即失败)
52
+ const filename = resolvePackageJson(name);
53
+ const meta: LocalPackage = JSON.parse(
54
+ readFileSync(filename, "utf8"),
55
+ );
56
+ meta.dependencies ||= {};
57
+ defineProperty(
58
+ meta,
59
+ "$workspace",
60
+ !filename.includes("node_modules"),
61
+ );
62
+ return meta;
63
+ }
64
+
65
+ /**
66
+ * 把依赖增删写回项目根 package.json,返回写回后的清单。
67
+ *
68
+ * 现读现写:调用方持有的清单原为构造期的启动快照,运行期间根
69
+ * package.json 可能已被外部更新,基于快照整体重写会抹掉变更,故此处
70
+ * 重新读盘。受护栏保护的声明(见 isGuardedRequest)既不覆盖也不删除。
71
+ *
72
+ * @param cwd 项目根目录
73
+ * @param deps 依赖增删清单(值为 null 表示卸载该依赖)
74
+ */
75
+ export async function writeManifest(
76
+ cwd: string,
77
+ deps: Dict<string | null>,
78
+ ): Promise<LocalPackage> {
79
+ const filename = resolve(cwd, "package.json");
80
+ const manifest: LocalPackage = JSON.parse(
81
+ readFileSync(filename, "utf8"),
82
+ );
83
+ manifest.dependencies ||= {};
84
+ for (const key in deps) {
85
+ if (deps[key]) {
86
+ if (isGuardedRequest(manifest.dependencies[key])) {
87
+ continue;
88
+ }
89
+ manifest.dependencies[key] = deps[key];
90
+ } else if (
91
+ !isGuardedRequest(manifest.dependencies[key])
92
+ ) {
93
+ delete manifest.dependencies[key];
94
+ }
95
+ }
96
+ manifest.dependencies = Object.fromEntries(
97
+ Object.entries(manifest.dependencies).sort((a, b) =>
98
+ a[0].localeCompare(b[0]),
99
+ ),
100
+ );
101
+ // 仓库格式权威是 biome(tab 缩进),按 tab 写出避免装插件后
102
+ // package.json 被重排成空格、lint 报格式漂移
103
+ await Bun.write(
104
+ filename,
105
+ `${JSON.stringify(manifest, null, "\t")}\n`,
106
+ );
107
+ return manifest;
108
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2019-present Shigma and Koishijs contributors.
3
+ // Copyright (c) 2026-present Koishi-CE contributors.
4
+
5
+ /**
6
+ * 本机 npm registry 配置探测(零子进程)。
7
+ *
8
+ * 实现已收敛到 @koishi-ce/utils 的 npm-registry 模块(与 create-koishi-ce
9
+ * 的 registry.ts 共享同一实现),此处按原公共 API 再导出;官方源回落由
10
+ * installer 在调用处以 NPM_OFFICIAL_REGISTRY 兜底,保证 http 恒有
11
+ * endpoint 可拼接相对 URL——缺省时相对请求会在 resolveURL 抛 Invalid URL。
12
+ */
13
+ export {
14
+ getLocalRegistry,
15
+ NPM_OFFICIAL_REGISTRY,
16
+ } from "@koishi-ce/utils";
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2019-present Shigma and Koishijs contributors.
3
+ // Copyright (c) 2026-present Koishi-CE contributors.
4
+
5
+ /**
6
+ * 远端包版本探测与归一化。
7
+ *
8
+ * 从 registry 取回版本数组后,过滤掉与当前框架不兼容的版本,再归一化为
9
+ * 「版本号 → peer 依赖声明 / 弃用标记」的映射并按版本号降序排列(首项即
10
+ * 最新版)。纯 IO + 纯函数,不持有服务状态;缓存与去重由 installer 侧负责。
11
+ */
12
+ import {
13
+ type Dict,
14
+ type HTTP,
15
+ pick,
16
+ } from "@koishi-ce/koishi";
17
+ import Scanner, {
18
+ type DependencyMetaKey,
19
+ type Registry,
20
+ type RemotePackage,
21
+ } from "@koishi-ce/registry";
22
+ import { compare, satisfies } from "semver";
23
+
24
+ /** 归一化后的版本表:版本号 → 依赖元信息(键序即降序,首项为最新版) */
25
+ export type VersionMap = Dict<
26
+ Pick<RemotePackage, DependencyMetaKey>
27
+ >;
28
+
29
+ /** 把 registry 返回的版本数组归一化并降序排列 */
30
+ export function getVersions(
31
+ versions: RemotePackage[],
32
+ ): VersionMap {
33
+ return Object.fromEntries(
34
+ versions
35
+ .map(
36
+ (item) =>
37
+ [
38
+ item.version,
39
+ pick(item, [
40
+ "peerDependencies",
41
+ "peerDependenciesMeta",
42
+ "deprecated",
43
+ ]),
44
+ ] as const,
45
+ )
46
+ .sort(([a], [b]) => compare(b, a)),
47
+ );
48
+ }
49
+
50
+ /**
51
+ * 取回单个包的可用版本表(已过滤不兼容版本)。
52
+ *
53
+ * 兼容性口径:`koishi` 本体按 4.x 判定;其余包若确认为插件则要求与
54
+ * 4.x 兼容(Scanner 的判定基于包名与 remote 元信息)。
55
+ *
56
+ * @throws 网络或 registry 响应异常时向上抛出,由调用方决定日志口径
57
+ */
58
+ export async function fetchVersions(
59
+ http: HTTP,
60
+ name: string,
61
+ ): Promise<VersionMap> {
62
+ const registry = await http.get<Registry>(`/${name}`);
63
+ return getVersions(
64
+ Object.values(registry.versions).filter((remote) => {
65
+ if (name === "koishi")
66
+ return satisfies(remote.version, "4");
67
+ return (
68
+ !Scanner.isPlugin(name) ||
69
+ Scanner.isCompatible("4", remote)
70
+ );
71
+ }),
72
+ );
73
+ }