@koishi-ce/plugin-market 1.0.2 → 1.0.4

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