dsh-config-manager 0.1.39 → 0.1.41

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.
Files changed (50) hide show
  1. package/lib/client.d.ts +90 -8
  2. package/lib/client.js +1904 -1086
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/cache-cleaner.d.ts +39 -0
  5. package/lib/core/cache-cleaner.js +164 -0
  6. package/lib/core/cache-cleaner.js.map +1 -0
  7. package/lib/index.js +172 -3
  8. package/lib/index.js.map +1 -1
  9. package/lib/market/github-repos.d.ts +19 -8
  10. package/lib/market/github-repos.js +62 -33
  11. package/lib/market/github-repos.js.map +1 -1
  12. package/lib/market/my-repo.d.ts +111 -3
  13. package/lib/market/my-repo.js +309 -26
  14. package/lib/market/my-repo.js.map +1 -1
  15. package/lib/market/prepare.js +3 -2
  16. package/lib/market/prepare.js.map +1 -1
  17. package/lib/market/types.d.ts +2 -0
  18. package/lib/security/secret-scanner.d.ts +18 -3
  19. package/lib/security/secret-scanner.js +144 -20
  20. package/lib/security/secret-scanner.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/client/common/ConfirmDialog.tsx +135 -0
  23. package/src/client/config-manager.module.css +101 -0
  24. package/src/client/market/MarketPanel.tsx +138 -45
  25. package/src/client/market/MyConfigsView.tsx +515 -132
  26. package/src/client/market/disclaimer.test.ts +57 -0
  27. package/src/client/market/disclaimer.ts +43 -0
  28. package/src/client/market/market-api.test.ts +3 -0
  29. package/src/client/market/market-locales.ts +37 -13
  30. package/src/client/market/my-configs-api.ts +20 -2
  31. package/src/client/market/my-configs-view.test.ts +137 -2
  32. package/src/client/market/my-configs-view.ts +174 -2
  33. package/src/client/run-store.test.ts +40 -2
  34. package/src/client/run-store.ts +70 -10
  35. package/src/client/sync/SyncConfirmView.tsx +28 -22
  36. package/src/client/sync/SyncSettingsView.tsx +175 -65
  37. package/src/client/sync/sync-api.ts +19 -0
  38. package/src/client/sync/sync-locales.ts +14 -2
  39. package/src/core/cache-cleaner.test.ts +333 -0
  40. package/src/core/cache-cleaner.ts +196 -0
  41. package/src/index.ts +165 -5
  42. package/src/market/github-repos.test.ts +99 -8
  43. package/src/market/github-repos.ts +67 -35
  44. package/src/market/my-repo.test.ts +218 -27
  45. package/src/market/my-repo.ts +403 -30
  46. package/src/market/prepare.test.ts +71 -1
  47. package/src/market/prepare.ts +3 -2
  48. package/src/market/types.ts +2 -0
  49. package/src/security/secret-scanner.ts +139 -22
  50. package/src/security/security.test.ts +111 -2
@@ -17,7 +17,7 @@ import assert from 'node:assert/strict';
17
17
  import { GitHubApiError, MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO } from './github-repos.ts';
18
18
  import type { GitHubForkInfo, GitHubPullRequestInfo, GitHubPullRequestParams, GitHubRepoInfo, GitHubUserInfo } from './github-repos.ts';
19
19
  import type { GitHubRestLike, MyItemEntry, MyRepoForm } from './my-repo.ts';
20
- import { MyRepoService, prBranchFor, slugifyItemId, uniqueItemId, bumpVersion } from './my-repo.ts';
20
+ import { MyRepoError, MyRepoService, delistBranchFor, prBranchFor, slugifyItemId, uniqueItemId, bumpVersion } from './my-repo.ts';
21
21
  import { MarketPrepareError } from './prepare.ts';
22
22
  import type { MarketPrepareInput, MarketPrepareResult } from './prepare.ts';
23
23
  import type { GitFileWriter, GitFileWriteCall, GitFileWriterResult } from './git-file-writer.ts';
@@ -54,6 +54,7 @@ interface RestOverrides {
54
54
  readFile?: (owner: string, repo: string, path: string, ref?: string) => Promise<string | null>;
55
55
  openPullRequest?: (params: GitHubPullRequestParams) => Promise<GitHubPullRequestInfo>;
56
56
  listOpenPullRequests?: (owner: string, repo: string, head?: string) => Promise<GitHubPullRequestInfo[]>;
57
+ closePullRequest?: (owner: string, repo: string, number: number) => Promise<GitHubPullRequestInfo>;
57
58
  }
58
59
 
59
60
  interface Harness {
@@ -90,10 +91,22 @@ function makePrepare() {
90
91
  return { prepare, prepareInputs };
91
92
  }
92
93
 
93
- function makeHarness(overrides: RestOverrides = {}): Harness {
94
+ function makeHarness(overrides: RestOverrides = {}, opts: { userIndex?: string; officialIndex?: string } = {}): Harness {
94
95
  const gitCalls: GitFileWriteCall[] = [];
96
+ /** 动态用户仓库 index(初始=opts.userIndex ?? 空;gitWriter 写入后更新,readFile 读取最新——后台收录要读回条目) */
97
+ let liveUserIndex = opts.userIndex ?? USER_INDEX_EMPTY;
98
+ /** 官方市场 index(opts.officialIndex 可覆盖;后台收录读它) */
99
+ const liveOfficialIndex = opts.officialIndex ?? OFFICIAL_INDEX;
95
100
  const gitWriter: GitFileWriter = {
96
- writeFiles: async (call): Promise<GitFileWriterResult> => { gitCalls.push(call); return { pushed: true, branch: 'default' }; },
101
+ writeFiles: async (call): Promise<GitFileWriterResult> => {
102
+ gitCalls.push(call);
103
+ // 写用户仓库时同步内存 index(后台收录/下架经 readFile 读最新)
104
+ if (call.repoUrl === USER_REPO_CLONE) {
105
+ const idx = call.entries.find((e) => e.path === 'index.json');
106
+ if (idx !== undefined) liveUserIndex = String(idx.content);
107
+ }
108
+ return { pushed: true, branch: 'default' };
109
+ },
97
110
  };
98
111
  const openPrCalls: GitHubPullRequestParams[] = [];
99
112
  const restCalls: string[] = [];
@@ -117,8 +130,8 @@ function makeHarness(overrides: RestOverrides = {}): Harness {
117
130
  },
118
131
  readFile: async (owner, repo, p) => {
119
132
  restCalls.push(`readFile:${owner}/${repo}/${p}`);
120
- if (owner === LOGIN) return USER_INDEX_EMPTY; // 用户仓库 index
121
- return OFFICIAL_INDEX; // 官方市场 index
133
+ if (owner === LOGIN) return liveUserIndex; // 用户仓库 index(动态:反映最近一次写入)
134
+ return liveOfficialIndex; // 官方市场 index
122
135
  },
123
136
  openPullRequest: async (params) => {
124
137
  restCalls.push('openPullRequest');
@@ -133,6 +146,7 @@ function makeHarness(overrides: RestOverrides = {}): Harness {
133
146
  };
134
147
  },
135
148
  listOpenPullRequests: async () => { restCalls.push('listOpenPullRequests'); return []; },
149
+ closePullRequest: async () => { restCalls.push('closePullRequest'); throw new Error('closePullRequest 默认不调用'); },
136
150
  ...overrides,
137
151
  };
138
152
  const { prepare, prepareInputs } = makePrepare();
@@ -159,18 +173,20 @@ function indexJsonOf(call: GitFileWriteCall): { schemaVersion: number; items: Ar
159
173
 
160
174
  /* ---------------------------------------------------------------- 首次上传全流程 */
161
175
 
162
- test('my-repo: 首次上传全流程 → prepare 元数据自动生成 + 建仓 + 写用户仓库 + fork + 建 PR', async () => {
176
+ test('my-repo: 首次上传全流程 → prepare 元数据自动生成 + 建仓 + 写用户仓库(同步)+ 后台收录(fork + 建 PR)', async () => {
163
177
  const h = makeHarness();
164
178
  const result = await h.service.upload({ zipBytes: ZIP_BYTES, form: form() });
165
179
 
180
+ // 同步段:推用户仓库立即返回(listing='pending',prUrl 尚不可用)
166
181
  assert.equal(result.ok, true);
167
182
  assert.equal(result.itemId, 'my-config');
168
183
  assert.equal(result.version, '1.0.0');
169
184
  assert.equal(result.sha256, 'sha256-abc');
170
185
  assert.deepEqual(result.sections, ['settings']);
171
186
  assert.equal(result.repoUrl, USER_REPO_URL);
172
- assert.equal(result.prNumber, 42);
173
- assert.equal(result.prUrl, `${OFFICIAL_URL}/pull/42`);
187
+ assert.equal(result.listing, 'pending', '上传立即返回 pending(收录后台进行)');
188
+ assert.equal(result.prNumber, null);
189
+ assert.equal(result.prUrl, null);
174
190
  assert.ok(result.warnings.length >= 1);
175
191
 
176
192
  // prepare 输入:author=login、version=1.0.0、repoUrl=用户仓库
@@ -182,14 +198,10 @@ test('my-repo: 首次上传全流程 → prepare 元数据自动生成 + 建仓
182
198
  assert.equal(pi.repoUrl, USER_REPO_URL);
183
199
  assert.equal(pi.now, '2026-08-20T00:00:00.000Z');
184
200
 
185
- // 仓库操作序:getUser → 读用户 index → repoExists → createPublicRepo → 写用户仓库 → ensureFork → 读官方 index → 写 fork 分支 → listOpenPullRequests → openPullRequest
201
+ // 同步段仓库操作:getUser → 读用户 index → repoExists → createPublicRepo → 写用户仓库
186
202
  assert.ok(h.restCalls.includes('createPublicRepo'), '首次上传必须自动创建公开仓库');
187
- assert.ok(h.restCalls.includes('ensureFork'), '必须 ensureFork 官方仓库');
188
- assert.ok(h.restCalls.some((c) => c.startsWith('readFile:xiajiajun516/dsh-config-market/index.json')), '必须读官方最新 index');
189
-
190
- // gitWriter 两次调用:用户仓库 + fork 分支
191
- assert.equal(h.gitCalls.length, 2);
192
- const [userCall, forkCall] = h.gitCalls as [GitFileWriteCall, GitFileWriteCall];
203
+ assert.equal(h.gitCalls.length, 1, '同步段只写用户仓库(收录在后台)');
204
+ const userCall = h.gitCalls[0]!;
193
205
  assert.equal(userCall.repoUrl, USER_REPO_CLONE);
194
206
  assert.deepEqual(userCall.entries.map((e) => e.path), [
195
207
  'items/my-config/manifest.json', 'items/my-config/config.zip', 'index.json',
@@ -203,7 +215,17 @@ test('my-repo: 首次上传全流程 → prepare 元数据自动生成 + 建仓
203
215
  assert.equal(userIndex.items[0]!.version, '1.0.0');
204
216
  assert.equal(userIndex.items[0]!.author, LOGIN);
205
217
 
206
- // fork 分支:基于官方最新 main + force push + 仅 index.json + repo 自托管引用
218
+ // 后台收录(waitForListing 等终态)→ fork + 写官方 index 分支 + 建 PR
219
+ const listing = await h.service.waitForListing('my-config');
220
+ assert.equal(listing.listing, 'done');
221
+ assert.equal(listing.prNumber, 42);
222
+ assert.equal(listing.prUrl, `${OFFICIAL_URL}/pull/42`);
223
+ assert.ok(h.restCalls.includes('ensureFork'), '收录阶段必须 ensureFork 官方仓库');
224
+ assert.ok(h.restCalls.some((c) => c.startsWith('readFile:xiajiajun516/dsh-config-market/index.json')), '必须读官方最新 index');
225
+
226
+ // gitWriter 两次调用:用户仓库 + fork 分支
227
+ assert.equal(h.gitCalls.length, 2);
228
+ const forkCall = h.gitCalls[1]!;
207
229
  assert.equal(forkCall.repoUrl, FORK_CLONE);
208
230
  assert.equal(forkCall.branch, 'dsh-market-sync/my-config');
209
231
  assert.equal(forkCall.baseRef, 'upstream/main');
@@ -226,9 +248,7 @@ test('my-repo: 首次上传全流程 → prepare 元数据自动生成 + 建仓
226
248
 
227
249
  test('my-repo: 更新 → id 不变、version 纯自动 +1、updatedAt 刷新', async () => {
228
250
  const existing = USER_INDEX_WITH([{ id: 'my-config', name: 'My Config', version: '1.0.1', author: LOGIN }]);
229
- const h = makeHarness({
230
- readFile: async (owner, repo, p) => (owner === LOGIN ? existing : OFFICIAL_INDEX),
231
- });
251
+ const h = makeHarness({}, { userIndex: existing });
232
252
  const result = await h.service.update({ zipBytes: ZIP_BYTES, form: form() });
233
253
 
234
254
  assert.equal(result.ok, true);
@@ -243,11 +263,44 @@ test('my-repo: 更新 → id 不变、version 纯自动 +1、updatedAt 刷新',
243
263
  const userIndex = indexJsonOf(h.gitCalls[0]!);
244
264
  const entry = userIndex.items.find((it) => it.id === 'my-config')!;
245
265
  assert.equal(entry.version, '1.0.2');
246
- // PR 分支同样同步新版本
266
+ // 后台收录:fork 分支同步新版本
267
+ await h.service.waitForListing('my-config');
247
268
  const forkIndex = indexJsonOf(h.gitCalls[1]!);
248
269
  assert.equal(forkIndex.items.find((it) => it.id === 'my-config')!.version, '1.0.2');
249
270
  });
250
271
 
272
+ test('my-repo: update 显式 form.id → 按 id 定位(name→slug 失配时不再误为新建)', async () => {
273
+ // 模拟中文名条目:id=config-abc12345(name slug 折叠为空 → hash 兜底),更新时 name 可随意改
274
+ const existing = USER_INDEX_WITH([
275
+ { id: 'config-abc12345', name: '我的配置', version: '2.1.0', author: LOGIN },
276
+ ]);
277
+ const h = makeHarness({
278
+ readFile: async (owner, repo, p) => (owner === LOGIN ? existing : OFFICIAL_INDEX),
279
+ });
280
+ // form.id 显式指向条目 id;name 被用户改了也不影响定位
281
+ const result = await h.service.update({ zipBytes: ZIP_BYTES, form: form({ id: 'config-abc12345', name: '我的配置 v2' }) });
282
+
283
+ assert.equal(result.ok, true);
284
+ assert.equal(result.itemId, 'config-abc12345', '必须按显式 id 更新');
285
+ assert.equal(result.version, '2.1.1', 'version 基于显式 id 条目的旧版本 +1');
286
+
287
+ const userIndex = indexJsonOf(h.gitCalls[0]!);
288
+ assert.ok(userIndex.items.some((it) => it.id === 'config-abc12345' && it.version === '2.1.1'), '显式 id 条目被更新');
289
+ assert.equal(userIndex.items.filter((it) => it.id === 'config-abc12345').length, 1, '不得新增重复条目');
290
+ });
291
+
292
+ test('my-repo: update 显式 form.id 但条目不存在 → 明确报错(不静默新建)', async () => {
293
+ const existing = USER_INDEX_WITH([{ id: 'other-item', name: 'Other', version: '1.0.0', author: LOGIN }]);
294
+ const h = makeHarness({
295
+ readFile: async (owner, repo, p) => (owner === LOGIN ? existing : OFFICIAL_INDEX),
296
+ });
297
+ const result = await h.service.update({ zipBytes: ZIP_BYTES, form: form({ id: 'gone-item', name: 'Gone' }) });
298
+
299
+ assert.equal(result.ok, false);
300
+ assert.equal(result.errorCode, 'item_not_found');
301
+ assert.equal(h.gitCalls.length, 0, '未找到条目必须零推送');
302
+ });
303
+
251
304
  /* ---------------------------------------------------------------- PR 复用与重开 */
252
305
 
253
306
  test('my-repo: PR 复用(open PR 未合并)→ 不重复创建', async () => {
@@ -260,15 +313,19 @@ test('my-repo: PR 复用(open PR 未合并)→ 不重复创建', async () =>
260
313
  });
261
314
  const result = await h.service.upload({ zipBytes: ZIP_BYTES, form: form() });
262
315
 
263
- assert.equal(result.prNumber, 7);
264
- assert.equal(result.prUrl, `${OFFICIAL_URL}/pull/7`);
316
+ // 后台收录完成:复用已有 open PR(head 匹配)
317
+ const listing = await h.service.waitForListing('my-config');
318
+ assert.equal(listing.prNumber, 7);
319
+ assert.equal(listing.prUrl, `${OFFICIAL_URL}/pull/7`);
265
320
  assert.equal(h.openPrCalls.length, 0, '已有 open PR 必须复用,不重复创建');
266
321
  });
267
322
 
268
323
  test('my-repo: PR 重开(无 open PR / 已合并)→ 新建 PR', async () => {
269
324
  const h = makeHarness(); // listOpenPullRequests 默认返回 []
270
325
  const result = await h.service.upload({ zipBytes: ZIP_BYTES, form: form() });
271
- assert.equal(result.prNumber, 42);
326
+ assert.equal(result.ok, true);
327
+ const listing = await h.service.waitForListing('my-config');
328
+ assert.equal(listing.prNumber, 42);
272
329
  assert.equal(h.openPrCalls.length, 1, '无 open PR 必须新建');
273
330
  });
274
331
 
@@ -287,9 +344,7 @@ test('my-repo: 远端 index 前进防覆盖 → 用户/官方既有条目全部
287
344
  { id: 'my-config', name: 'My Config', version: '0.9.0', repo: USER_REPO_URL },
288
345
  ],
289
346
  });
290
- const h = makeHarness({
291
- readFile: async (owner, repo, p) => (owner === LOGIN ? userIndexText : officialText),
292
- });
347
+ const h = makeHarness({}, { userIndex: userIndexText, officialIndex: officialText });
293
348
 
294
349
  // update 同名:旧条目已在,直接 bump 而非新建
295
350
  const result = await h.service.update({ zipBytes: ZIP_BYTES, form: form() });
@@ -303,7 +358,8 @@ test('my-repo: 远端 index 前进防覆盖 → 用户/官方既有条目全部
303
358
  assert.equal(userIndex.items.find((it) => it.id === 'old-item')!.version, '2.0.0');
304
359
  assert.equal(userIndex.items.find((it) => it.id === 'my-config')!.version, '0.9.1');
305
360
 
306
- // 官方 index:keep-me 保留 + my-config 更新
361
+ // 官方 index(后台收录完成):keep-me 保留 + my-config 更新
362
+ await h.service.waitForListing('my-config');
307
363
  const forkIndex = indexJsonOf(h.gitCalls[1]!);
308
364
  assert.deepEqual(forkIndex.items.map((it) => it.id), ['keep-me', 'my-config']);
309
365
  assert.equal(forkIndex.items.find((it) => it.id === 'my-config')!.version, '0.9.1');
@@ -330,6 +386,7 @@ test('my-repo: 校验失败(prepare 抛错)→ 零推送、零仓库操作',
330
386
  readFile: async () => { restCalls.push('readFile'); return null; },
331
387
  openPullRequest: async () => { restCalls.push('openPullRequest'); throw new Error('不应调用'); },
332
388
  listOpenPullRequests: async () => { restCalls.push('listOpenPullRequests'); return []; },
389
+ closePullRequest: async () => { restCalls.push('closePullRequest'); throw new Error('不应调用'); },
333
390
  };
334
391
  const service = new MyRepoService({
335
392
  prepare: failingPrepare,
@@ -450,6 +507,140 @@ test('my-repo: listItems 从未上传(用户仓库无 index)→ 空数组',
450
507
  assert.deepEqual(items, []);
451
508
  });
452
509
 
510
+ /* ---------------------------------------------------------------- 删除条目(关 PR / 下架) */
511
+
512
+ test('my-repo: 删除未收录条目(有 open PR)→ 移除索引与文件 + 关闭收录 PR', async () => {
513
+ const existing = USER_INDEX_WITH([{ id: 'my-config', name: 'My Config', version: '1.0.0', author: LOGIN }]);
514
+ const openPrs = [
515
+ { number: 7, htmlUrl: `${OFFICIAL_URL}/pull/7`, title: 't', head: prBranchFor('my-config'), state: 'open', merged: false },
516
+ ];
517
+ const closedCalls: number[] = [];
518
+ const h = makeHarness({
519
+ readFile: async (owner, repo, p) => (owner === LOGIN ? existing : OFFICIAL_INDEX),
520
+ listOpenPullRequests: async (owner, repo, head) => {
521
+ // 删除场景:先查官方 index(未收录)→ 再查 open PR(命中)
522
+ return openPrs.filter((p) => head === `${LOGIN}:${p.head}`);
523
+ },
524
+ closePullRequest: async (owner, repo, number) => {
525
+ closedCalls.push(number);
526
+ return { number, htmlUrl: `${OFFICIAL_URL}/pull/${number}`, title: 't', head: 'x', state: 'closed', merged: false };
527
+ },
528
+ });
529
+
530
+ const result = await h.service.deleteItem('my-config');
531
+ assert.equal(result.ok, true);
532
+ assert.equal(result.delisted, false, '未收录不触发下架');
533
+ assert.equal(result.prNumber, 7);
534
+ assert.deepEqual(closedCalls, [7], '必须关闭待审核的收录 PR');
535
+
536
+ // 用户仓库写回:index.json 移除条目(删除 items/ 目录由调用方在 writeFiles 前 fs.rm 完成)
537
+ assert.equal(h.gitCalls.length, 1);
538
+ const call = h.gitCalls[0]!;
539
+ assert.equal(call.repoUrl, `${USER_REPO_URL}.git`);
540
+ assert.deepEqual(call.entries.map((e) => e.path), ['index.json']);
541
+ const userIndex = indexJsonOf(call);
542
+ assert.equal(userIndex.items.length, 0, '删除后 index 必须为空');
543
+ });
544
+
545
+ test('my-repo: 删除已收录条目 → 移除本地内容 + 后台异步提下架 PR(独立 delist 分支)', async () => {
546
+ const existing = USER_INDEX_WITH([{ id: 'my-config', name: 'My Config', version: '1.0.0', author: LOGIN }]);
547
+ const officialText = JSON.stringify({
548
+ schemaVersion: 1,
549
+ name: 'DSH 配置市场',
550
+ items: [{ id: 'my-config', name: 'My Config', version: '1.0.0', repo: USER_REPO_URL }],
551
+ });
552
+ const h = makeHarness({
553
+ readFile: async (owner, repo, p) => (owner === LOGIN ? existing : officialText),
554
+ });
555
+
556
+ const result = await h.service.deleteItem('my-config');
557
+ assert.equal(result.ok, true);
558
+ assert.equal(result.delisted, true, '已收录必须触发下架');
559
+ assert.ok(result.warnings.some((w) => w.includes('下架')), '必须提示已提交下架 PR');
560
+
561
+ // 本地删除:index.json 移除条目
562
+ const userIndex = indexJsonOf(h.gitCalls[0]!);
563
+ assert.equal(userIndex.items.length, 0);
564
+
565
+ // 后台下架:独立 delist 分支 + 从官方 index 移除
566
+ const listing = await h.service.waitForListing('my-config');
567
+ assert.equal(listing.listing, 'done');
568
+ assert.equal(h.gitCalls.length, 2);
569
+ const forkCall = h.gitCalls[1]!;
570
+ assert.equal(forkCall.branch, delistBranchFor('my-config'), '下架必须用独立 delist 分支');
571
+ assert.equal(forkCall.force, true);
572
+ const officialIndex = indexJsonOf(forkCall);
573
+ assert.equal(officialIndex.items.length, 0, '官方 index 必须移除该条目');
574
+ // 下架 PR 标题/正文走下架语义
575
+ assert.equal(h.openPrCalls.length, 1);
576
+ assert.ok(h.openPrCalls[0]!.title.includes('下架'), '下架 PR 标题必须区分收录');
577
+ assert.equal(h.openPrCalls[0]!.head, `${LOGIN}:${delistBranchFor('my-config')}`);
578
+ });
579
+
580
+ test('my-repo: 删除不存在的条目 → item_not_found 零推送', async () => {
581
+ const h = makeHarness({
582
+ readFile: async (owner, repo, p) => (owner === LOGIN ? USER_INDEX_EMPTY : OFFICIAL_INDEX),
583
+ });
584
+ const result = await h.service.deleteItem('gone-item');
585
+ assert.equal(result.ok, false);
586
+ assert.equal(result.errorCode, 'item_not_found');
587
+ assert.equal(h.gitCalls.length, 0, '删除不存在的条目必须零推送');
588
+ });
589
+
590
+ /* ---------------------------------------------------------------- relist / listingStatus */
591
+
592
+ test('my-repo: relist 幂等(上传失败后重新提交收录)→ 复用已有 fork + open PR', async () => {
593
+ const openPrs = [
594
+ { number: 7, htmlUrl: `${OFFICIAL_URL}/pull/7`, title: 't', head: prBranchFor('my-config'), state: 'open', merged: false },
595
+ ];
596
+ const h = makeHarness({
597
+ listOpenPullRequests: async (owner, repo, head) => openPrs.filter((p) => head === `${LOGIN}:${p.head}`),
598
+ });
599
+
600
+ // 上传(后台收录完成)
601
+ const up = await h.service.upload({ zipBytes: ZIP_BYTES, form: form() });
602
+ assert.equal(up.ok, true);
603
+ assert.equal(up.itemId, 'my-config');
604
+ await h.service.waitForListing('my-config');
605
+
606
+ // relist:任务已 done → 直接返回当前状态,不重复启动
607
+ const again = await h.service.relist('my-config');
608
+ assert.equal(again.listing, 'done');
609
+ assert.equal(again.prNumber, 7);
610
+ assert.equal(h.openPrCalls.length, 0, 'relist 复用 open PR 不新建');
611
+ });
612
+
613
+ test('my-repo: relist 条目不存在 → item_not_found', async () => {
614
+ const h = makeHarness({
615
+ readFile: async (owner, repo, p) => (owner === LOGIN ? USER_INDEX_EMPTY : OFFICIAL_INDEX),
616
+ });
617
+ await assert.rejects(h.service.relist('gone-item'), (err: unknown) => {
618
+ assert.ok(err instanceof MyRepoError);
619
+ assert.equal(err.code, 'item_not_found');
620
+ return true;
621
+ });
622
+ });
623
+
624
+ test('my-repo: listingStatus 任务表未命中 → 回退 GitHub 实况推导(已收录 → done)', async () => {
625
+ const officialText = JSON.stringify({
626
+ schemaVersion: 1,
627
+ name: 'DSH 配置市场',
628
+ items: [{ id: 'my-config', name: 'My Config', version: '1.0.0', repo: USER_REPO_URL }],
629
+ });
630
+ const h = makeHarness({
631
+ readFile: async (owner, repo, p) => (owner === LOGIN ? USER_INDEX_EMPTY : officialText),
632
+ });
633
+ const status = await h.service.listingStatus('my-config');
634
+ assert.equal(status?.listing, 'done');
635
+ assert.equal(status?.prUrl, null, '官方 index 命中时无 PR 链接');
636
+ });
637
+
638
+ test('my-repo: listingStatus 无任务且无实况 → null', async () => {
639
+ const h = makeHarness();
640
+ const status = await h.service.listingStatus('never-existed');
641
+ assert.equal(status, null);
642
+ });
643
+
453
644
  /* ---------------------------------------------------------------- 纯函数 */
454
645
 
455
646
  test('my-repo: slugifyItemId 稳定 slug(中文/符号折叠、字母数字开头、长度上限)', () => {