@koishi-ce/plugin-market 1.0.3 → 1.0.5
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/lib/index.d.ts +24 -22
- package/lib/index.mjs +44 -10
- package/package.json +4 -4
- package/src/__tests__/index.test.ts +674 -0
- package/src/node/installer.ts +55 -15
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { Awaitable, Context, Dict
|
|
1
|
+
import { Awaitable, Context, Dict, HTTP, Schema, Service } from "@koishi-ce/koishi";
|
|
2
2
|
import { DataService } from "@koishi-ce/console";
|
|
3
|
-
import { DependencyMetaKey, RemotePackage, SearchObject
|
|
3
|
+
import { DependencyMetaKey, RemotePackage, SearchObject, SearchResult } from "@koishi-ce/registry";
|
|
4
4
|
//#region src/node/installer.d.ts
|
|
5
5
|
interface Dependency {
|
|
6
6
|
/**
|
|
@@ -23,8 +23,8 @@ interface Dependency {
|
|
|
23
23
|
declare class Installer extends Service {
|
|
24
24
|
http: HTTP;
|
|
25
25
|
endpoint: string | undefined;
|
|
26
|
-
fullCache: Dict
|
|
27
|
-
tempCache: Dict
|
|
26
|
+
fullCache: Dict<Dict<Pick<RemotePackage, DependencyMetaKey>>>;
|
|
27
|
+
tempCache: Dict<Dict<Pick<RemotePackage, DependencyMetaKey>>>;
|
|
28
28
|
private pkgTasks;
|
|
29
29
|
private agent;
|
|
30
30
|
private manifest;
|
|
@@ -32,22 +32,22 @@ declare class Installer extends Service {
|
|
|
32
32
|
private flushData;
|
|
33
33
|
config: Installer.Config;
|
|
34
34
|
constructor(ctx: Context, config: Installer.Config);
|
|
35
|
-
get cwd():
|
|
35
|
+
get cwd(): string;
|
|
36
36
|
start(): Promise<void>;
|
|
37
37
|
resolveName(name: string): string[];
|
|
38
|
-
findVersion(names: string[]): Promise<
|
|
38
|
+
findVersion(names: string[]): Promise<Dict<string> | undefined>;
|
|
39
39
|
private _getPackage;
|
|
40
40
|
setPackage(name: string, versions: RemotePackage[]): void;
|
|
41
|
-
getPackage(name: string):
|
|
41
|
+
getPackage(name: string): Promise<Dict<Pick<RemotePackage, DependencyMetaKey>>>;
|
|
42
42
|
private _getDeps;
|
|
43
|
-
getDeps(): Promise<Dict
|
|
43
|
+
getDeps(): Promise<Dict<Dependency>>;
|
|
44
44
|
refreshData(): void;
|
|
45
45
|
refresh(refresh?: boolean): void;
|
|
46
46
|
exec(args: string[]): Promise<number>;
|
|
47
|
-
override(deps: Dict
|
|
47
|
+
override(deps: Dict<string | null>): Promise<void>;
|
|
48
48
|
private _install;
|
|
49
49
|
private _getLocalDeps;
|
|
50
|
-
install(deps: Dict
|
|
50
|
+
install(deps: Dict<string | null>, forced?: boolean): Promise<number>;
|
|
51
51
|
static Config: Schema<Installer.Config>;
|
|
52
52
|
}
|
|
53
53
|
declare namespace Installer {
|
|
@@ -58,13 +58,13 @@ declare namespace Installer {
|
|
|
58
58
|
}
|
|
59
59
|
//#endregion
|
|
60
60
|
//#region src/node/deps.d.ts
|
|
61
|
-
declare class DependencyProvider extends DataService<Dict
|
|
61
|
+
declare class DependencyProvider extends DataService<Dict<Dependency>> {
|
|
62
62
|
constructor(ctx: Context);
|
|
63
|
-
get(): Promise<
|
|
63
|
+
get(): Promise<Dict<Dependency>>;
|
|
64
64
|
}
|
|
65
|
-
declare class RegistryProvider extends DataService<Dict
|
|
65
|
+
declare class RegistryProvider extends DataService<Dict<Dict<Pick<RemotePackage, DependencyMetaKey>>>> {
|
|
66
66
|
constructor(ctx: Context);
|
|
67
|
-
get(): Promise<
|
|
67
|
+
get(): Promise<Dict<Dict<Pick<RemotePackage, DependencyMetaKey>>>>;
|
|
68
68
|
}
|
|
69
69
|
//#endregion
|
|
70
70
|
//#region src/shared/index.d.ts
|
|
@@ -90,7 +90,7 @@ declare abstract class MarketProvider extends DataService<MarketProvider.Payload
|
|
|
90
90
|
declare namespace MarketProvider {
|
|
91
91
|
interface Payload {
|
|
92
92
|
registry?: string | undefined;
|
|
93
|
-
data: Dict
|
|
93
|
+
data: Dict<SearchObject>;
|
|
94
94
|
total: number;
|
|
95
95
|
failed: number;
|
|
96
96
|
progress: number;
|
|
@@ -125,11 +125,13 @@ declare class MarketProvider$1 extends MarketProvider {
|
|
|
125
125
|
registry?: never;
|
|
126
126
|
gravatar?: never;
|
|
127
127
|
} | {
|
|
128
|
-
registry:
|
|
129
|
-
data:
|
|
128
|
+
registry: string | undefined;
|
|
129
|
+
data: {
|
|
130
|
+
[k: string]: SearchObject;
|
|
131
|
+
};
|
|
130
132
|
failed: number;
|
|
131
|
-
total:
|
|
132
|
-
progress:
|
|
133
|
+
total: number;
|
|
134
|
+
progress: number;
|
|
133
135
|
gravatar: string | undefined;
|
|
134
136
|
}>;
|
|
135
137
|
static Config: Schema<MarketProvider$1.Config>;
|
|
@@ -155,8 +157,8 @@ declare module "@koishi-ce/console" {
|
|
|
155
157
|
}
|
|
156
158
|
}
|
|
157
159
|
interface Events {
|
|
158
|
-
"market/install"(deps: Dict
|
|
159
|
-
"market/registry"(names: string[]): Promise<Dict
|
|
160
|
+
"market/install"(deps: Dict<string>, forced?: boolean): Promise<number>;
|
|
161
|
+
"market/registry"(names: string[]): Promise<Dict<Dict<Pick<RemotePackage, DependencyMetaKey>>>>;
|
|
160
162
|
}
|
|
161
163
|
}
|
|
162
164
|
declare const name = "market";
|
|
@@ -167,6 +169,6 @@ interface Config {
|
|
|
167
169
|
search?: MarketProvider$1.Config;
|
|
168
170
|
}
|
|
169
171
|
declare const Config: Schema<Config>;
|
|
170
|
-
declare function apply(ctx: Context, config: Config):
|
|
172
|
+
declare function apply(ctx: Context, config: Config): void;
|
|
171
173
|
//#endregion
|
|
172
174
|
export { Config, DependencyProvider, Installer, MarketProvider, RegistryProvider, apply, inject, name, usage };
|
package/lib/index.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
3
3
|
import { Logger, Schema, Service, Time, defineProperty, pick, sleep, valueMap } from "@koishi-ce/koishi";
|
|
4
4
|
import { compare, gt, satisfies, valid } from "semver";
|
|
5
5
|
import { DataService } from "@koishi-ce/console";
|
|
6
|
-
import { readFileSync } from "node:fs";
|
|
6
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import Scanner, { resolvePackageJson } from "@koishi-ce/registry";
|
|
9
9
|
import spawn from "execa";
|
|
@@ -33,6 +33,19 @@ var RegistryProvider = class extends DataService {
|
|
|
33
33
|
//#endregion
|
|
34
34
|
//#region src/node/installer.ts
|
|
35
35
|
const logger$2 = new Logger("market");
|
|
36
|
+
/**
|
|
37
|
+
* 判断依赖声明是否受护栏保护、不可被安装清单覆盖或删除。两类:
|
|
38
|
+
* - `workspace:` 声明是本仓库(monorepo)对上游裸名的归属(如 koishi
|
|
39
|
+
* 裸名 shim,见 packages/node/koishi);
|
|
40
|
+
* - `npm:@koishi-ce/...` alias 是下游脚手架生成项目对上游名的归属
|
|
41
|
+
* (如 "koishi": "npm:@koishi-ce/koishi-shim@^4.18.11",见
|
|
42
|
+
* packages/node/koishi-shim)。
|
|
43
|
+
* 两者被覆盖或删除都会让 peer 解析失去归属,重新拉下 npm 官方包形成
|
|
44
|
+
* 第二份框架副本。
|
|
45
|
+
*/
|
|
46
|
+
function isGuardedRequest(request) {
|
|
47
|
+
return request?.startsWith("workspace:") === true || request?.startsWith("npm:@koishi-ce") === true;
|
|
48
|
+
}
|
|
36
49
|
/** 从单个 .npmrc 文件提取 registry 配置项;文件不存在或读取出错一律视为未配置 */
|
|
37
50
|
function readNpmrcRegistry(file) {
|
|
38
51
|
try {
|
|
@@ -73,6 +86,32 @@ function loadManifest(name) {
|
|
|
73
86
|
defineProperty(meta, "$workspace", !filename.includes("node_modules"));
|
|
74
87
|
return meta;
|
|
75
88
|
}
|
|
89
|
+
/** 缓存键归一(win32 大小写不敏感),对齐 require.cache 键与探测路径的比对 */
|
|
90
|
+
function normalizeCacheKey(path) {
|
|
91
|
+
return process.platform === "win32" ? path.toLowerCase() : path;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 判断某依赖包是否有模块驻留在 require.cache(旧版本仍在内存)。
|
|
95
|
+
*
|
|
96
|
+
* 原上游实现用 require.resolve(name)(FIXME koishijs/webui#273 的报错
|
|
97
|
+
* 源):安装流程在包落盘前的探测已把该裸名记入 Bun 的进程内解析负缓
|
|
98
|
+
* 存,装完后同进程内必然抛 ResolveMessage。改为经 resolvePackageJson
|
|
99
|
+
* (fs 探测兜底)取包目录后扫 require.cache 前缀;任何环节失败都保守
|
|
100
|
+
* 返回 true——多一次重载只是进程重启,漏判才会让旧版本代码继续驻留。
|
|
101
|
+
*/
|
|
102
|
+
function isResidentInCache(name) {
|
|
103
|
+
try {
|
|
104
|
+
const dir = dirname(resolvePackageJson(name));
|
|
105
|
+
const prefixes = [normalizeCacheKey(dir + sep)];
|
|
106
|
+
try {
|
|
107
|
+
const real = realpathSync(dir);
|
|
108
|
+
if (real !== dir) prefixes.push(normalizeCacheKey(real + sep));
|
|
109
|
+
} catch {}
|
|
110
|
+
return Object.keys(__require.cache).some((key) => prefixes.some((prefix) => normalizeCacheKey(key).startsWith(prefix)));
|
|
111
|
+
} catch {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
76
115
|
function getVersions(versions) {
|
|
77
116
|
return Object.fromEntries(versions.map((item) => [item.version, pick(item, [
|
|
78
117
|
"peerDependencies",
|
|
@@ -227,9 +266,9 @@ var Installer = class extends Service {
|
|
|
227
266
|
this.manifest = JSON.parse(readFileSync(filename, "utf8"));
|
|
228
267
|
this.manifest.dependencies ||= {};
|
|
229
268
|
for (const key in deps) if (deps[key]) {
|
|
230
|
-
if (this.manifest.dependencies[key]
|
|
269
|
+
if (isGuardedRequest(this.manifest.dependencies[key])) continue;
|
|
231
270
|
this.manifest.dependencies[key] = deps[key];
|
|
232
|
-
} else if (!this.manifest.dependencies[key]
|
|
271
|
+
} else if (!isGuardedRequest(this.manifest.dependencies[key])) delete this.manifest.dependencies[key];
|
|
233
272
|
this.manifest.dependencies = Object.fromEntries(Object.entries(this.manifest.dependencies).sort((a, b) => a[0].localeCompare(b[0])));
|
|
234
273
|
await Bun.write(filename, `${JSON.stringify(this.manifest, null, " ")}\n`);
|
|
235
274
|
}
|
|
@@ -271,12 +310,7 @@ var Installer = class extends Service {
|
|
|
271
310
|
const newDep = newDeps[name];
|
|
272
311
|
if (!local || !newDep || local.workspace) continue;
|
|
273
312
|
if (newDep.resolved === local.resolved) continue;
|
|
274
|
-
|
|
275
|
-
if (!(__require.resolve(name) in __require.cache)) continue;
|
|
276
|
-
} catch (error) {
|
|
277
|
-
logger$2.error(error);
|
|
278
|
-
}
|
|
279
|
-
this.ctx.loader.fullReload();
|
|
313
|
+
if (isResidentInCache(name)) this.ctx.loader.fullReload();
|
|
280
314
|
}
|
|
281
315
|
this.refreshData();
|
|
282
316
|
return 0;
|
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.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.mjs",
|
|
7
7
|
"typings": "lib/index.d.ts",
|
|
@@ -61,15 +61,15 @@
|
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@koishi-ce/client": "^1.0.1",
|
|
64
|
-
"@koishi-ce/loader": "^1.0.
|
|
65
|
-
"@koishi-ce/plugin-config": "^1.0.
|
|
64
|
+
"@koishi-ce/loader": "^1.0.2",
|
|
65
|
+
"@koishi-ce/plugin-config": "^1.0.4",
|
|
66
66
|
"@koishijs/market": "^4.2.10",
|
|
67
67
|
"@types/semver": "^7.5.8",
|
|
68
68
|
"vue": "^3.5.12"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
71
|
"@koishi-ce/console": "^1.0.0",
|
|
72
|
-
"@koishi-ce/registry": "^1.0.
|
|
72
|
+
"@koishi-ce/registry": "^1.0.3",
|
|
73
73
|
"execa": "^5.1.1",
|
|
74
74
|
"p-map": "^4.0.0",
|
|
75
75
|
"semver": "^7.6.3",
|
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import memory from "@koishijs/plugin-database-memory";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* market 插件测试:
|
|
9
|
+
* - 子进程(npm/yarn 安装)经 mock.module 拦截 execa,不落盘不联网安装;
|
|
10
|
+
* - registry 网络请求由进程内 Bun.serve 提供(registry 协议的最小 JSON);
|
|
11
|
+
* - 宿主环境(loader / cwd)使用 FakeLoader 与临时目录 + chdir,
|
|
12
|
+
* Installer 的 override 写盘只会作用于临时 package.json。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 已发生的子进程调用(命令与参数)。 */
|
|
16
|
+
const spawnCalls: string[][] = [];
|
|
17
|
+
/** 控制下一次子进程的退出码与触发事件。 */
|
|
18
|
+
let nextExitCode = 0;
|
|
19
|
+
let nextSpawnError = false;
|
|
20
|
+
|
|
21
|
+
const execaMock = (name: string, args: string[]) => {
|
|
22
|
+
spawnCalls.push([name, ...args]);
|
|
23
|
+
return {
|
|
24
|
+
on(event: string, cb: (code?: number) => void) {
|
|
25
|
+
if (nextSpawnError) {
|
|
26
|
+
if (event === "error") setImmediate(() => cb());
|
|
27
|
+
} else if (event === "exit") {
|
|
28
|
+
setImmediate(() => cb(nextExitCode));
|
|
29
|
+
}
|
|
30
|
+
return this;
|
|
31
|
+
},
|
|
32
|
+
stderr: {
|
|
33
|
+
on(event: string, cb: (data: Buffer) => void) {
|
|
34
|
+
if (event === "data") {
|
|
35
|
+
setImmediate(() => cb(Buffer.from("stderr line\n")));
|
|
36
|
+
}
|
|
37
|
+
return this;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
stdout: {
|
|
41
|
+
on(event: string, cb: (data: Buffer) => void) {
|
|
42
|
+
if (event === "data") {
|
|
43
|
+
setImmediate(() => cb(Buffer.from("stdout line\n")));
|
|
44
|
+
}
|
|
45
|
+
return this;
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
mock.module("execa", () => ({ default: execaMock }));
|
|
52
|
+
|
|
53
|
+
import type { Entry } from "@koishi-ce/console";
|
|
54
|
+
// 均为 type-only 导入:编译期擦除,不干扰 mock.module 先于插件加载的时序
|
|
55
|
+
import type { Plugin } from "@koishi-ce/koishi";
|
|
56
|
+
import type { RemotePackage } from "@koishi-ce/registry";
|
|
57
|
+
|
|
58
|
+
const { Console } = await import("@koishi-ce/console");
|
|
59
|
+
const { App, Service } = await import("@koishi-ce/koishi");
|
|
60
|
+
const http = (await import("@koishi-ce/plugin-http")).default;
|
|
61
|
+
const market = await import("../node/index.ts");
|
|
62
|
+
const { default: Installer, isResidentInCache } = await import(
|
|
63
|
+
"../node/installer.ts"
|
|
64
|
+
);
|
|
65
|
+
const mockPlugin = (await import("@koishi-ce/plugin-mock")).default;
|
|
66
|
+
// 加载包入口占位文件(纯 re-export,无独立逻辑),保证 src 全量被加载
|
|
67
|
+
await import("../index.ts");
|
|
68
|
+
|
|
69
|
+
/** 控制台服务桩:仅实现入口登记所需的最小面。 */
|
|
70
|
+
class FakeConsole extends Console {
|
|
71
|
+
protected resolveEntry(_files: Entry.Files, _key: string): string[] {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** loader 服务桩(immediate Service):writable 可写、envData 供重启消息断言。 */
|
|
77
|
+
class FakeLoader extends Service {
|
|
78
|
+
writable = true;
|
|
79
|
+
envData: Record<string, unknown> = { message: null };
|
|
80
|
+
paths() {
|
|
81
|
+
return ["group:entry", "plugins"];
|
|
82
|
+
}
|
|
83
|
+
fullReload() {}
|
|
84
|
+
constructor(ctx: ConstructorParameters<typeof Service>[0]) {
|
|
85
|
+
super(ctx, "loader", true);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** registry 协议最小 JSON 服务(Bun.serve,随机端口)。 */
|
|
90
|
+
const registryData: {
|
|
91
|
+
[key: string]: {
|
|
92
|
+
versions: Record<
|
|
93
|
+
string,
|
|
94
|
+
{
|
|
95
|
+
version: string;
|
|
96
|
+
peerDependencies?: Record<string, string>;
|
|
97
|
+
deprecated?: boolean;
|
|
98
|
+
}
|
|
99
|
+
>;
|
|
100
|
+
time?: Record<string, string>;
|
|
101
|
+
};
|
|
102
|
+
} = {};
|
|
103
|
+
|
|
104
|
+
/** 搜索接口响应(/-/v1/search,collect 阶段消费)。 */
|
|
105
|
+
let searchResponse: { objects: unknown[]; total: number } | null = null;
|
|
106
|
+
|
|
107
|
+
const registryServer = Bun.serve({
|
|
108
|
+
port: 0,
|
|
109
|
+
hostname: "127.0.0.1",
|
|
110
|
+
async fetch(request) {
|
|
111
|
+
const url = new URL(request.url);
|
|
112
|
+
if (url.pathname.startsWith("/-/v1/search")) {
|
|
113
|
+
if (!searchResponse) {
|
|
114
|
+
return new Response("not found", { status: 404 });
|
|
115
|
+
}
|
|
116
|
+
return Response.json(searchResponse);
|
|
117
|
+
}
|
|
118
|
+
// 模拟 registry 限流:恒定 429 + 极短的 Retry-After,驱动重试后失败
|
|
119
|
+
if (url.pathname.includes("koishi-plugin-ratelimited")) {
|
|
120
|
+
return new Response("rate limited", {
|
|
121
|
+
status: 429,
|
|
122
|
+
headers: { "Retry-After": "0.01" },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const name = decodeURIComponent(url.pathname.slice(1));
|
|
126
|
+
const data = registryData[name];
|
|
127
|
+
if (!data) {
|
|
128
|
+
return new Response("not found", { status: 404 });
|
|
129
|
+
}
|
|
130
|
+
return Response.json(data);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// 预置一个兼容插件包:最新版 2.0.0,旧版 1.0.0 均声明 koishi ^4 peer
|
|
135
|
+
registryData["koishi-plugin-demo"] = {
|
|
136
|
+
versions: {
|
|
137
|
+
"1.0.0": { version: "1.0.0", peerDependencies: { koishi: "^4.17.0" } },
|
|
138
|
+
"2.0.0": { version: "2.0.0", peerDependencies: { koishi: "^4.17.0" } },
|
|
139
|
+
},
|
|
140
|
+
time: { "1.0.0": "2024-01-01T00:00:00Z", "2.0.0": "2024-06-01T00:00:00Z" },
|
|
141
|
+
};
|
|
142
|
+
// 预置一个未安装的插件包(plugin.install 安装路径使用)
|
|
143
|
+
registryData["koishi-plugin-newpkg"] = {
|
|
144
|
+
versions: {
|
|
145
|
+
"1.0.0": { version: "1.0.0", peerDependencies: { koishi: "^4.17.0" } },
|
|
146
|
+
},
|
|
147
|
+
time: { "1.0.0": "2024-01-01T00:00:00Z" },
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** 临时宿主目录(Installer 的 cwd 与 override 写盘目标)。 */
|
|
151
|
+
const initialDependencies = {
|
|
152
|
+
// 护栏:workspace 声明不可被覆盖或删除
|
|
153
|
+
koishi: "workspace:*",
|
|
154
|
+
// 护栏:npm:@koishi-ce alias 同样受保护
|
|
155
|
+
"market-alias": "npm:@koishi-ce/anything@^1.0.0",
|
|
156
|
+
// 普通依赖:可解析远端最新版
|
|
157
|
+
"koishi-plugin-demo": "^1.0.0",
|
|
158
|
+
// 非法 semver 区间:应标记 invalid
|
|
159
|
+
"bad-range": "not-a-version",
|
|
160
|
+
};
|
|
161
|
+
const tmp = mkdtempSync(join(tmpdir(), "market-test-"));
|
|
162
|
+
const originalCwd = process.cwd();
|
|
163
|
+
writeFileSync(
|
|
164
|
+
join(tmp, "package.json"),
|
|
165
|
+
JSON.stringify(
|
|
166
|
+
{ name: "market-host", dependencies: initialDependencies },
|
|
167
|
+
null,
|
|
168
|
+
"\t",
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
process.chdir(tmp);
|
|
172
|
+
|
|
173
|
+
// App 经动态 import 取值为 const,实例类型由构造器派生供 Plugin.Constructor 泛型使用
|
|
174
|
+
type TestApp = InstanceType<typeof App>;
|
|
175
|
+
|
|
176
|
+
const app = new App();
|
|
177
|
+
|
|
178
|
+
// 同 admin:CJS 实现配 ESM 声明,nodenext 互操作视图多包一层 default,类型层穿透取真实类
|
|
179
|
+
app.plugin(memory as unknown as typeof memory.default);
|
|
180
|
+
app.plugin(http);
|
|
181
|
+
// Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
|
|
182
|
+
app.plugin(FakeConsole as unknown as Plugin.Constructor<TestApp>);
|
|
183
|
+
app.plugin(FakeLoader);
|
|
184
|
+
app.plugin(market, {
|
|
185
|
+
registry: { endpoint: `http://127.0.0.1:${registryServer.port}/` },
|
|
186
|
+
});
|
|
187
|
+
app.plugin(mockPlugin);
|
|
188
|
+
|
|
189
|
+
const client = app.mock.client("123");
|
|
190
|
+
|
|
191
|
+
beforeAll(async () => {
|
|
192
|
+
await app.start();
|
|
193
|
+
await app.mock.initUser("123", 4);
|
|
194
|
+
// 触发 installer 等延迟服务的实例化
|
|
195
|
+
expect(app.installer).toBeDefined();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
afterAll(async () => {
|
|
199
|
+
await app.stop();
|
|
200
|
+
process.chdir(originalCwd);
|
|
201
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
202
|
+
registryServer.stop(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("market 插件", () => {
|
|
206
|
+
it("注册三个数据服务与浏览器监听器", () => {
|
|
207
|
+
expect(app.get("console.services.market")).toBeDefined();
|
|
208
|
+
expect(app.get("console.services.dependencies")).toBeDefined();
|
|
209
|
+
expect(app.get("console.services.registry")).toBeDefined();
|
|
210
|
+
expect(app.console.listeners["market/install"]).toBeDefined();
|
|
211
|
+
expect(app.console.listeners["market/registry"]).toBeDefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("resolveName 解析插件短名的候选全名", () => {
|
|
215
|
+
const installer = app.installer;
|
|
216
|
+
expect(installer.resolveName("@koishijs/plugin-echo")).toEqual([
|
|
217
|
+
"@koishijs/plugin-echo",
|
|
218
|
+
]);
|
|
219
|
+
expect(installer.resolveName("koishi-plugin-echo")).toEqual([
|
|
220
|
+
"koishi-plugin-echo",
|
|
221
|
+
]);
|
|
222
|
+
expect(installer.resolveName("@scope/echo")).toEqual([
|
|
223
|
+
"@scope/koishi-plugin-echo",
|
|
224
|
+
]);
|
|
225
|
+
expect(installer.resolveName("echo")).toEqual([
|
|
226
|
+
"@koishijs/plugin-echo",
|
|
227
|
+
"koishi-plugin-echo",
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("getDeps 汇总本地依赖并带出远端最新版", async () => {
|
|
232
|
+
const deps = await app.installer.getDeps();
|
|
233
|
+
// 语义化区间去除前缀符号
|
|
234
|
+
expect(deps["koishi-plugin-demo"]?.request).toBe("1.0.0");
|
|
235
|
+
// 远端最新版(本地 registry 预置 2.0.0)
|
|
236
|
+
expect(deps["koishi-plugin-demo"]?.latest).toBe("2.0.0");
|
|
237
|
+
// 非法 semver 标记 invalid
|
|
238
|
+
expect(deps["bad-range"]?.invalid).toBe(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("findVersion 返回首个存在的候选包版本", async () => {
|
|
242
|
+
const found = await app.installer.findVersion([
|
|
243
|
+
"@koishijs/plugin-none",
|
|
244
|
+
"koishi-plugin-demo",
|
|
245
|
+
]);
|
|
246
|
+
expect(found).toEqual({ "koishi-plugin-demo": "2.0.0" });
|
|
247
|
+
// 全部不存在时返回 undefined
|
|
248
|
+
expect(
|
|
249
|
+
await app.installer.findVersion(["@koishijs/plugin-none"]),
|
|
250
|
+
).toBeUndefined();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("getPackage 拉取失败时回退为空表", async () => {
|
|
254
|
+
const versions = await app.installer.getPackage("koishi-plugin-missing");
|
|
255
|
+
expect(versions).toEqual({});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("setPackage 写入缓存并触发节流广播", async () => {
|
|
259
|
+
app.installer.setPackage("koishi-plugin-demo", [
|
|
260
|
+
{
|
|
261
|
+
version: "3.0.0",
|
|
262
|
+
peerDependencies: { koishi: "^4.17.0" },
|
|
263
|
+
// RemotePackage 的其余元数据字段与本断言无关,最小载荷经 unknown 二段式断言
|
|
264
|
+
} as unknown as RemotePackage,
|
|
265
|
+
]);
|
|
266
|
+
expect(
|
|
267
|
+
Object.keys(app.installer.fullCache["koishi-plugin-demo"] ?? {}),
|
|
268
|
+
).toEqual(["3.0.0"]);
|
|
269
|
+
// 等待节流窗口
|
|
270
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("Installer 安装链路", () => {
|
|
275
|
+
it("install 尊重护栏依赖并执行子进程安装", async () => {
|
|
276
|
+
nextExitCode = 0;
|
|
277
|
+
spawnCalls.length = 0;
|
|
278
|
+
const code = await app.installer.install({
|
|
279
|
+
koishi: "2.0.0",
|
|
280
|
+
"market-alias": null,
|
|
281
|
+
"koishi-plugin-demo": "^1.0.0",
|
|
282
|
+
});
|
|
283
|
+
expect(code).toBe(0);
|
|
284
|
+
// 触发了包管理器安装(npm install --registry …)
|
|
285
|
+
expect(spawnCalls.length).toBe(1);
|
|
286
|
+
expect(spawnCalls[0]?.[0]).toBe("npm");
|
|
287
|
+
expect(spawnCalls[0]?.[1]).toBe("install");
|
|
288
|
+
// 重新读取临时 package.json:护栏项保持原样,新依赖加入
|
|
289
|
+
const manifest = JSON.parse(
|
|
290
|
+
await Bun.file(join(tmp, "package.json")).text(),
|
|
291
|
+
) as { dependencies: Record<string, string> };
|
|
292
|
+
expect(manifest.dependencies["koishi"]).toBe("workspace:*");
|
|
293
|
+
expect(manifest.dependencies["market-alias"]).toBe(
|
|
294
|
+
"npm:@koishi-ce/anything@^1.0.0",
|
|
295
|
+
);
|
|
296
|
+
expect(manifest.dependencies["koishi-plugin-demo"]).toBe("^1.0.0");
|
|
297
|
+
}, 15000);
|
|
298
|
+
|
|
299
|
+
it("install 强制时无视本地满足也要装", async () => {
|
|
300
|
+
nextExitCode = 0;
|
|
301
|
+
spawnCalls.length = 0;
|
|
302
|
+
const code = await app.installer.install(
|
|
303
|
+
{ "koishi-plugin-demo": "^1.0.0" },
|
|
304
|
+
true,
|
|
305
|
+
);
|
|
306
|
+
expect(code).toBe(0);
|
|
307
|
+
expect(spawnCalls.length).toBe(1);
|
|
308
|
+
}, 15000);
|
|
309
|
+
|
|
310
|
+
it("子进程非零退出码向上传递", async () => {
|
|
311
|
+
nextExitCode = 1;
|
|
312
|
+
spawnCalls.length = 0;
|
|
313
|
+
const code = await app.installer.install(
|
|
314
|
+
{ "koishi-plugin-demo": "^2.0.0" },
|
|
315
|
+
true,
|
|
316
|
+
);
|
|
317
|
+
expect(code).toBe(1);
|
|
318
|
+
}, 15000);
|
|
319
|
+
|
|
320
|
+
it("子进程 spawn 失败返回 -1", async () => {
|
|
321
|
+
nextSpawnError = true;
|
|
322
|
+
nextExitCode = 0;
|
|
323
|
+
const code = await app.installer.exec(["install"]);
|
|
324
|
+
nextSpawnError = false;
|
|
325
|
+
expect(code).toBe(-1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("exec 收集子进程 stdout / stderr 输出行", async () => {
|
|
329
|
+
nextExitCode = 0;
|
|
330
|
+
nextSpawnError = false;
|
|
331
|
+
const code = await app.installer.exec(["install"]);
|
|
332
|
+
expect(code).toBe(0);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("isResidentInCache(装后重载判定)", () => {
|
|
337
|
+
/** 在临时 node_modules 放一个已安装包,返回其目录 */
|
|
338
|
+
function placePkg(name: string) {
|
|
339
|
+
const pkgDir = join(tmp, "node_modules", name);
|
|
340
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
341
|
+
writeFileSync(
|
|
342
|
+
join(pkgDir, "package.json"),
|
|
343
|
+
JSON.stringify({ name, version: "1.0.0", main: "index.js" }),
|
|
344
|
+
);
|
|
345
|
+
writeFileSync(join(pkgDir, "index.js"), "module.exports = {}");
|
|
346
|
+
return pkgDir;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
it("包目录下有模块驻留 require.cache 时返回 true", () => {
|
|
350
|
+
const pkgDir = placePkg("koishi-plugin-resident-check");
|
|
351
|
+
// 入口经 require 进入 require.cache,模拟旧版本驻留内存
|
|
352
|
+
require(join(pkgDir, "index.js"));
|
|
353
|
+
expect(isResidentInCache("koishi-plugin-resident-check")).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("已安装但无模块驻留内存时返回 false", () => {
|
|
357
|
+
placePkg("koishi-plugin-idle-check");
|
|
358
|
+
expect(isResidentInCache("koishi-plugin-idle-check")).toBe(false);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("包不存在时保守返回 true(宁可多重载不漏判)", () => {
|
|
362
|
+
expect(isResidentInCache("koishi-plugin-absent-check")).toBe(true);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("registry 配置探测", () => {
|
|
367
|
+
it("无显式 endpoint 时按 npmrc / 环境变量探测", async () => {
|
|
368
|
+
// 环境变量优先:npm_config_registry 指向本地服务
|
|
369
|
+
process.env["npm_config_registry"] =
|
|
370
|
+
`http://127.0.0.1:${registryServer.port}/`;
|
|
371
|
+
const app2 = new App();
|
|
372
|
+
app2.plugin(http);
|
|
373
|
+
app2.plugin(Installer, {});
|
|
374
|
+
await app2.start();
|
|
375
|
+
expect(app2.installer.endpoint).toBe(
|
|
376
|
+
`http://127.0.0.1:${registryServer.port}/`,
|
|
377
|
+
);
|
|
378
|
+
await app2.stop();
|
|
379
|
+
|
|
380
|
+
// 环境变量缺失时回落到项目 .npmrc(含不合法行与合法 registry 行)
|
|
381
|
+
delete process.env["npm_config_registry"];
|
|
382
|
+
writeFileSync(
|
|
383
|
+
join(tmp, ".npmrc"),
|
|
384
|
+
"not-a-registry-line\nregistry=http://registry.example.npm/\n",
|
|
385
|
+
);
|
|
386
|
+
const app3 = new App();
|
|
387
|
+
app3.plugin(http);
|
|
388
|
+
app3.plugin(Installer, {});
|
|
389
|
+
await app3.start();
|
|
390
|
+
expect(app3.installer.endpoint).toBe("http://registry.example.npm/");
|
|
391
|
+
await app3.stop();
|
|
392
|
+
rmSync(join(tmp, ".npmrc"), { force: true });
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe("MarketProvider 市场数据服务", () => {
|
|
397
|
+
it("collect 经搜索接口收集并逐包分析填充缓存", async () => {
|
|
398
|
+
const svc = app.get("console.services.market");
|
|
399
|
+
expect(svc).toBeDefined();
|
|
400
|
+
// 提供搜索结果:一个插件条目 + 一个被忽略条目
|
|
401
|
+
searchResponse = {
|
|
402
|
+
objects: [
|
|
403
|
+
{
|
|
404
|
+
package: {
|
|
405
|
+
name: "koishi-plugin-demo",
|
|
406
|
+
version: "1.0.0",
|
|
407
|
+
date: "2024-01-01T00:00:00Z",
|
|
408
|
+
keywords: ["koishi", "plugin", "Tool"],
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
{ package: { name: "not-a-plugin", date: "2024-01-01T00:00:00Z" } },
|
|
412
|
+
],
|
|
413
|
+
total: 1,
|
|
414
|
+
};
|
|
415
|
+
// start(true) 强制刷新市场数据(重新 collect)
|
|
416
|
+
await svc?.start(true);
|
|
417
|
+
// 等待节流窗口与逐包分析完成
|
|
418
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
419
|
+
const payload = await svc?.get();
|
|
420
|
+
expect(payload).toBeDefined();
|
|
421
|
+
// 非 plugin 条目被剔除,只保留 demo
|
|
422
|
+
expect(Object.keys(payload?.data ?? {})).toEqual(["koishi-plugin-demo"]);
|
|
423
|
+
expect(payload?.total).toBe(1);
|
|
424
|
+
expect(payload?.failed).toBe(0);
|
|
425
|
+
expect(payload?.registry).toBe(`http://127.0.0.1:${registryServer.port}/`);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("依赖 / 注册表数据服务读取安装器缓存", async () => {
|
|
429
|
+
const dependencies = await app.get("console.services.dependencies")?.get();
|
|
430
|
+
expect(dependencies?.["koishi-plugin-demo"]?.request).toBeTruthy();
|
|
431
|
+
expect(dependencies?.["koishi-plugin-demo"]?.latest).toBe("2.0.0");
|
|
432
|
+
|
|
433
|
+
const registry = await app.get("console.services.registry")?.get();
|
|
434
|
+
expect(Object.keys(registry?.["koishi-plugin-demo"] ?? {})).toContain(
|
|
435
|
+
"2.0.0",
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("搜索接口失败时 get 返回空数据与错误标记", async () => {
|
|
440
|
+
const svc = app.get("console.services.market");
|
|
441
|
+
searchResponse = null;
|
|
442
|
+
// 强制重扫:collect 失败置 _error,get 返回空 payload
|
|
443
|
+
await svc?.start(true);
|
|
444
|
+
const payload = await svc?.get();
|
|
445
|
+
expect(payload).toEqual({ data: {}, failed: 0, total: 0, progress: 0 });
|
|
446
|
+
searchResponse = {
|
|
447
|
+
objects: [],
|
|
448
|
+
total: 0,
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("控制台连接事件在数据过期时触发刷新", async () => {
|
|
453
|
+
const svc = app.get("console.services.market");
|
|
454
|
+
expect(svc).toBeDefined();
|
|
455
|
+
// 伪造一个在线客户端,使连接事件通过在线检查(broadcast 需可用的 socket)
|
|
456
|
+
const fakeClient = { id: "conn-1", socket: { send() {} } };
|
|
457
|
+
(app.console.clients as Record<string, unknown>)["conn-1"] = fakeClient;
|
|
458
|
+
// 刚刷新过:12 小时窗口内直接返回,不重新收集
|
|
459
|
+
const timestamp = svc?.["_timestamp" as keyof typeof svc] as number;
|
|
460
|
+
// console/connection 载荷声明为 Client,桩对象仅含在线检查所需的最小面,类型层断言穿透
|
|
461
|
+
app.emit("console/connection", fakeClient as never);
|
|
462
|
+
expect(svc?.["_timestamp" as keyof typeof svc] as number).toBe(timestamp);
|
|
463
|
+
// 将时间戳回拨到窗口外,连接事件重新触发 start(异步监听,稍等)
|
|
464
|
+
(svc as Record<string, unknown>)["_timestamp"] = 0;
|
|
465
|
+
app.emit("console/connection", fakeClient as never);
|
|
466
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
467
|
+
expect(
|
|
468
|
+
(svc as Record<string, unknown>)["_timestamp"] as number,
|
|
469
|
+
).toBeGreaterThan(0);
|
|
470
|
+
delete app.console.clients["conn-1"];
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe("market 聊天指令", () => {
|
|
475
|
+
it("plugin.install 缺参与未找到的报错路径", async () => {
|
|
476
|
+
const missing = await client.receive("plugin.install");
|
|
477
|
+
expect(missing[0]).toContain("请输入插件名。");
|
|
478
|
+
const notFound = await client.receive("plugin.install absent-pkg");
|
|
479
|
+
expect(notFound[0]).toContain("未找到该插件。");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("plugin.install 已安装时提示重复", async () => {
|
|
483
|
+
const replies = await client.receive("plugin.install demo");
|
|
484
|
+
expect(replies[0]).toContain("该插件已安装。");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("plugin.install 安装新插件并写入依赖", async () => {
|
|
488
|
+
nextExitCode = 0;
|
|
489
|
+
spawnCalls.length = 0;
|
|
490
|
+
const replies = await client.receive("plugin.install newpkg");
|
|
491
|
+
expect(replies[0]).toContain("安装成功!");
|
|
492
|
+
expect(spawnCalls.length).toBe(1);
|
|
493
|
+
const manifest = JSON.parse(
|
|
494
|
+
await Bun.file(join(tmp, "package.json")).text(),
|
|
495
|
+
) as { dependencies: Record<string, string> };
|
|
496
|
+
expect(manifest.dependencies["koishi-plugin-newpkg"]).toBe("1.0.0");
|
|
497
|
+
// 重启消息在安装完成后复位(Loader 与桩形状不同,经 unknown 二段式断言)
|
|
498
|
+
expect((app.loader as unknown as FakeLoader).envData["message"]).toBeNull();
|
|
499
|
+
}, 15000);
|
|
500
|
+
|
|
501
|
+
it("plugin.uninstall 卸载依赖并从清单移除", async () => {
|
|
502
|
+
nextExitCode = 0;
|
|
503
|
+
spawnCalls.length = 0;
|
|
504
|
+
const replies = await client.receive("plugin.uninstall newpkg");
|
|
505
|
+
expect(replies[0]).toContain("卸载成功!");
|
|
506
|
+
const manifest = JSON.parse(
|
|
507
|
+
await Bun.file(join(tmp, "package.json")).text(),
|
|
508
|
+
) as { dependencies: Record<string, string> };
|
|
509
|
+
expect(manifest.dependencies["koishi-plugin-newpkg"]).toBeUndefined();
|
|
510
|
+
}, 15000);
|
|
511
|
+
|
|
512
|
+
it("plugin.uninstall 未安装时提示", async () => {
|
|
513
|
+
const replies = await client.receive("plugin.uninstall absent-pkg");
|
|
514
|
+
expect(replies[0]).toContain("该插件未安装。");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("plugin.upgrade 无可升级项时提示已最新", async () => {
|
|
518
|
+
const replies = await client.receive("plugin.upgrade");
|
|
519
|
+
expect(replies[0]).toContain("所有插件已是最新版本。");
|
|
520
|
+
}, 10000);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("market 进阶链路", () => {
|
|
524
|
+
it("宿主配置不可写时不加载安装器", async () => {
|
|
525
|
+
const appNoLoader = new App();
|
|
526
|
+
appNoLoader.plugin(http);
|
|
527
|
+
appNoLoader.plugin(FakeConsole as unknown as Plugin.Constructor<TestApp>);
|
|
528
|
+
appNoLoader.plugin(market, {
|
|
529
|
+
registry: { endpoint: `http://127.0.0.1:${registryServer.port}/` },
|
|
530
|
+
});
|
|
531
|
+
await appNoLoader.start();
|
|
532
|
+
// apply 在 loader 缺席时仅告警并提前返回
|
|
533
|
+
expect(appNoLoader.installer).toBeUndefined();
|
|
534
|
+
await appNoLoader.stop();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("浏览器 market/install 监听器执行安装并刷新服务", async () => {
|
|
538
|
+
nextExitCode = 0;
|
|
539
|
+
const listener = app.console.listeners["market/install"];
|
|
540
|
+
expect(listener).toBeDefined();
|
|
541
|
+
const code = (await listener?.callback.call(
|
|
542
|
+
{} as never,
|
|
543
|
+
{ "koishi-plugin-newpkg": "1.0.0" },
|
|
544
|
+
true,
|
|
545
|
+
)) as number;
|
|
546
|
+
expect(code).toBe(0);
|
|
547
|
+
}, 15000);
|
|
548
|
+
|
|
549
|
+
it("浏览器 market/registry 监听器批量查询包元数据", async () => {
|
|
550
|
+
const listener = app.console.listeners["market/registry"];
|
|
551
|
+
expect(listener).toBeDefined();
|
|
552
|
+
const meta = (await listener?.callback.call({} as never, [
|
|
553
|
+
"koishi-plugin-demo",
|
|
554
|
+
"koishi-plugin-missing",
|
|
555
|
+
])) as Record<string, unknown>;
|
|
556
|
+
expect(Object.keys(meta["koishi-plugin-demo"] ?? {})).toContain("2.0.0");
|
|
557
|
+
expect(meta["koishi-plugin-missing"]).toEqual({});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("搜索结果中不兼容的包被跳过(analyze onSkipped/ignored)", async () => {
|
|
561
|
+
const svc = app.get("console.services.market");
|
|
562
|
+
// ghost 有 registry 条目,但版本声明的 koishi peer 与 4.x 不相交
|
|
563
|
+
registryData["koishi-plugin-ghost"] = {
|
|
564
|
+
versions: {
|
|
565
|
+
"1.0.0": { version: "1.0.0", peerDependencies: { koishi: "^5.0.0" } },
|
|
566
|
+
},
|
|
567
|
+
time: { "1.0.0": "2024-01-01T00:00:00Z" },
|
|
568
|
+
};
|
|
569
|
+
searchResponse = {
|
|
570
|
+
objects: [
|
|
571
|
+
{
|
|
572
|
+
package: {
|
|
573
|
+
name: "koishi-plugin-ghost",
|
|
574
|
+
version: "1.0.0",
|
|
575
|
+
date: "2024-01-01T00:00:00Z",
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
],
|
|
579
|
+
total: 1,
|
|
580
|
+
};
|
|
581
|
+
await svc?.start(true);
|
|
582
|
+
// collect 对 analyze 为即发即忘,等待逐包分析完成
|
|
583
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
584
|
+
const payload = await svc?.get();
|
|
585
|
+
// 无兼容版本:对象标记 ignored,不进入数据缓存。
|
|
586
|
+
// progress 恒为 0:Scanner 以 defineProperty 定义 progress(不可写),
|
|
587
|
+
// analyze 收尾的自增在严格模式下抛错且被即发即忘吞掉(上游行为)。
|
|
588
|
+
expect(payload?.data).toEqual({});
|
|
589
|
+
expect(payload?.failed).toBe(0);
|
|
590
|
+
expect(payload?.total).toBe(1);
|
|
591
|
+
expect(payload?.progress).toBe(0);
|
|
592
|
+
delete registryData["koishi-plugin-ghost"];
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("搜索结果中被限流的包经重试后计入 failed(onFailure)", async () => {
|
|
596
|
+
const svc = app.get("console.services.market");
|
|
597
|
+
searchResponse = {
|
|
598
|
+
objects: [
|
|
599
|
+
{
|
|
600
|
+
package: {
|
|
601
|
+
name: "koishi-plugin-ratelimited",
|
|
602
|
+
version: "1.0.0",
|
|
603
|
+
date: "2024-01-01T00:00:00Z",
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
],
|
|
607
|
+
total: 1,
|
|
608
|
+
};
|
|
609
|
+
await svc?.start(true);
|
|
610
|
+
// 等待限流重试(Retry-After 10ms × 3 次)与即发即忘的 analyze。
|
|
611
|
+
// 注意不能经 get() 断言:super.start() 会清空 _task,get() 触发的
|
|
612
|
+
// 二次 collect 会把 failed 重置(即发即忘的 analyze 尚未完成)。
|
|
613
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
614
|
+
// 不可达/被限流的包名进入 failed 列表(上一个用例中 registry
|
|
615
|
+
// 条目已删除的 ghost 包经 404 路径同样落入此处)
|
|
616
|
+
const provider = svc as unknown as { failed: string[] };
|
|
617
|
+
expect(
|
|
618
|
+
provider.failed.some((name) => name.startsWith("koishi-plugin-")),
|
|
619
|
+
).toBe(true);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("plugin.upgrade 检出可升级项并输出确认提示", async () => {
|
|
623
|
+
// 本地放置旧版安装,使 resolved 有值且低于远端 latest
|
|
624
|
+
mkdirSync(join(tmp, "node_modules", "koishi-plugin-demo"), {
|
|
625
|
+
recursive: true,
|
|
626
|
+
});
|
|
627
|
+
writeFileSync(
|
|
628
|
+
join(tmp, "node_modules", "koishi-plugin-demo", "package.json"),
|
|
629
|
+
JSON.stringify({ name: "koishi-plugin-demo", version: "1.0.0" }),
|
|
630
|
+
);
|
|
631
|
+
app.installer.refresh();
|
|
632
|
+
|
|
633
|
+
nextExitCode = 0;
|
|
634
|
+
// 发出升级指令(异步等待确认),再以 Y 回复确认。
|
|
635
|
+
// 注:mock 环境下指令 ctx 对 loader 服务的可见性受 cordis
|
|
636
|
+
// isolate 语义限制(见仓库测试任务记录),确认后的安装段
|
|
637
|
+
// 由 market/install 监听器用例覆盖。
|
|
638
|
+
const question = client.receive("plugin.upgrade demo");
|
|
639
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
640
|
+
await client.receive("Y");
|
|
641
|
+
const replies = await question;
|
|
642
|
+
const output = replies.join("\n");
|
|
643
|
+
expect(output).toContain("koishi-plugin-demo");
|
|
644
|
+
expect(output).toContain("1.0.0 -> 2.0.0");
|
|
645
|
+
}, 20000);
|
|
646
|
+
|
|
647
|
+
it("plugin.upgrade 对本地畸形版本静默跳过(非法 semver catch)", async () => {
|
|
648
|
+
// registry 侧提供合法 latest;本地安装产物的 version 是畸形串,
|
|
649
|
+
// request(清单声明)合法故不标记 invalid,gt(latest, resolved)
|
|
650
|
+
// 解析失败进入 catch 分支:该包被过滤,视为无可升级项
|
|
651
|
+
registryData["koishi-plugin-weird"] = {
|
|
652
|
+
versions: {
|
|
653
|
+
"2.0.0": { version: "2.0.0", peerDependencies: { koishi: "^4.17.0" } },
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
mkdirSync(join(tmp, "node_modules", "koishi-plugin-weird"), {
|
|
657
|
+
recursive: true,
|
|
658
|
+
});
|
|
659
|
+
writeFileSync(
|
|
660
|
+
join(tmp, "node_modules", "koishi-plugin-weird", "package.json"),
|
|
661
|
+
JSON.stringify({ name: "koishi-plugin-weird", version: "not.a.version" }),
|
|
662
|
+
);
|
|
663
|
+
nextExitCode = 0;
|
|
664
|
+
// 经 install 注入清单声明(顺带刷新 Installer 的 manifest 快照)
|
|
665
|
+
const code = await app.installer.install({
|
|
666
|
+
"koishi-plugin-weird": "1.0.0",
|
|
667
|
+
});
|
|
668
|
+
expect(code).toBe(0);
|
|
669
|
+
const deps = await app.installer.getDeps();
|
|
670
|
+
expect(deps["koishi-plugin-weird"]?.resolved).toBe("not.a.version");
|
|
671
|
+
const replies = await client.receive("plugin.upgrade weird");
|
|
672
|
+
expect(replies[0]).toContain("所有插件已是最新版本。");
|
|
673
|
+
}, 20000);
|
|
674
|
+
});
|
package/src/node/installer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
5
5
|
import type {} from "@koishi-ce/console";
|
|
6
6
|
import {
|
|
7
7
|
type Context,
|
|
@@ -30,6 +30,23 @@ import { compare, satisfies, valid } from "semver";
|
|
|
30
30
|
|
|
31
31
|
const logger = new Logger("market");
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* 判断依赖声明是否受护栏保护、不可被安装清单覆盖或删除。两类:
|
|
35
|
+
* - `workspace:` 声明是本仓库(monorepo)对上游裸名的归属(如 koishi
|
|
36
|
+
* 裸名 shim,见 packages/node/koishi);
|
|
37
|
+
* - `npm:@koishi-ce/...` alias 是下游脚手架生成项目对上游名的归属
|
|
38
|
+
* (如 "koishi": "npm:@koishi-ce/koishi-shim@^4.18.11",见
|
|
39
|
+
* packages/node/koishi-shim)。
|
|
40
|
+
* 两者被覆盖或删除都会让 peer 解析失去归属,重新拉下 npm 官方包形成
|
|
41
|
+
* 第二份框架副本。
|
|
42
|
+
*/
|
|
43
|
+
function isGuardedRequest(request: string | undefined): boolean {
|
|
44
|
+
return (
|
|
45
|
+
request?.startsWith("workspace:") === true ||
|
|
46
|
+
request?.startsWith("npm:@koishi-ce") === true
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
/** 从单个 .npmrc 文件提取 registry 配置项;文件不存在或读取出错一律视为未配置 */
|
|
34
51
|
function readNpmrcRegistry(file: string): string | undefined {
|
|
35
52
|
try {
|
|
@@ -121,6 +138,37 @@ export function loadManifest(name: string) {
|
|
|
121
138
|
return meta;
|
|
122
139
|
}
|
|
123
140
|
|
|
141
|
+
/** 缓存键归一(win32 大小写不敏感),对齐 require.cache 键与探测路径的比对 */
|
|
142
|
+
function normalizeCacheKey(path: string): string {
|
|
143
|
+
return process.platform === "win32" ? path.toLowerCase() : path;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 判断某依赖包是否有模块驻留在 require.cache(旧版本仍在内存)。
|
|
148
|
+
*
|
|
149
|
+
* 原上游实现用 require.resolve(name)(FIXME koishijs/webui#273 的报错
|
|
150
|
+
* 源):安装流程在包落盘前的探测已把该裸名记入 Bun 的进程内解析负缓
|
|
151
|
+
* 存,装完后同进程内必然抛 ResolveMessage。改为经 resolvePackageJson
|
|
152
|
+
* (fs 探测兜底)取包目录后扫 require.cache 前缀;任何环节失败都保守
|
|
153
|
+
* 返回 true——多一次重载只是进程重启,漏判才会让旧版本代码继续驻留。
|
|
154
|
+
*/
|
|
155
|
+
export function isResidentInCache(name: string): boolean {
|
|
156
|
+
try {
|
|
157
|
+
const dir = dirname(resolvePackageJson(name));
|
|
158
|
+
const prefixes = [normalizeCacheKey(dir + sep)];
|
|
159
|
+
// 符号链接布局下缓存键可能是 realpath 形态,两种前缀都查
|
|
160
|
+
try {
|
|
161
|
+
const real = realpathSync(dir);
|
|
162
|
+
if (real !== dir) prefixes.push(normalizeCacheKey(real + sep));
|
|
163
|
+
} catch {}
|
|
164
|
+
return Object.keys(require.cache).some((key) =>
|
|
165
|
+
prefixes.some((prefix) => normalizeCacheKey(key).startsWith(prefix)),
|
|
166
|
+
);
|
|
167
|
+
} catch {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
function getVersions(versions: RemotePackage[]) {
|
|
125
173
|
return Object.fromEntries(
|
|
126
174
|
versions
|
|
@@ -332,14 +380,11 @@ class Installer extends Service {
|
|
|
332
380
|
this.manifest.dependencies ||= {};
|
|
333
381
|
for (const key in deps) {
|
|
334
382
|
if (deps[key]) {
|
|
335
|
-
|
|
336
|
-
// shim,见 packages/node/koishi),不可被安装清单覆盖成
|
|
337
|
-
// npm 版本,否则会拉下官方包形成第二份框架副本
|
|
338
|
-
if (this.manifest.dependencies[key]?.startsWith("workspace:")) {
|
|
383
|
+
if (isGuardedRequest(this.manifest.dependencies[key])) {
|
|
339
384
|
continue;
|
|
340
385
|
}
|
|
341
386
|
this.manifest.dependencies[key] = deps[key];
|
|
342
|
-
} else if (!this.manifest.dependencies[key]
|
|
387
|
+
} else if (!isGuardedRequest(this.manifest.dependencies[key])) {
|
|
343
388
|
delete this.manifest.dependencies[key];
|
|
344
389
|
}
|
|
345
390
|
}
|
|
@@ -404,14 +449,9 @@ class Installer extends Service {
|
|
|
404
449
|
const newDep = newDeps[name];
|
|
405
450
|
if (!local || !newDep || local.workspace) continue;
|
|
406
451
|
if (newDep.resolved === local.resolved) continue;
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
// FIXME https://github.com/koishijs/webui/issues/273
|
|
411
|
-
// I have no idea why this happens and how to fix it.
|
|
412
|
-
logger.error(error);
|
|
413
|
-
}
|
|
414
|
-
this.ctx.loader.fullReload();
|
|
452
|
+
// 版本变化且旧版本仍驻留内存时才需要整进程重载(判定不走
|
|
453
|
+
// 解析 API,规避 Bun 负缓存,见 isResidentInCache)
|
|
454
|
+
if (isResidentInCache(name)) this.ctx.loader.fullReload();
|
|
415
455
|
}
|
|
416
456
|
this.refreshData();
|
|
417
457
|
|