@koishi-ce/plugin-market 1.0.12 → 1.0.14
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.
- package/dist/index.js +3 -3
- package/lib/index.d.ts +18 -0
- package/lib/index.mjs +78 -3
- package/package.json +5 -4
- package/src/__tests__/index.test.ts +173 -6
- package/src/node/installer.ts +107 -3
- package/src/node/market.ts +11 -3
package/lib/index.d.ts
CHANGED
|
@@ -45,6 +45,20 @@ declare class Installer extends Service {
|
|
|
45
45
|
exec(args: string[]): Promise<number>;
|
|
46
46
|
override(deps: Dict<string | null>): Promise<void>;
|
|
47
47
|
private _install;
|
|
48
|
+
/**
|
|
49
|
+
* 安装完整性校验:从本次安装的包出发递归纯 fs 探测依赖可达性,返回
|
|
50
|
+
* 「包: 缺失依赖」描述列表。背景是 Bun isolated 布局的增量安装缺陷
|
|
51
|
+
* (2026-09-07 实证):升级插件时包目录 hash 变化,而其依赖的解析
|
|
52
|
+
* 结果未变,bun 的增量安装跳过新包目录内的依赖链接建立——插件入口
|
|
53
|
+
* (顶层 symlink)可加载,内部依赖的 require 却炸 MODULE_NOT_FOUND;
|
|
54
|
+
* 此时补跑一次普通 bun install,bun 按磁盘实际状态检测到包不完整
|
|
55
|
+
* 即重建链接(秒级)。全程只走 existsSync / readFileSync /
|
|
56
|
+
* realpathSync,不触碰解析 API(防父目录快照缓存,与
|
|
57
|
+
* resolvePackageJson 同一纪律)。peer / optional 依赖由宿主或按需
|
|
58
|
+
* 提供不入校验;workspace / alias 等非 semver 声明的落盘名与键名无
|
|
59
|
+
* 直接映射,同样跳过。
|
|
60
|
+
*/
|
|
61
|
+
private _findMissingDeps;
|
|
48
62
|
private _getLocalDeps;
|
|
49
63
|
install(deps: Dict<string | null>, forced?: boolean): Promise<number>;
|
|
50
64
|
static Config: Schema<Installer.Config>;
|
|
@@ -113,6 +127,10 @@ declare class MarketProvider$1 extends MarketProvider {
|
|
|
113
127
|
* 服务端瞬态错误(408 / 5xx)可安全重试;allowNotFound 时把 404
|
|
114
128
|
* 也视为瞬态——镜像索引以「302 指向版本化文件」方式发布,切换文件
|
|
115
129
|
* 的部署窗口会出现短时 404。
|
|
130
|
+
*
|
|
131
|
+
* 无 code 且无 response 的 HTTP.Error 是 plugin-http 对 fetch 网络层
|
|
132
|
+
* 异常(证书验证失败、连接重置、DNS 等)的统一包装——代理节点切换
|
|
133
|
+
* 等瞬态网络抖动即此形态,重试可自愈。
|
|
116
134
|
*/
|
|
117
135
|
private isRetryable;
|
|
118
136
|
/**
|
package/lib/index.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { join, resolve } from "node:path";
|
|
1
|
+
import { dirname, join, resolve } from "node:path";
|
|
2
2
|
import { Logger, Schema, Service, Time, defineProperty, pick, sleep, valueMap } from "@koishi-ce/koishi";
|
|
3
3
|
import { compare, gt, satisfies, valid } from "semver";
|
|
4
4
|
import { DataService } from "@koishi-ce/console";
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import Scanner, { isResidentInCache, mapLimit, resolvePackageJson } from "@koishi-ce/registry";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
@@ -62,6 +62,26 @@ const logger$2 = new Logger("market");
|
|
|
62
62
|
function isGuardedRequest(request) {
|
|
63
63
|
return request?.startsWith("workspace:") === true || request?.startsWith("npm:@koishi-ce") === true;
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* 从 fromDir 沿 node_modules 链纯 fs 探测依赖包,命中返回其真实目录。
|
|
67
|
+
* realpath 化是 isolated 布局的关键:顶层条目是 symlink,包的依赖链接
|
|
68
|
+
* 挂在 .bun 真实目录的同级 node_modules,从链接路径向上爬会错误地查
|
|
69
|
+
* 顶层(isolated 不提升传递依赖)。
|
|
70
|
+
*/
|
|
71
|
+
function probePackage(fromDir, name) {
|
|
72
|
+
let dir = resolve(fromDir);
|
|
73
|
+
for (;;) {
|
|
74
|
+
const manifestPath = join(dir, "node_modules", name, "package.json");
|
|
75
|
+
if (existsSync(manifestPath)) try {
|
|
76
|
+
return dirname(realpathSync(manifestPath));
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const parent = dirname(dir);
|
|
81
|
+
if (parent === dir) return void 0;
|
|
82
|
+
dir = parent;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
65
85
|
/** 从单个 .npmrc 文件提取 registry 配置项;文件不存在或读取出错一律视为未配置 */
|
|
66
86
|
function readNpmrcRegistry(file) {
|
|
67
87
|
try {
|
|
@@ -244,6 +264,50 @@ var Installer = class extends Service {
|
|
|
244
264
|
if (this.endpoint) args.push("--registry", this.endpoint);
|
|
245
265
|
return this.exec(args);
|
|
246
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* 安装完整性校验:从本次安装的包出发递归纯 fs 探测依赖可达性,返回
|
|
269
|
+
* 「包: 缺失依赖」描述列表。背景是 Bun isolated 布局的增量安装缺陷
|
|
270
|
+
* (2026-09-07 实证):升级插件时包目录 hash 变化,而其依赖的解析
|
|
271
|
+
* 结果未变,bun 的增量安装跳过新包目录内的依赖链接建立——插件入口
|
|
272
|
+
* (顶层 symlink)可加载,内部依赖的 require 却炸 MODULE_NOT_FOUND;
|
|
273
|
+
* 此时补跑一次普通 bun install,bun 按磁盘实际状态检测到包不完整
|
|
274
|
+
* 即重建链接(秒级)。全程只走 existsSync / readFileSync /
|
|
275
|
+
* realpathSync,不触碰解析 API(防父目录快照缓存,与
|
|
276
|
+
* resolvePackageJson 同一纪律)。peer / optional 依赖由宿主或按需
|
|
277
|
+
* 提供不入校验;workspace / alias 等非 semver 声明的落盘名与键名无
|
|
278
|
+
* 直接映射,同样跳过。
|
|
279
|
+
*/
|
|
280
|
+
_findMissingDeps(deps) {
|
|
281
|
+
const problems = [];
|
|
282
|
+
const visited = /* @__PURE__ */ new Set();
|
|
283
|
+
const walk = (dir, label) => {
|
|
284
|
+
if (visited.has(dir)) return;
|
|
285
|
+
visited.add(dir);
|
|
286
|
+
let manifest;
|
|
287
|
+
try {
|
|
288
|
+
manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
289
|
+
} catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
for (const [dep, request] of Object.entries(manifest.dependencies ?? {})) {
|
|
293
|
+
if (request.includes(":")) continue;
|
|
294
|
+
const depDir = probePackage(dir, dep);
|
|
295
|
+
if (!depDir) {
|
|
296
|
+
problems.push(`${label} 的依赖 ${dep} 未被链接`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
walk(depDir, `${label} → ${dep}`);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
for (const name in deps) {
|
|
303
|
+
const request = deps[name];
|
|
304
|
+
if (!request || request.includes(":")) continue;
|
|
305
|
+
const dir = probePackage(this.cwd, name);
|
|
306
|
+
if (!dir) continue;
|
|
307
|
+
walk(dir, name);
|
|
308
|
+
}
|
|
309
|
+
return problems;
|
|
310
|
+
}
|
|
247
311
|
_getLocalDeps(override) {
|
|
248
312
|
return valueMap(override, (request, name) => {
|
|
249
313
|
const dep = { request };
|
|
@@ -269,6 +333,13 @@ var Installer = class extends Service {
|
|
|
269
333
|
if (shouldInstall) {
|
|
270
334
|
const code = await this._install();
|
|
271
335
|
if (code) return code;
|
|
336
|
+
const missing = this._findMissingDeps(deps);
|
|
337
|
+
if (missing.length) {
|
|
338
|
+
logger$2.warn(`检测到增量安装未建立部分依赖链接(isolated 布局已知缺陷),正在补装: ${missing.join("; ")}`);
|
|
339
|
+
const retry = await this._install();
|
|
340
|
+
if (retry) return retry;
|
|
341
|
+
if (this._findMissingDeps(deps).length) logger$2.warn("自动补装后依赖链接仍不完整,请在项目根目录运行 bun install --force 并重启 koishi");
|
|
342
|
+
}
|
|
272
343
|
}
|
|
273
344
|
this.refresh();
|
|
274
345
|
const newDeps = await this.getDeps();
|
|
@@ -359,10 +430,14 @@ var MarketProvider$1 = class extends MarketProvider {
|
|
|
359
430
|
* 服务端瞬态错误(408 / 5xx)可安全重试;allowNotFound 时把 404
|
|
360
431
|
* 也视为瞬态——镜像索引以「302 指向版本化文件」方式发布,切换文件
|
|
361
432
|
* 的部署窗口会出现短时 404。
|
|
433
|
+
*
|
|
434
|
+
* 无 code 且无 response 的 HTTP.Error 是 plugin-http 对 fetch 网络层
|
|
435
|
+
* 异常(证书验证失败、连接重置、DNS 等)的统一包装——代理节点切换
|
|
436
|
+
* 等瞬态网络抖动即此形态,重试可自愈。
|
|
362
437
|
*/
|
|
363
438
|
isRetryable(error, allowNotFound = false) {
|
|
364
439
|
if (!this.ctx.http.isError(error)) return false;
|
|
365
|
-
if (!error.response) return error.code === "ETIMEDOUT";
|
|
440
|
+
if (!error.response) return error.code === void 0 || error.code === "ETIMEDOUT";
|
|
366
441
|
const { status } = error.response;
|
|
367
442
|
return status === 429 || status === 408 || allowNotFound && status === 404 || status !== void 0 && status >= 500;
|
|
368
443
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koishi-ce/plugin-market",
|
|
3
3
|
"description": "Manage your bots and plugins with console",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.14",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.mjs",
|
|
7
7
|
"typings": "lib/index.d.ts",
|
|
@@ -60,9 +60,10 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@koishi-ce/
|
|
64
|
-
"@koishi-ce/
|
|
65
|
-
"@koishi-ce/
|
|
63
|
+
"@koishi-ce/plugin-database-memory": "^1.1.0",
|
|
64
|
+
"@koishi-ce/client": "^1.2.3",
|
|
65
|
+
"@koishi-ce/loader": "^1.0.8",
|
|
66
|
+
"@koishi-ce/plugin-config": "^1.1.2",
|
|
66
67
|
"@koishijs/market": "^4.2.10",
|
|
67
68
|
"@types/semver": "^7.5.8",
|
|
68
69
|
"@vueuse/core": "^14.4.0",
|
|
@@ -16,9 +16,10 @@ import {
|
|
|
16
16
|
rmSync,
|
|
17
17
|
writeFileSync,
|
|
18
18
|
} from "node:fs";
|
|
19
|
+
import net from "node:net";
|
|
19
20
|
import { tmpdir } from "node:os";
|
|
20
21
|
import { join } from "node:path";
|
|
21
|
-
import memory from "@
|
|
22
|
+
import memory from "@koishi-ce/plugin-database-memory";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* market 插件测试:
|
|
@@ -297,8 +298,7 @@ type TestApp = InstanceType<typeof App>;
|
|
|
297
298
|
|
|
298
299
|
const app = new App();
|
|
299
300
|
|
|
300
|
-
|
|
301
|
-
app.plugin(memory as unknown as typeof memory.default);
|
|
301
|
+
app.plugin(memory);
|
|
302
302
|
app.plugin(http);
|
|
303
303
|
// Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
|
|
304
304
|
app.plugin(
|
|
@@ -493,6 +493,108 @@ describe("Installer 安装链路", () => {
|
|
|
493
493
|
expect(code).toBe(-1);
|
|
494
494
|
});
|
|
495
495
|
|
|
496
|
+
/** 在临时 node_modules 落盘一个带 dependencies 声明的校验用包 */
|
|
497
|
+
function placeLinkcheckPkg(
|
|
498
|
+
dependencies: Record<string, string>,
|
|
499
|
+
) {
|
|
500
|
+
const name = "koishi-plugin-linkcheck";
|
|
501
|
+
const pkgDir = join(tmp, "node_modules", name);
|
|
502
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
503
|
+
writeFileSync(
|
|
504
|
+
join(pkgDir, "package.json"),
|
|
505
|
+
JSON.stringify({
|
|
506
|
+
name,
|
|
507
|
+
version: "1.0.0",
|
|
508
|
+
dependencies,
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
return pkgDir;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
itQuiet(
|
|
515
|
+
"装后依赖链接完整时不触发补装",
|
|
516
|
+
async () => {
|
|
517
|
+
nextExitCode = 0;
|
|
518
|
+
spawnCalls.length = 0;
|
|
519
|
+
const pkgDir = placeLinkcheckPkg({
|
|
520
|
+
"linkcheck-dep": "^1.0.0",
|
|
521
|
+
});
|
|
522
|
+
const depDir = join(
|
|
523
|
+
tmp,
|
|
524
|
+
"node_modules",
|
|
525
|
+
"linkcheck-dep",
|
|
526
|
+
);
|
|
527
|
+
mkdirSync(depDir, { recursive: true });
|
|
528
|
+
writeFileSync(
|
|
529
|
+
join(depDir, "package.json"),
|
|
530
|
+
JSON.stringify({
|
|
531
|
+
name: "linkcheck-dep",
|
|
532
|
+
version: "1.0.0",
|
|
533
|
+
}),
|
|
534
|
+
);
|
|
535
|
+
try {
|
|
536
|
+
const code = await app.installer.install(
|
|
537
|
+
{ "koishi-plugin-linkcheck": "^1.0.0" },
|
|
538
|
+
true,
|
|
539
|
+
);
|
|
540
|
+
expect(code).toBe(0);
|
|
541
|
+
expect(spawnCalls.length).toBe(1);
|
|
542
|
+
} finally {
|
|
543
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
544
|
+
rmSync(depDir, { recursive: true, force: true });
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
15000,
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
itQuiet(
|
|
551
|
+
"包级依赖链接缺失时自动补装一次且不无限重试",
|
|
552
|
+
async () => {
|
|
553
|
+
nextExitCode = 0;
|
|
554
|
+
spawnCalls.length = 0;
|
|
555
|
+
// isolated 增量安装缺陷形态:包已落盘但声明的依赖未链接。
|
|
556
|
+
// 首装后校验命中补装一次;mock 安装不会真落盘依赖,二次校验
|
|
557
|
+
// 仍缺,只告警不再补装——总计恰好两次子进程调用
|
|
558
|
+
const pkgDir = placeLinkcheckPkg({
|
|
559
|
+
"linkcheck-missing": "^1.0.0",
|
|
560
|
+
});
|
|
561
|
+
try {
|
|
562
|
+
const code = await app.installer.install(
|
|
563
|
+
{ "koishi-plugin-linkcheck": "^1.0.0" },
|
|
564
|
+
true,
|
|
565
|
+
);
|
|
566
|
+
expect(code).toBe(0);
|
|
567
|
+
expect(spawnCalls.length).toBe(2);
|
|
568
|
+
} finally {
|
|
569
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
570
|
+
}
|
|
571
|
+
},
|
|
572
|
+
15000,
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
itQuiet(
|
|
576
|
+
"alias 声明的依赖跳过链接校验",
|
|
577
|
+
async () => {
|
|
578
|
+
nextExitCode = 0;
|
|
579
|
+
spawnCalls.length = 0;
|
|
580
|
+
// alias 落盘名与键名无直接映射,不入校验,缺失也不触发补装
|
|
581
|
+
const pkgDir = placeLinkcheckPkg({
|
|
582
|
+
"linkcheck-alias": "npm:whatever@^1.0.0",
|
|
583
|
+
});
|
|
584
|
+
try {
|
|
585
|
+
const code = await app.installer.install(
|
|
586
|
+
{ "koishi-plugin-linkcheck": "^1.0.0" },
|
|
587
|
+
true,
|
|
588
|
+
);
|
|
589
|
+
expect(code).toBe(0);
|
|
590
|
+
expect(spawnCalls.length).toBe(1);
|
|
591
|
+
} finally {
|
|
592
|
+
rmSync(pkgDir, { recursive: true, force: true });
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
15000,
|
|
596
|
+
);
|
|
597
|
+
|
|
496
598
|
itQuiet(
|
|
497
599
|
"exec 收集子进程 stdout / stderr 输出行",
|
|
498
600
|
async () => {
|
|
@@ -1014,9 +1116,7 @@ describe("镜像索引瞬态重试", () => {
|
|
|
1014
1116
|
timeout?: number;
|
|
1015
1117
|
}) {
|
|
1016
1118
|
const mirror = new App();
|
|
1017
|
-
mirror.plugin(
|
|
1018
|
-
memory as unknown as typeof memory.default,
|
|
1019
|
-
);
|
|
1119
|
+
mirror.plugin(memory);
|
|
1020
1120
|
mirror.plugin(http);
|
|
1021
1121
|
mirror.plugin(
|
|
1022
1122
|
FakeConsole as unknown as Plugin.Constructor<TestApp>,
|
|
@@ -1072,4 +1172,71 @@ describe("镜像索引瞬态重试", () => {
|
|
|
1072
1172
|
},
|
|
1073
1173
|
15000,
|
|
1074
1174
|
);
|
|
1175
|
+
|
|
1176
|
+
// 裸 TCP 桩:前 planned 次请求收到请求头后直接销毁连接(复现
|
|
1177
|
+
// 证书验证失败 / 连接重置等 fetch 网络层瞬态错误),随后回正常索引
|
|
1178
|
+
function startFlakyTcpServer(planned: number) {
|
|
1179
|
+
let hits = 0;
|
|
1180
|
+
const server = net.createServer((socket) => {
|
|
1181
|
+
socket.on("data", () => {
|
|
1182
|
+
hits++;
|
|
1183
|
+
if (hits <= planned) {
|
|
1184
|
+
socket.destroy();
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const body = JSON.stringify({
|
|
1188
|
+
version: "4",
|
|
1189
|
+
objects: [
|
|
1190
|
+
{
|
|
1191
|
+
package: {
|
|
1192
|
+
name: "koishi-plugin-demo",
|
|
1193
|
+
version: "2.0.0",
|
|
1194
|
+
date: "2024-06-01T00:00:00Z",
|
|
1195
|
+
keywords: ["koishi", "plugin"],
|
|
1196
|
+
},
|
|
1197
|
+
},
|
|
1198
|
+
],
|
|
1199
|
+
});
|
|
1200
|
+
socket.end(
|
|
1201
|
+
`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
|
|
1202
|
+
);
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
1205
|
+
return new Promise<{
|
|
1206
|
+
port: number;
|
|
1207
|
+
hits: () => number;
|
|
1208
|
+
close: () => void;
|
|
1209
|
+
}>((resolve) => {
|
|
1210
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1211
|
+
const { port } =
|
|
1212
|
+
server.address() as net.AddressInfo;
|
|
1213
|
+
resolve({
|
|
1214
|
+
port,
|
|
1215
|
+
hits: () => hits,
|
|
1216
|
+
close: () => {
|
|
1217
|
+
server.close();
|
|
1218
|
+
},
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
it("网络层瞬态错误(连接重置)经退避重试后恢复", async () => {
|
|
1225
|
+
const stub = await startFlakyTcpServer(2);
|
|
1226
|
+
const mirror = await createMirrorApp({
|
|
1227
|
+
endpoint: `http://127.0.0.1:${stub.port}/`,
|
|
1228
|
+
});
|
|
1229
|
+
const svc = mirror.get("console.services.market");
|
|
1230
|
+
// 前两次连接被重置(fetch 抛 TypeError,plugin-http 包装为无 code
|
|
1231
|
+
// 无 response 的 HTTP.Error),退避重试后第三次成功
|
|
1232
|
+
await svc?.start(true);
|
|
1233
|
+
expect(stub.hits()).toBeGreaterThanOrEqual(3);
|
|
1234
|
+
const payload = await svc?.get();
|
|
1235
|
+
expect(Object.keys(payload?.data ?? {})).toEqual([
|
|
1236
|
+
"koishi-plugin-demo",
|
|
1237
|
+
]);
|
|
1238
|
+
expect(payload?.total).toBe(1);
|
|
1239
|
+
await mirror.stop();
|
|
1240
|
+
stub.close();
|
|
1241
|
+
}, 10000);
|
|
1075
1242
|
});
|
package/src/node/installer.ts
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
// Copyright (c) 2019-present Shigma and Koishijs contributors.
|
|
3
3
|
// Copyright (c) 2026-present Koishi-CE contributors.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
} from "node:fs";
|
|
6
10
|
import { homedir } from "node:os";
|
|
7
|
-
import { join, resolve } from "node:path";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
12
|
import type {} from "@koishi-ce/console";
|
|
9
13
|
import {
|
|
10
14
|
type Context,
|
|
@@ -53,6 +57,37 @@ function isGuardedRequest(
|
|
|
53
57
|
);
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
/**
|
|
61
|
+
* 从 fromDir 沿 node_modules 链纯 fs 探测依赖包,命中返回其真实目录。
|
|
62
|
+
* realpath 化是 isolated 布局的关键:顶层条目是 symlink,包的依赖链接
|
|
63
|
+
* 挂在 .bun 真实目录的同级 node_modules,从链接路径向上爬会错误地查
|
|
64
|
+
* 顶层(isolated 不提升传递依赖)。
|
|
65
|
+
*/
|
|
66
|
+
function probePackage(
|
|
67
|
+
fromDir: string,
|
|
68
|
+
name: string,
|
|
69
|
+
): string | undefined {
|
|
70
|
+
let dir = resolve(fromDir);
|
|
71
|
+
for (;;) {
|
|
72
|
+
const manifestPath = join(
|
|
73
|
+
dir,
|
|
74
|
+
"node_modules",
|
|
75
|
+
name,
|
|
76
|
+
"package.json",
|
|
77
|
+
);
|
|
78
|
+
if (existsSync(manifestPath)) {
|
|
79
|
+
try {
|
|
80
|
+
return dirname(realpathSync(manifestPath));
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const parent = dirname(dir);
|
|
86
|
+
if (parent === dir) return undefined;
|
|
87
|
+
dir = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
56
91
|
/** 从单个 .npmrc 文件提取 registry 配置项;文件不存在或读取出错一律视为未配置 */
|
|
57
92
|
function readNpmrcRegistry(
|
|
58
93
|
file: string,
|
|
@@ -174,7 +209,7 @@ class Installer extends Service {
|
|
|
174
209
|
Promise<Dict<Pick<RemotePackage, DependencyMetaKey>>>
|
|
175
210
|
> = {};
|
|
176
211
|
private manifest: LocalPackage;
|
|
177
|
-
private
|
|
212
|
+
declare private depTask: Promise<Dict<Dependency>>;
|
|
178
213
|
private flushData: () => void;
|
|
179
214
|
|
|
180
215
|
override config: Installer.Config;
|
|
@@ -404,6 +439,59 @@ class Installer extends Service {
|
|
|
404
439
|
return this.exec(args);
|
|
405
440
|
}
|
|
406
441
|
|
|
442
|
+
/**
|
|
443
|
+
* 安装完整性校验:从本次安装的包出发递归纯 fs 探测依赖可达性,返回
|
|
444
|
+
* 「包: 缺失依赖」描述列表。背景是 Bun isolated 布局的增量安装缺陷
|
|
445
|
+
* (2026-09-07 实证):升级插件时包目录 hash 变化,而其依赖的解析
|
|
446
|
+
* 结果未变,bun 的增量安装跳过新包目录内的依赖链接建立——插件入口
|
|
447
|
+
* (顶层 symlink)可加载,内部依赖的 require 却炸 MODULE_NOT_FOUND;
|
|
448
|
+
* 此时补跑一次普通 bun install,bun 按磁盘实际状态检测到包不完整
|
|
449
|
+
* 即重建链接(秒级)。全程只走 existsSync / readFileSync /
|
|
450
|
+
* realpathSync,不触碰解析 API(防父目录快照缓存,与
|
|
451
|
+
* resolvePackageJson 同一纪律)。peer / optional 依赖由宿主或按需
|
|
452
|
+
* 提供不入校验;workspace / alias 等非 semver 声明的落盘名与键名无
|
|
453
|
+
* 直接映射,同样跳过。
|
|
454
|
+
*/
|
|
455
|
+
private _findMissingDeps(
|
|
456
|
+
deps: Dict<string | null>,
|
|
457
|
+
): string[] {
|
|
458
|
+
const problems: string[] = [];
|
|
459
|
+
const visited = new Set<string>();
|
|
460
|
+
const walk = (dir: string, label: string) => {
|
|
461
|
+
if (visited.has(dir)) return;
|
|
462
|
+
visited.add(dir);
|
|
463
|
+
let manifest: LocalPackage;
|
|
464
|
+
try {
|
|
465
|
+
manifest = JSON.parse(
|
|
466
|
+
readFileSync(join(dir, "package.json"), "utf8"),
|
|
467
|
+
) as LocalPackage;
|
|
468
|
+
} catch {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
for (const [dep, request] of Object.entries(
|
|
472
|
+
manifest.dependencies ?? {},
|
|
473
|
+
)) {
|
|
474
|
+
if (request.includes(":")) continue;
|
|
475
|
+
const depDir = probePackage(dir, dep);
|
|
476
|
+
if (!depDir) {
|
|
477
|
+
problems.push(`${label} 的依赖 ${dep} 未被链接`);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
walk(depDir, `${label} → ${dep}`);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
for (const name in deps) {
|
|
484
|
+
const request = deps[name];
|
|
485
|
+
if (!request || request.includes(":")) continue;
|
|
486
|
+
const dir = probePackage(this.cwd, name);
|
|
487
|
+
// 顶层包未落盘属于安装失败,由退出码与后续加载告警兜底,
|
|
488
|
+
// 不属于「包已装但链接缺失」的补装场景
|
|
489
|
+
if (!dir) continue;
|
|
490
|
+
walk(dir, name);
|
|
491
|
+
}
|
|
492
|
+
return problems;
|
|
493
|
+
}
|
|
494
|
+
|
|
407
495
|
private _getLocalDeps(override: Dict<string | null>) {
|
|
408
496
|
return valueMap(override, (request, name) => {
|
|
409
497
|
const dep = { request } as Dependency;
|
|
@@ -443,6 +531,22 @@ class Installer extends Service {
|
|
|
443
531
|
if (shouldInstall) {
|
|
444
532
|
const code = await this._install();
|
|
445
533
|
if (code) return code;
|
|
534
|
+
// isolated 布局的增量安装可能漏建新包目录的依赖链接(机理
|
|
535
|
+
// 见 _findMissingDeps 注释):装完即校验,缺失时补跑一次
|
|
536
|
+
// 安装——bun 按磁盘实际状态重建链接,秒级自愈,无需人工
|
|
537
|
+
const missing = this._findMissingDeps(deps);
|
|
538
|
+
if (missing.length) {
|
|
539
|
+
logger.warn(
|
|
540
|
+
`检测到增量安装未建立部分依赖链接(isolated 布局已知缺陷),正在补装: ${missing.join("; ")}`,
|
|
541
|
+
);
|
|
542
|
+
const retry = await this._install();
|
|
543
|
+
if (retry) return retry;
|
|
544
|
+
if (this._findMissingDeps(deps).length) {
|
|
545
|
+
logger.warn(
|
|
546
|
+
"自动补装后依赖链接仍不完整,请在项目根目录运行 bun install --force 并重启 koishi",
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
446
550
|
}
|
|
447
551
|
|
|
448
552
|
this.refresh();
|
package/src/node/market.ts
CHANGED
|
@@ -22,8 +22,8 @@ const logger = new Logger("market");
|
|
|
22
22
|
|
|
23
23
|
// registry 接口对无认证请求有速率限制:搜索接口(/-/v1/search)超频时
|
|
24
24
|
// 返回 429,collect 一旦失败会中断整个市场数据刷新(prepare 置 _error,
|
|
25
|
-
//
|
|
26
|
-
//
|
|
25
|
+
// 页面清空)。对限流、请求超时、fetch 网络层瞬态错误与 5xx 做指数退避
|
|
26
|
+
// 重试;镜像索引的 404(部署窗口内版本化文件尚未就绪)同样按瞬态处理。
|
|
27
27
|
/** 首次请求失败后的最大重试次数 */
|
|
28
28
|
const maxRetries = 2;
|
|
29
29
|
/** 重试的基础退避时长,按尝试次数指数放大 */
|
|
@@ -69,13 +69,21 @@ class MarketProvider extends BaseMarketProvider {
|
|
|
69
69
|
* 服务端瞬态错误(408 / 5xx)可安全重试;allowNotFound 时把 404
|
|
70
70
|
* 也视为瞬态——镜像索引以「302 指向版本化文件」方式发布,切换文件
|
|
71
71
|
* 的部署窗口会出现短时 404。
|
|
72
|
+
*
|
|
73
|
+
* 无 code 且无 response 的 HTTP.Error 是 plugin-http 对 fetch 网络层
|
|
74
|
+
* 异常(证书验证失败、连接重置、DNS 等)的统一包装——代理节点切换
|
|
75
|
+
* 等瞬态网络抖动即此形态,重试可自愈。
|
|
72
76
|
*/
|
|
73
77
|
private isRetryable(
|
|
74
78
|
error: unknown,
|
|
75
79
|
allowNotFound = false,
|
|
76
80
|
): error is HTTP.Error {
|
|
77
81
|
if (!this.ctx.http.isError(error)) return false;
|
|
78
|
-
if (!error.response)
|
|
82
|
+
if (!error.response)
|
|
83
|
+
return (
|
|
84
|
+
error.code === undefined ||
|
|
85
|
+
error.code === "ETIMEDOUT"
|
|
86
|
+
);
|
|
79
87
|
const { status } = error.response;
|
|
80
88
|
return (
|
|
81
89
|
status === 429 ||
|