@koishi-ce/plugin-market 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,11 +33,14 @@ import {
33
33
  type RemotePackage,
34
34
  } from "@koishi-ce/registry";
35
35
  import { satisfies, valid } from "semver";
36
+ import type { Dependency } from "../dependencies/types.ts";
36
37
  import { runBun } from "./exec.ts";
37
38
  import { findMissingDeps } from "./integrity.ts";
38
39
  import {
40
+ backupManifest,
39
41
  type LocalPackage,
40
42
  loadManifest,
43
+ restoreManifest,
41
44
  writeManifest,
42
45
  } from "./manifest.ts";
43
46
  import {
@@ -52,24 +55,7 @@ import {
52
55
 
53
56
  const logger = new Logger("market");
54
57
 
55
- export interface Dependency {
56
- /**
57
- * requested semver range
58
- * @example `^1.2.3` -> `1.2.3`
59
- */
60
- request: string;
61
- /**
62
- * installed package version
63
- * @example `1.2.5`
64
- */
65
- resolved?: string | undefined;
66
- /** whether it is a workspace package */
67
- workspace?: boolean | undefined;
68
- /** valid (unsupported) syntax */
69
- invalid?: boolean | undefined;
70
- /** latest version */
71
- latest?: string | undefined;
72
- }
58
+ export type { Dependency };
73
59
 
74
60
  class Installer extends Service {
75
61
  declare http: HTTP;
@@ -78,8 +64,12 @@ class Installer extends Service {
78
64
  public tempCache: Dict<VersionMap> = {};
79
65
 
80
66
  private pkgTasks: Dict<Promise<VersionMap>> = {};
67
+ /** 各包最近一次拉取失败的归类(404 与否);依赖页的 error 徽标消费 */
68
+ public pkgErrors: Dict<{ notFound: boolean }> = {};
81
69
  private manifest: LocalPackage;
82
- declare private depTask: Promise<Dict<Dependency>>;
70
+ declare private depTask:
71
+ | Promise<Dict<Dependency>>
72
+ | undefined;
83
73
  private flushData: () => void;
84
74
 
85
75
  override config: Installer.Config;
@@ -149,11 +139,23 @@ class Installer extends Service {
149
139
  private async _getPackage(name: string) {
150
140
  try {
151
141
  const versions = await fetchVersions(this.http, name);
142
+ delete this.pkgErrors[name];
152
143
  this.fullCache[name] = this.tempCache[name] =
153
144
  versions;
154
145
  this.flushData();
155
146
  return versions;
156
147
  } catch (error) {
148
+ // 404 与否决定负缓存策略:404 是 registry 的确定性答复,
149
+ // resolved 空表照常驻留 pkgTasks(会话内不再重试);其余
150
+ // (网络抖动、超时等瞬态)清除任务让下次调用重试
151
+ const notFound =
152
+ this.http.isError(error) &&
153
+ error.response?.status === 404;
154
+ this.pkgErrors[name] = { notFound };
155
+ if (!notFound) {
156
+ // 本任务自身 catch 后恒 resolve,直接移除即可让下次调用重试
157
+ delete this.pkgTasks[name];
158
+ }
157
159
  logger.warn(error);
158
160
  return {};
159
161
  }
@@ -217,6 +219,7 @@ class Installer extends Service {
217
219
 
218
220
  refresh(refresh = false) {
219
221
  this.pkgTasks = {};
222
+ this.pkgErrors = {};
220
223
  this.fullCache = {};
221
224
  this.tempCache = {};
222
225
  this.depTask = this._getDeps();
@@ -224,6 +227,12 @@ class Installer extends Service {
224
227
  this.refreshData();
225
228
  }
226
229
 
230
+ /** 轻量重建:重读宿主清单并重置依赖汇总任务(registry 缓存不动)。 */
231
+ reload() {
232
+ this.manifest = loadManifest(this.cwd);
233
+ this.depTask = undefined;
234
+ }
235
+
227
236
  async exec(args: string[]) {
228
237
  // Bun-first:CE 生态只存在 bun 这一种包管理器,直接驱动 bun 执行
229
238
  // 安装(上游经 which-pm-runs 探测 npm/yarn/bun,此处固定为 bun)
@@ -260,6 +269,8 @@ class Installer extends Service {
260
269
  forced?: boolean,
261
270
  ) {
262
271
  const localDeps = this._getLocalDeps(deps);
272
+ // 备份清单原文:主安装失败时整体还原,防止半改状态残留在磁盘
273
+ const backup = await backupManifest(this.cwd);
263
274
  await this.override(deps);
264
275
 
265
276
  let shouldInstall = forced === true;
@@ -281,7 +292,14 @@ class Installer extends Service {
281
292
 
282
293
  if (shouldInstall) {
283
294
  const code = await this._install();
284
- if (code) return code;
295
+ if (code) {
296
+ // 安装失败:还原清单原文并轻量重建内存态
297
+ // (registry 缓存与依赖服务快照不受影响,后者由
298
+ // 安装监听器统一 refresh 触发重建)
299
+ await restoreManifest(this.cwd, backup);
300
+ this.reload();
301
+ return code;
302
+ }
285
303
  // isolated 布局的增量安装可能漏建新包目录的依赖链接(机理
286
304
  // 见 integrity.ts 模块注释):装完即校验,缺失时补跑一次
287
305
  // 安装——bun 按磁盘实际状态重建链接,秒级自愈,无需人工
@@ -0,0 +1,282 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2019-present Shigma and Koishijs contributors.
3
+ // Copyright (c) 2026-present Koishi-CE contributors.
4
+
5
+ import {
6
+ afterAll,
7
+ beforeAll,
8
+ describe,
9
+ expect,
10
+ it,
11
+ } from "bun:test";
12
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import {
15
+ app,
16
+ itQuiet,
17
+ setNextExitCode,
18
+ setNextSpawnError,
19
+ spawnCalls,
20
+ startApp,
21
+ stopApp,
22
+ tmp,
23
+ } from "../__tests__/helpers.ts";
24
+
25
+ const { isResidentInCache } = await import(
26
+ "@koishi-ce/registry"
27
+ );
28
+
29
+ beforeAll(startApp);
30
+ afterAll(stopApp);
31
+
32
+ describe("Installer 安装链路", () => {
33
+ itQuiet(
34
+ "install 尊重护栏依赖并执行子进程安装",
35
+ async () => {
36
+ setNextExitCode(0);
37
+ spawnCalls.length = 0;
38
+ const code = await app.installer.install({
39
+ koishi: "2.0.0",
40
+ "market-alias": null,
41
+ "koishi-plugin-demo": "^1.0.0",
42
+ });
43
+ expect(code).toBe(0);
44
+ // 触发了包管理器安装(bun install --registry …)
45
+ expect(spawnCalls.length).toBe(1);
46
+ expect(spawnCalls[0]?.[0]).toBe("install");
47
+ // 重新读取临时 package.json:护栏项保持原样,新依赖加入
48
+ const manifest = JSON.parse(
49
+ await Bun.file(join(tmp, "package.json")).text(),
50
+ ) as { dependencies: Record<string, string> };
51
+ expect(manifest.dependencies["koishi"]).toBe(
52
+ "workspace:*",
53
+ );
54
+ expect(manifest.dependencies["market-alias"]).toBe(
55
+ "npm:@koishi-ce/anything@^1.0.0",
56
+ );
57
+ expect(
58
+ manifest.dependencies["koishi-plugin-demo"],
59
+ ).toBe("^1.0.0");
60
+ },
61
+ 15000,
62
+ );
63
+
64
+ itQuiet(
65
+ "install 强制时无视本地满足也要装",
66
+ async () => {
67
+ setNextExitCode(0);
68
+ spawnCalls.length = 0;
69
+ const code = await app.installer.install(
70
+ { "koishi-plugin-demo": "^1.0.0" },
71
+ true,
72
+ );
73
+ expect(code).toBe(0);
74
+ expect(spawnCalls.length).toBe(1);
75
+ },
76
+ 15000,
77
+ );
78
+
79
+ itQuiet(
80
+ "子进程非零退出码向上传递",
81
+ async () => {
82
+ setNextExitCode(1);
83
+ spawnCalls.length = 0;
84
+ const code = await app.installer.install(
85
+ { "koishi-plugin-demo": "^2.0.0" },
86
+ true,
87
+ );
88
+ expect(code).toBe(1);
89
+ },
90
+ 15000,
91
+ );
92
+
93
+ itQuiet(
94
+ "安装失败时还原 package.json 备份(失败回滚链)",
95
+ async () => {
96
+ setNextExitCode(1);
97
+ const before = await Bun.file(
98
+ join(tmp, "package.json"),
99
+ ).text();
100
+ const code = await app.installer.install(
101
+ { "koishi-plugin-demo": "^2.0.0" },
102
+ true,
103
+ );
104
+ expect(code).toBe(1);
105
+ // 失败后清单原文原样还原(依赖改动不留半改状态)
106
+ const after = await Bun.file(
107
+ join(tmp, "package.json"),
108
+ ).text();
109
+ expect(after).toBe(before);
110
+ // 内存清单同步重建:还原后的清单仍是 1.0.0 请求
111
+ const deps = await app.installer.getDeps();
112
+ expect(deps["koishi-plugin-demo"]?.request).toBe(
113
+ "1.0.0",
114
+ );
115
+ setNextExitCode(0);
116
+ },
117
+ 15000,
118
+ );
119
+
120
+ itQuiet("子进程 spawn 失败返回 -1", async () => {
121
+ setNextSpawnError(true);
122
+ setNextExitCode(0);
123
+ const code = await app.installer.exec(["install"]);
124
+ setNextSpawnError(false);
125
+ expect(code).toBe(-1);
126
+ });
127
+
128
+ /** 在临时 node_modules 落盘一个带 dependencies 声明的校验用包 */
129
+ function placeLinkcheckPkg(
130
+ dependencies: Record<string, string>,
131
+ ) {
132
+ const name = "koishi-plugin-linkcheck";
133
+ const pkgDir = join(tmp, "node_modules", name);
134
+ mkdirSync(pkgDir, { recursive: true });
135
+ writeFileSync(
136
+ join(pkgDir, "package.json"),
137
+ JSON.stringify({
138
+ name,
139
+ version: "1.0.0",
140
+ dependencies,
141
+ }),
142
+ );
143
+ return pkgDir;
144
+ }
145
+
146
+ itQuiet(
147
+ "装后依赖链接完整时不触发补装",
148
+ async () => {
149
+ setNextExitCode(0);
150
+ spawnCalls.length = 0;
151
+ const pkgDir = placeLinkcheckPkg({
152
+ "linkcheck-dep": "^1.0.0",
153
+ });
154
+ const depDir = join(
155
+ tmp,
156
+ "node_modules",
157
+ "linkcheck-dep",
158
+ );
159
+ mkdirSync(depDir, { recursive: true });
160
+ writeFileSync(
161
+ join(depDir, "package.json"),
162
+ JSON.stringify({
163
+ name: "linkcheck-dep",
164
+ version: "1.0.0",
165
+ }),
166
+ );
167
+ try {
168
+ const code = await app.installer.install(
169
+ { "koishi-plugin-linkcheck": "^1.0.0" },
170
+ true,
171
+ );
172
+ expect(code).toBe(0);
173
+ expect(spawnCalls.length).toBe(1);
174
+ } finally {
175
+ rmSync(pkgDir, { recursive: true, force: true });
176
+ rmSync(depDir, { recursive: true, force: true });
177
+ }
178
+ },
179
+ 15000,
180
+ );
181
+
182
+ itQuiet(
183
+ "包级依赖链接缺失时自动补装一次且不无限重试",
184
+ async () => {
185
+ setNextExitCode(0);
186
+ spawnCalls.length = 0;
187
+ // isolated 增量安装缺陷形态:包已落盘但声明的依赖未链接。
188
+ // 首装后校验命中补装一次;mock 安装不会真落盘依赖,二次校验
189
+ // 仍缺,只告警不再补装——总计恰好两次子进程调用
190
+ const pkgDir = placeLinkcheckPkg({
191
+ "linkcheck-missing": "^1.0.0",
192
+ });
193
+ try {
194
+ const code = await app.installer.install(
195
+ { "koishi-plugin-linkcheck": "^1.0.0" },
196
+ true,
197
+ );
198
+ expect(code).toBe(0);
199
+ expect(spawnCalls.length).toBe(2);
200
+ } finally {
201
+ rmSync(pkgDir, { recursive: true, force: true });
202
+ }
203
+ },
204
+ 15000,
205
+ );
206
+
207
+ itQuiet(
208
+ "alias 声明的依赖跳过链接校验",
209
+ async () => {
210
+ setNextExitCode(0);
211
+ spawnCalls.length = 0;
212
+ // alias 落盘名与键名无直接映射,不入校验,缺失也不触发补装
213
+ const pkgDir = placeLinkcheckPkg({
214
+ "linkcheck-alias": "npm:whatever@^1.0.0",
215
+ });
216
+ try {
217
+ const code = await app.installer.install(
218
+ { "koishi-plugin-linkcheck": "^1.0.0" },
219
+ true,
220
+ );
221
+ expect(code).toBe(0);
222
+ expect(spawnCalls.length).toBe(1);
223
+ } finally {
224
+ rmSync(pkgDir, { recursive: true, force: true });
225
+ }
226
+ },
227
+ 15000,
228
+ );
229
+
230
+ itQuiet(
231
+ "exec 收集子进程 stdout / stderr 输出行",
232
+ async () => {
233
+ setNextExitCode(0);
234
+ setNextSpawnError(false);
235
+ const code = await app.installer.exec(["install"]);
236
+ expect(code).toBe(0);
237
+ },
238
+ );
239
+ });
240
+
241
+ describe("isResidentInCache(装后重载判定)", () => {
242
+ /** 在临时 node_modules 放一个已安装包,返回其目录 */
243
+ function placePkg(name: string) {
244
+ const pkgDir = join(tmp, "node_modules", name);
245
+ mkdirSync(pkgDir, { recursive: true });
246
+ writeFileSync(
247
+ join(pkgDir, "package.json"),
248
+ JSON.stringify({
249
+ name,
250
+ version: "1.0.0",
251
+ main: "index.js",
252
+ }),
253
+ );
254
+ writeFileSync(
255
+ join(pkgDir, "index.js"),
256
+ "module.exports = {}",
257
+ );
258
+ return pkgDir;
259
+ }
260
+
261
+ it("包目录下有模块驻留 require.cache 时返回 true", () => {
262
+ const pkgDir = placePkg("koishi-plugin-resident-check");
263
+ // 入口经 require 进入 require.cache,模拟旧版本驻留内存
264
+ require(join(pkgDir, "index.js"));
265
+ expect(
266
+ isResidentInCache("koishi-plugin-resident-check"),
267
+ ).toBe(true);
268
+ });
269
+
270
+ it("已安装但无模块驻留内存时返回 false", () => {
271
+ placePkg("koishi-plugin-idle-check");
272
+ expect(
273
+ isResidentInCache("koishi-plugin-idle-check"),
274
+ ).toBe(false);
275
+ });
276
+
277
+ it("包不存在时保守返回 true(宁可多重载不漏判)", () => {
278
+ expect(
279
+ isResidentInCache("koishi-plugin-absent-check"),
280
+ ).toBe(true);
281
+ });
282
+ });
@@ -62,6 +62,21 @@ export function loadManifest(name: string): LocalPackage {
62
62
  return meta;
63
63
  }
64
64
 
65
+ /** 读取项目根 package.json 原文(安装前的备份快照)。 */
66
+ export async function backupManifest(
67
+ cwd: string,
68
+ ): Promise<string> {
69
+ return Bun.file(resolve(cwd, "package.json")).text();
70
+ }
71
+
72
+ /** 把备份原文原样写回项目根 package.json(安装失败的回滚路径)。 */
73
+ export async function restoreManifest(
74
+ cwd: string,
75
+ backup: string,
76
+ ): Promise<void> {
77
+ await Bun.write(resolve(cwd, "package.json"), backup);
78
+ }
79
+
65
80
  /**
66
81
  * 把依赖增删写回项目根 package.json,返回写回后的清单。
67
82
  *