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
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { join } from 'node:path';
26
26
  import os from 'node:os';
27
+ import fs from 'node:fs/promises';
27
28
 
28
29
  import { MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO } from './github-repos.ts';
29
30
  import type {
@@ -51,14 +52,22 @@ import type { SectionId } from '../schema/types.ts';
51
52
  export const USER_CONFIGS_REPO = 'dsh-configs';
52
53
  /** 用户仓库 index.json 的名称/描述(展示用) */
53
54
  export const USER_INDEX_NAME = '我的配置仓库';
54
- /** PR 分支固定名模式:dsh-market-sync/<itemId> */
55
+ /** PR 分支固定名模式:dsh-market-sync/<itemId>(收录) */
55
56
  export const PR_BRANCH_PREFIX = 'dsh-market-sync';
57
+ /** 下架 PR 分支固定名模式:dsh-market-delist/<itemId>(删除已收录条目;独立分支避免与收录分支混淆,
58
+ * 防止 listItems 的 pr-pending 判定把下架 PR 误判为收录待审、以及删除后重传时 PR 复用错乱) */
59
+ export const DELIST_BRANCH_PREFIX = 'dsh-market-delist';
56
60
 
57
61
  /** 由 itemId 生成 PR 分支名(固定模式,同条目复用同一分支:未合并 force push 更新、已合并基于最新 main 重开) */
58
62
  export function prBranchFor(itemId: string): string {
59
63
  return `${PR_BRANCH_PREFIX}/${itemId}`;
60
64
  }
61
65
 
66
+ /** 由 itemId 生成下架 PR 分支名(删除已收录条目时用;独立于收录分支) */
67
+ export function delistBranchFor(itemId: string): string {
68
+ return `${DELIST_BRANCH_PREFIX}/${itemId}`;
69
+ }
70
+
62
71
  /** 用户公开仓库 URL(https 形态,绝不携带凭据) */
63
72
  export function userConfigsRepoUrl(login: string): string {
64
73
  return `https://github.com/${login}/${USER_CONFIGS_REPO}`;
@@ -73,11 +82,23 @@ export type MyItemStatus = 'not-listed' | 'pr-pending' | 'listed';
73
82
  export interface MyRepoForm {
74
83
  /** 配置名(预填 zip 文件名,可改);id 由其派生稳定 slug */
75
84
  name: string;
85
+ /** 显式目标条目 id(**仅 update 模式**用:由列表「更新」按钮预填,避免靠 name→slug 猜测匹配;
86
+ * 上传(新条目)时省略;后端校验存在性,缺省回退 name slug 匹配(向后兼容) */
87
+ id?: string;
76
88
  description?: string;
77
89
  categories?: string[];
78
90
  }
79
91
 
80
- /** upload / update 返回(ok=false 时携带已脱敏 error + errorCode) */
92
+ /** 收录流程状态:pending=已提交、后台处理中;done=已提交收录(PR 已开/复用);failed=收录失败可重试 */
93
+ export type ListingState = 'pending' | 'done' | 'failed';
94
+
95
+ /**
96
+ * upload / update 返回(ok=false 时携带已脱敏 error + errorCode)。
97
+ * 流程拆分(2026-08-20 优化):upload/update 只同步执行「推用户仓库」并立即返回
98
+ * (listing='pending'),「收录流程」(fork + 官方 index + PR)在后台异步执行,
99
+ * 避免 GitHub fork 排队时整个上传请求长时间挂起;收录结果经 listingStatus /
100
+ * /me/items 状态徽章查询。
101
+ */
81
102
  export interface UploadResult {
82
103
  ok: boolean;
83
104
  itemId: string;
@@ -86,15 +107,50 @@ export interface UploadResult {
86
107
  sections: SectionId[];
87
108
  /** 用户公开仓库 URL(https://github.com/<login>/dsh-configs) */
88
109
  repoUrl: string;
110
+ /** 收录 PR 编号(异步模式下收起完成前为 null;完成经 listingStatus 查询) */
89
111
  prNumber: number | null;
112
+ /** 收录 PR 链接(同上) */
90
113
  prUrl: string | null;
91
114
  warnings: string[];
115
+ /** 收录流程状态:ok=true 且上传成功后为 'pending'(后台进行中);失败无收录流程时 'done' 无意义 */
116
+ listing: ListingState;
117
+ /** listing='failed' 时:收录失败原因(已脱敏,含重试指引) */
118
+ listingError?: string;
92
119
  /** ok=false 时:可展示错误(已脱敏,无 token 形态) */
93
120
  error?: string;
94
121
  /** ok=false 时:错误分类码(unauthorized/prepare_failed/...;无则 'internal') */
95
122
  errorCode?: string;
96
123
  }
97
124
 
125
+ /** POST /me/listing 响应:收录任务状态(结果卡轮询用,比拉全列表更轻) */
126
+ export interface ListingStatusResponse {
127
+ itemId: string;
128
+ listing: ListingState;
129
+ /** listing='done' 时:PR 编号 */
130
+ prNumber: number | null;
131
+ /** listing='done' 时:PR 链接 */
132
+ prUrl: string | null;
133
+ /** listing='failed' 时:失败原因(已脱敏) */
134
+ error?: string;
135
+ }
136
+
137
+ /** POST /me/delete 响应:删除条目结果 */
138
+ export interface DeleteResult {
139
+ ok: boolean;
140
+ itemId: string;
141
+ /** 是否已异步提交「下架 PR」(条目此前已收录进官方市场时 true) */
142
+ delisted: boolean;
143
+ /** 被关闭的收录 PR(条目处于待审核、有 open PR 时关闭它) */
144
+ prNumber: number | null;
145
+ /** 被关闭 PR 的链接(同上) */
146
+ prUrl: string | null;
147
+ warnings: string[];
148
+ /** ok=false 时:可展示错误(已脱敏) */
149
+ error?: string;
150
+ /** ok=false 时:错误分类码(item_not_found/unauthorized/...) */
151
+ errorCode?: string;
152
+ }
153
+
98
154
  /** listItems 条目:用户仓库 index.json 条目 + 收录状态 */
99
155
  export interface MyItemEntry {
100
156
  id: string;
@@ -125,6 +181,8 @@ export interface GitHubRestLike {
125
181
  readFile(owner: string, repo: string, path: string, ref?: string): Promise<string | null>;
126
182
  openPullRequest(params: GitHubPullRequestParams): Promise<GitHubPullRequestInfo>;
127
183
  listOpenPullRequests(owner: string, repo: string, head?: string): Promise<GitHubPullRequestInfo[]>;
184
+ /** 关闭 PR(删除条目时关闭待审核的收录 PR) */
185
+ closePullRequest(owner: string, repo: string, number: number): Promise<GitHubPullRequestInfo>;
128
186
  }
129
187
 
130
188
  /* ---------------------------------------------------------------- 服务选项与错误 */
@@ -154,6 +212,26 @@ export class MyRepoError extends Error {
154
212
  }
155
213
  }
156
214
 
215
+ /** 后台市场任务动作:list=收录(写官方 index + 提收录 PR);delist=下架(从官方 index 移除 + 提下架 PR) */
216
+ export type ListingAction = 'list' | 'delist';
217
+
218
+ /** 后台市场任务(收录/下架共用同一管道;内存 Map<itemId, job>,进程重启后丢失,靠 relist/删除重新提交) */
219
+ export interface ListingJob {
220
+ action: ListingAction;
221
+ login: string;
222
+ itemId: string;
223
+ version: string;
224
+ /** 用户公开仓库 URL(条目内容托管地) */
225
+ repoUrl: string;
226
+ status: ListingState;
227
+ prNumber: number | null;
228
+ prUrl: string | null;
229
+ /** status='failed' 时:失败原因(已脱敏) */
230
+ error: string | null;
231
+ startedAt: number;
232
+ finishedAt: number | null;
233
+ }
234
+
157
235
  /* ---------------------------------------------------------------- 纯函数(node 可单测) */
158
236
 
159
237
  /** name → 稳定安全 itemId:小写、非字母数字折叠为 -、字母数字开头、去头尾分隔符;空 → config-<hash> */
@@ -201,6 +279,11 @@ function upsertIndexItem(index: MarketIndex, item: MarketIndexItem): MarketIndex
201
279
  return { ...index, items };
202
280
  }
203
281
 
282
+ /** 移除条目:按 id 过滤(删除条目时用);不存在则原样返回 */
283
+ function removeIndexItem(index: MarketIndex, itemId: string): MarketIndex {
284
+ return { ...index, items: index.items.filter((it) => it.id !== itemId) };
285
+ }
286
+
204
287
  /** manifest → 用户仓库 index 条目(不含 repo:条目即在本仓库) */
205
288
  function toUserIndexItem(m: MarketItemManifest): MarketIndexItem {
206
289
  return {
@@ -239,6 +322,7 @@ function failureResult(itemId: string, version: string, err: unknown): UploadRes
239
322
  prNumber: null,
240
323
  prUrl: null,
241
324
  warnings: [],
325
+ listing: 'done', // 上传阶段即失败:无收录流程,listing 无意义
242
326
  error: redact(msg),
243
327
  errorCode: classifyError(err),
244
328
  };
@@ -253,6 +337,10 @@ export class MyRepoService {
253
337
  private readonly tokenProvider: () => Promise<string>;
254
338
  private readonly now: () => Date;
255
339
  private readonly workDirRoot: string;
340
+ /** 后台市场任务表(收录/下架;key=itemId;内存态,重启丢失 → 列表状态徽章回退 + relist/删除可重提) */
341
+ private readonly listingJobs = new Map<string, ListingJob>();
342
+ /** 同一 login 的「写用户仓库」串行队列(user-<login> 工作副本共享;上传/删除并发时防踩踏) */
343
+ private readonly loginWriteQueues = new Map<string, Promise<unknown>>();
256
344
 
257
345
  constructor(options: MyRepoServiceOptions) {
258
346
  if (options.prepare === null || typeof options.prepare !== 'function') {
@@ -319,6 +407,174 @@ export class MyRepoService {
319
407
  return entries;
320
408
  }
321
409
 
410
+ /* ---------------- 后台收录/下架任务状态 ---------------- */
411
+
412
+ /**
413
+ * 查询收录/下架任务状态。
414
+ * 内存任务表命中 → 直接返回;未命中(进程重启 / 从未提交)→ 回退 GitHub 实况推导:
415
+ * 官方 index 含该 id → done(已收录);存在 open 收录 PR → done(带 PR 链接);否则 null。
416
+ */
417
+ async listingStatus(itemId: string): Promise<ListingStatusResponse | null> {
418
+ const job = this.listingJobs.get(itemId);
419
+ if (job !== undefined) return this.toListingStatus(job);
420
+ // 回退实况推导(重启后任务表丢失:靠官方 index / open PR 判断是否已完成)
421
+ try {
422
+ const user = await this.rest.getUser();
423
+ const login = user.login;
424
+ const officialText = await this.rest.readFile(MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO, 'index.json');
425
+ if (officialText !== null) {
426
+ const op = parseMarketIndex(officialText);
427
+ if (op.ok && op.index !== null && op.index.items.some((it) => it.id === itemId)) {
428
+ return { itemId, listing: 'done', prNumber: null, prUrl: null };
429
+ }
430
+ }
431
+ const openPrs = await this.rest.listOpenPullRequests(
432
+ MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO, `${login}:${prBranchFor(itemId)}`,
433
+ );
434
+ if (openPrs.length > 0) {
435
+ const pr = openPrs[0]!;
436
+ return { itemId, listing: 'done', prNumber: pr.number, prUrl: pr.htmlUrl };
437
+ }
438
+ return null;
439
+ } catch {
440
+ return null; // 实况推导失败不阻塞轮询:前端按 null 处理(任务已结束/重启丢失)
441
+ }
442
+ }
443
+
444
+ /** 重新提交收录(收录失败 / 进程重启丢失后的一键重试):幂等 —— 已有 pending 任务直接复用,不再重复启动 */
445
+ async relist(itemId: string): Promise<ListingStatusResponse> {
446
+ const user = await this.rest.getUser(); // 401 → 上层映射「请重新登录」
447
+ const login = user.login;
448
+ const existing = this.listingJobs.get(itemId);
449
+ if (existing !== undefined) {
450
+ if (existing.status === 'pending') return this.toListingStatus(existing);
451
+ if (existing.status === 'done') return this.toListingStatus(existing);
452
+ // failed → 落下去重新启动
453
+ }
454
+ const { index } = await this.readUserIndex(login);
455
+ const item = index.items.find((it) => it.id === itemId);
456
+ if (item === undefined) {
457
+ throw new MyRepoError(`未找到条目 ${itemId}(可能已被删除),请刷新列表后重试`, 'item_not_found');
458
+ }
459
+ const repoUrl = userConfigsRepoUrl(login);
460
+ this.launchListing({
461
+ action: 'list',
462
+ login,
463
+ itemId,
464
+ version: item.version ?? '1.0.0',
465
+ repoUrl,
466
+ });
467
+ const job = this.listingJobs.get(itemId)!;
468
+ return this.toListingStatus(job);
469
+ }
470
+
471
+ /** 等待任务终态(测试辅助 / 需要同步等待的场景;超时抛 internal 错误) */
472
+ async waitForListing(itemId: string, timeoutMs = 10_000): Promise<ListingStatusResponse> {
473
+ const deadline = Date.now() + timeoutMs;
474
+ for (;;) {
475
+ const job = this.listingJobs.get(itemId);
476
+ if (job !== undefined && job.status !== 'pending') return this.toListingStatus(job);
477
+ if (Date.now() >= deadline) {
478
+ throw new MyRepoError(`等待市场任务超时(${itemId})`, 'internal');
479
+ }
480
+ await new Promise((resolve) => setTimeout(resolve, 25));
481
+ }
482
+ }
483
+
484
+ /* ---------------- 删除条目(含下架处理) ---------------- */
485
+
486
+ /**
487
+ * 删除已上传条目:
488
+ * ① 从用户仓库 index.json 移除条目 + 删除 items/<itemId>/ 目录(同步,秒级)
489
+ * ② 收录状态处理:
490
+ * - 已收录进官方市场(listed)→ 后台异步提「下架 PR」(从官方 index 移除,独立 delist 分支)
491
+ * - 有待审核的收录 open PR → 同步关闭该 PR
492
+ * - 未收录 / 无 PR → 无额外操作
493
+ * 返回 DeleteResult(ok=false 时携带已脱敏 error + errorCode)。
494
+ */
495
+ async deleteItem(itemId: string): Promise<DeleteResult> {
496
+ const user = await this.rest.getUser();
497
+ const login = user.login;
498
+ const repoUrl = userConfigsRepoUrl(login);
499
+ try {
500
+ // ① 读用户仓库 index + 定位条目
501
+ const { index } = await this.readUserIndex(login);
502
+ const item = index.items.find((it) => it.id === itemId);
503
+ if (item === undefined) {
504
+ throw new MyRepoError(`未找到要删除的条目 ${itemId}(可能已被删除),请刷新列表后重试`, 'item_not_found');
505
+ }
506
+
507
+ // ② 移除条目 + 删除 items/<itemId>/ 目录 → 写回 index.json(per-login 串行)
508
+ const updatedIndex = removeIndexItem(index, itemId);
509
+ await this.withLoginLock(login, async () => {
510
+ // writeFiles 只写 entries 列出的文件、不会主动删除 items/<itemId>/:先删工作副本目录,git add -A 才会捕获删除
511
+ const workDir = this.userWorkDir(login);
512
+ await fs.rm(join(workDir, 'items', itemId), { recursive: true, force: true }).catch(() => undefined);
513
+ await this.gitWriter.writeFiles({
514
+ repoUrl: `${userConfigsRepoUrl(login)}.git`,
515
+ workDir,
516
+ entries: [{ path: 'index.json', content: stringifyJsonSafe(updatedIndex, { space: 2 }) }],
517
+ commitMessage: `publish: remove ${itemId}`,
518
+ });
519
+ });
520
+
521
+ // ③ 收录状态处理
522
+ const officialText = await this.rest.readFile(MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO, 'index.json');
523
+ let officialListed = false;
524
+ if (officialText !== null) {
525
+ const op = parseMarketIndex(officialText);
526
+ if (op.ok && op.index !== null) officialListed = op.index.items.some((it) => it.id === itemId);
527
+ }
528
+ if (officialListed) {
529
+ // 已收录 → 后台异步提下架 PR(不阻塞删除;下架 PR 合并前官方市场条目短暂不可下载,UI 已提示)
530
+ this.launchListing({
531
+ action: 'delist',
532
+ login,
533
+ itemId,
534
+ version: item.version ?? '1.0.0',
535
+ repoUrl,
536
+ });
537
+ return {
538
+ ok: true,
539
+ itemId,
540
+ delisted: true,
541
+ prNumber: null,
542
+ prUrl: null,
543
+ warnings: ['该条目已收录官方市场,已自动提交下架 PR;合并前官方市场可能仍显示该条目'],
544
+ };
545
+ }
546
+ // 未收录 → 关闭待审核的收录 PR(如有)
547
+ const openPrs = await this.rest.listOpenPullRequests(
548
+ MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO, `${login}:${prBranchFor(itemId)}`,
549
+ );
550
+ if (openPrs.length > 0) {
551
+ const pr = openPrs[0]!;
552
+ await this.rest.closePullRequest(MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO, pr.number);
553
+ return {
554
+ ok: true,
555
+ itemId,
556
+ delisted: false,
557
+ prNumber: pr.number,
558
+ prUrl: pr.htmlUrl,
559
+ warnings: ['已关闭该条目的待审核收录 PR'],
560
+ };
561
+ }
562
+ return { ok: true, itemId, delisted: false, prNumber: null, prUrl: null, warnings: [] };
563
+ } catch (err) {
564
+ const msg = err instanceof Error ? err.message : String(err);
565
+ return {
566
+ ok: false,
567
+ itemId,
568
+ delisted: false,
569
+ prNumber: null,
570
+ prUrl: null,
571
+ warnings: [],
572
+ error: redact(msg),
573
+ errorCode: classifyError(err),
574
+ };
575
+ }
576
+ }
577
+
322
578
  /* ---------------- 上传/更新共享状态机 ---------------- */
323
579
 
324
580
  private async runPublish(
@@ -341,10 +597,18 @@ export class MyRepoService {
341
597
  let version: string;
342
598
  const warnings: string[] = [];
343
599
  if (params.mode === 'update') {
344
- const existing = userIndex.index.items.find((it) => it.id === baseId);
600
+ // 优先按显式 form.id 定位(「更新」按钮预填,避免 name→slug 猜测在中文名/改名场景失配);
601
+ // 缺省回退 name slug 匹配(旧调用方向后兼容)。
602
+ const explicitId = typeof form.id === 'string' && form.id.trim() !== '' ? form.id.trim() : null;
603
+ const existing = explicitId !== null
604
+ ? userIndex.index.items.find((it) => it.id === explicitId)
605
+ : userIndex.index.items.find((it) => it.id === baseId);
345
606
  if (existing !== undefined) {
346
607
  itemId = existing.id; // id 保持不变
347
608
  version = bumpVersion(existing.version ?? '0.0.0');
609
+ } else if (explicitId !== null) {
610
+ // 明确要求更新一个不存在的 id → 报错(避免静默新建导致用户以为更新成功)
611
+ throw new MyRepoError(`未找到要更新的条目 ${explicitId}(可能已被移除),请刷新列表后重试`, 'item_not_found');
348
612
  } else {
349
613
  itemId = uniqueItemId(baseId, ids); // 名称变化 → 作为新条目
350
614
  version = '1.0.0';
@@ -373,29 +637,21 @@ export class MyRepoService {
373
637
  const repo = await this.ensureUserRepo(login);
374
638
 
375
639
  // ⑥ 写用户仓库:items/<id>/manifest.json + config.zip + 更新 index.json,commit + push
640
+ // (同一 login 的写操作串行化:user-<login> 工作副本共享,删除/上传并发时防踩踏)
376
641
  const updatedUserIndex = upsertIndexItem(userIndex.index, toUserIndexItem(manifest));
377
- await this.gitWriterManifestSync(login, repo, prepared, params.zipBytes, updatedUserIndex, itemId, version, params.mode);
378
-
379
- // ⑦ ensureFork 官方仓库(复用已 fork / 新建 + 轮询就绪)
380
- const fork = await this.rest.ensureFork(MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO);
381
-
382
- // ⑧ 官方 index:读最新 → 更新条目(带 repo 自托管引用)→ 写 fork 分支 + force push
383
- const officialIndex = await this.readOfficialIndex();
384
- const updatedOfficial = upsertIndexItem(officialIndex.index, toOfficialIndexItem(manifest, repoUrl));
385
- const branch = prBranchFor(itemId);
386
- await this.gitWriter.writeFiles({
387
- repoUrl: fork.cloneUrl,
388
- workDir: this.forkWorkDir(login),
389
- upstreamUrl: `https://github.com/${MARKET_UPSTREAM_OWNER}/${MARKET_UPSTREAM_REPO}.git`,
390
- branch,
391
- baseRef: 'upstream/main',
392
- force: true,
393
- entries: [{ path: 'index.json', content: stringifyJsonSafe(updatedOfficial, { space: 2 }) }],
394
- commitMessage: `market: ${params.mode === 'update' ? 'update' : 'add'} ${itemId} v${version}`,
395
- });
642
+ await this.withLoginLock(login, () =>
643
+ this.gitWriterManifestSync(login, repo, prepared, params.zipBytes, updatedUserIndex, itemId, version, params.mode),
644
+ );
396
645
 
397
- // ⑨ PR:查 open PR(head=<login>:<branch>)复用;无则创建
398
- const pr = await this.ensurePullRequest(login, branch, itemId, version);
646
+ // ⑦⑧⑨ 收录流程(fork + 官方 index + PR)转入后台异步任务:上传立即返回,
647
+ // 避免 GitHub fork 排队时整个请求长时间挂起;结果经 listingStatus / 列表徽章查询。
648
+ this.launchListing({
649
+ action: 'list',
650
+ login,
651
+ itemId,
652
+ version,
653
+ repoUrl,
654
+ });
399
655
 
400
656
  return {
401
657
  ok: true,
@@ -404,9 +660,10 @@ export class MyRepoService {
404
660
  sha256: prepared.sha256,
405
661
  sections: prepared.sections,
406
662
  repoUrl,
407
- prNumber: pr.number,
408
- prUrl: pr.htmlUrl,
663
+ prNumber: null,
664
+ prUrl: null,
409
665
  warnings: [...prepared.warnings, ...warnings],
666
+ listing: 'pending',
410
667
  };
411
668
  } catch (err) {
412
669
  return failureResult(baseId, '1.0.0', err);
@@ -487,11 +744,32 @@ export class MyRepoService {
487
744
  return this.rest.createPublicRepo(USER_CONFIGS_REPO, '我的配置仓库(dsh-config-manager 自动发布)');
488
745
  }
489
746
 
490
- /** ⑨ ensure PR:open PR(head=<login>:<branch>)存在 → 复用;否则 openPullRequest(base=main) */
491
- private async ensurePullRequest(login: string, branch: string, itemId: string, version: string): Promise<GitHubPullRequestInfo> {
747
+ /** ⑨ ensure PR:open PR(head=<login>:<branch>)存在 → 复用;否则 openPullRequest(base=main)。
748
+ * action='delist' 时标题/正文为下架语义(分支用 delistBranchFor,head 过滤天然隔离)。 */
749
+ private async ensurePullRequest(
750
+ login: string,
751
+ branch: string,
752
+ itemId: string,
753
+ version: string,
754
+ action: ListingAction = 'list',
755
+ ): Promise<GitHubPullRequestInfo> {
492
756
  const head = `${login}:${branch}`;
493
757
  const open = await this.rest.listOpenPullRequests(MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO, head);
494
758
  if (open.length > 0) return open[0]!;
759
+ if (action === 'delist') {
760
+ return this.rest.openPullRequest({
761
+ head,
762
+ base: 'main',
763
+ title: `市场下架:${itemId}`,
764
+ body: [
765
+ `由 dsh-config-manager「删除条目」自动提交。`,
766
+ '',
767
+ `- 条目:${itemId}`,
768
+ `- 操作:从官方市场 index.json 移除该条目(下架)`,
769
+ `- 内容:原条目内容位于用户公开仓库,已随删除移除`,
770
+ ].join('\n'),
771
+ });
772
+ }
495
773
  return this.rest.openPullRequest({
496
774
  head,
497
775
  base: 'main',
@@ -506,11 +784,106 @@ export class MyRepoService {
506
784
  });
507
785
  }
508
786
 
787
+ /* ---------------- 后台市场任务(收录/下架) ---------------- */
788
+
789
+ /** 启动后台任务(fire-and-forget):同 itemId 已有 pending 任务 → 不重复启动(幂等) */
790
+ private launchListing(input: {
791
+ action: ListingAction;
792
+ login: string;
793
+ itemId: string;
794
+ version: string;
795
+ repoUrl: string;
796
+ }): void {
797
+ const existing = this.listingJobs.get(input.itemId);
798
+ if (existing !== undefined && existing.status === 'pending') return;
799
+ const job: ListingJob = {
800
+ ...input,
801
+ status: 'pending',
802
+ prNumber: null,
803
+ prUrl: null,
804
+ error: null,
805
+ startedAt: Date.now(),
806
+ finishedAt: null,
807
+ };
808
+ this.listingJobs.set(input.itemId, job);
809
+ void this.finishListing(job).catch((err) => {
810
+ job.status = 'failed';
811
+ job.error = redact(err instanceof Error ? err.message : String(err));
812
+ job.finishedAt = Date.now();
813
+ });
814
+ }
815
+
816
+ /**
817
+ * 后台任务主体(收录/下架共用管道):
818
+ * 读用户仓库条目 → ensureFork(复用/新建+轮询就绪)→ 读官方 index → 改(收录 upsert 带 repo /
819
+ * 下架 remove)→ 写 fork 分支(workDir 按 itemId 隔离,防并发踩踏)force push → 开/复用 PR。
820
+ */
821
+ private async finishListing(job: ListingJob): Promise<void> {
822
+ // ⑦ fork(复用已 fork / 新建 + 轮询就绪;GitHub 侧异步复制)
823
+ const fork = await this.rest.ensureFork(MARKET_UPSTREAM_OWNER, MARKET_UPSTREAM_REPO);
824
+ // ⑧ 官方 index:读最新 → 改 → 写 fork 分支 + force push
825
+ const officialIndex = await this.readOfficialIndex();
826
+ let updatedOfficial: MarketIndex;
827
+ let commitMsg: string;
828
+ if (job.action === 'delist') {
829
+ // 下架:从官方 index 移除(不依赖用户仓库条目仍存在——删除后条目已移除)
830
+ updatedOfficial = removeIndexItem(officialIndex.index, job.itemId);
831
+ commitMsg = `market: delist ${job.itemId}`;
832
+ } else {
833
+ // 收录:从用户仓库读最新条目元数据(上传已完成,条目在用户仓库)→ upsert 带 repo 引用
834
+ const { index } = await this.readUserIndex(job.login);
835
+ const item = index.items.find((it) => it.id === job.itemId);
836
+ if (item === undefined) {
837
+ throw new MyRepoError(`条目 ${job.itemId} 已不在用户仓库(可能已删除),任务终止`, 'item_not_found');
838
+ }
839
+ updatedOfficial = upsertIndexItem(officialIndex.index, { ...item, repo: job.repoUrl });
840
+ commitMsg = `market: ${item.version !== undefined && item.version !== '' ? 'update' : 'add'} ${job.itemId} v${job.version}`;
841
+ }
842
+ const branch = job.action === 'delist' ? delistBranchFor(job.itemId) : prBranchFor(job.itemId);
843
+ await this.gitWriter.writeFiles({
844
+ repoUrl: fork.cloneUrl,
845
+ workDir: this.forkWorkDir(job.login, job.itemId),
846
+ upstreamUrl: `https://github.com/${MARKET_UPSTREAM_OWNER}/${MARKET_UPSTREAM_REPO}.git`,
847
+ branch,
848
+ baseRef: 'upstream/main',
849
+ force: true,
850
+ entries: [{ path: 'index.json', content: stringifyJsonSafe(updatedOfficial, { space: 2 }) }],
851
+ commitMessage: commitMsg,
852
+ });
853
+ // ⑨ PR:查 open PR(head=<login>:<branch>)复用;无则创建
854
+ const pr = await this.ensurePullRequest(job.login, branch, job.itemId, job.version, job.action);
855
+ job.status = 'done';
856
+ job.prNumber = pr.number;
857
+ job.prUrl = pr.htmlUrl;
858
+ job.finishedAt = Date.now();
859
+ }
860
+
861
+ /** job → ListingStatusResponse(错误文本 redact 兜底) */
862
+ private toListingStatus(job: ListingJob): ListingStatusResponse {
863
+ return {
864
+ itemId: job.itemId,
865
+ listing: job.status,
866
+ prNumber: job.prNumber,
867
+ prUrl: job.prUrl,
868
+ ...(job.status === 'failed' ? { error: redact(job.error ?? '') } : {}),
869
+ };
870
+ }
871
+
872
+ /** 同一 login 的「写用户仓库」串行化(user-<login> 工作副本共享;上传/删除并发防踩踏)。
873
+ * 前一个操作失败不阻塞队列:catch 吞掉错误只作为队列锚点,真实错误已由调用方处理。 */
874
+ private async withLoginLock<T>(login: string, fn: () => Promise<T>): Promise<T> {
875
+ const prev = this.loginWriteQueues.get(login) ?? Promise.resolve();
876
+ const run = prev.then(fn, fn);
877
+ this.loginWriteQueues.set(login, run.then(() => undefined, () => undefined));
878
+ return run;
879
+ }
880
+
509
881
  private userWorkDir(login: string): string {
510
882
  return join(this.workDirRoot, `user-${login}`);
511
883
  }
512
884
 
513
- private forkWorkDir(login: string): string {
514
- return join(this.workDirRoot, `fork-${login}`);
885
+ /** fork 工作副本按 itemId 隔离(fork-<login>/<itemId>):不同条目的后台收录/下架并发操作互不踩踏 */
886
+ private forkWorkDir(login: string, itemId: string): string {
887
+ return join(this.workDirRoot, `fork-${login}`, itemId);
515
888
  }
516
889
  }
@@ -111,7 +111,7 @@ test('prepare:zip 含 self 分区拒绝(本地环境专属,禁止进入市
111
111
 
112
112
  test('prepare:内容级秘密扫描拒绝(providers 分区含 apiKey 值,即使 containsSecrets=false)', () => {
113
113
  const settingsJson = JSON.stringify({ version: 1, namespaces: {} }, null, 2)
114
- const providersJson = JSON.stringify({ version: 1, providers: { deepseek: { apiKey: 'sk-test1234567890abcdef' } } }, null, 2)
114
+ const providersJson = JSON.stringify({ version: 1, providers: { deepseek: { apiKey: 'sk-proj-9f8e7d6c5b4a3210abcdef' } } }, null, 2)
115
115
  const entries: ZipWriteEntry[] = [
116
116
  { name: 'config/settings.json', data: Buffer.from(settingsJson) },
117
117
  { name: 'ai/providers.json', data: Buffer.from(providersJson) },
@@ -133,6 +133,76 @@ test('prepare:内容级秘密扫描拒绝(providers 分区含 apiKey 值,
133
133
  )
134
134
  })
135
135
 
136
+ test('prepare:插件/MCP 配置中的占位/模板引用/示例不误报(2026-08-21 优化)', () => {
137
+ const settingsJson = JSON.stringify({ version: 1, namespaces: {} }, null, 2)
138
+ // plugins 分区:插件配置里常见的「环境变量模板引用 + 占位头」形态(真实结构为数组)
139
+ const pluginsJson = JSON.stringify({
140
+ version: 1,
141
+ plugins: [
142
+ {
143
+ name: 'mcp-server',
144
+ enabled: true,
145
+ config: {
146
+ headers: { Authorization: 'Bearer <token>' },
147
+ apiKey: '${OPENAI_API_KEY}',
148
+ max_tokens: 4096,
149
+ baseUrl: 'https://api.example.com',
150
+ },
151
+ },
152
+ ],
153
+ }, null, 2)
154
+ const entries: ZipWriteEntry[] = [
155
+ { name: 'config/settings.json', data: Buffer.from(settingsJson) },
156
+ { name: 'plugins/plugins.json', data: Buffer.from(pluginsJson) },
157
+ ]
158
+ const manifest = {
159
+ schemaVersion: 1,
160
+ exporter: { name: 'DSH Config Manager', version: 'test' },
161
+ source: { dshVersion: '1.0.0', platform: 'linux', arch: 'x64' },
162
+ exportedAt: new Date().toISOString(),
163
+ sections: { settings: true, plugins: true },
164
+ security: { containsSecrets: false, encrypted: false, encryption: null },
165
+ }
166
+ entries.push({ name: 'manifest.json', data: Buffer.from(JSON.stringify(manifest)) })
167
+ const zip = Buffer.from(zipToBuffer(entries))
168
+ const res = prepareMarketItem({ itemId: 'mcp', name: 'MCP', zipBytes: zip, now: FIXED_NOW })
169
+ const manifestOut = JSON.parse(res.manifestText) as { sections: string[] }
170
+ assert.deepEqual(manifestOut.sections, ['settings', 'plugins'])
171
+ })
172
+
173
+ test('prepare:skill 文档中的示例代码/占位符不误报;真实密钥仍拦截', () => {
174
+ const settingsJson = JSON.stringify({ version: 1, namespaces: {} }, null, 2)
175
+ // skills 分区(文件类,scanText):示例文档 + 一个真实密钥文件
176
+ const skillDoc = [
177
+ '# 使用示例',
178
+ 'token: ${API_TOKEN}',
179
+ 'password: your-token-here',
180
+ 'api_key: sk-your-api-key-here',
181
+ 'Authorization: Bearer example-token-here',
182
+ ].join('\n')
183
+ const realDoc = 'client_secret: s3cretP@ssw0rdXyZ2024'
184
+ const entries: ZipWriteEntry[] = [
185
+ { name: 'config/settings.json', data: Buffer.from(settingsJson) },
186
+ { name: 'custom/skills/example.md', data: Buffer.from(skillDoc) },
187
+ { name: 'custom/skills/real.md', data: Buffer.from(realDoc) },
188
+ ]
189
+ const manifest = {
190
+ schemaVersion: 1,
191
+ exporter: { name: 'DSH Config Manager', version: 'test' },
192
+ source: { dshVersion: '1.0.0', platform: 'linux', arch: 'x64' },
193
+ exportedAt: new Date().toISOString(),
194
+ sections: { settings: true, skills: true },
195
+ security: { containsSecrets: false, encrypted: false, encryption: null },
196
+ }
197
+ entries.push({ name: 'manifest.json', data: Buffer.from(JSON.stringify(manifest)) })
198
+ const zip = Buffer.from(zipToBuffer(entries))
199
+ // 示例文档放行,但真实密钥文件仍触发拦截(命中报告含 skills 前缀)
200
+ assert.throws(
201
+ () => prepareMarketItem({ itemId: 'sk', name: 'SK', zipBytes: zip }),
202
+ (err: unknown) => err instanceof MarketPrepareError && /疑似敏感内容.*skills/.test(err.message),
203
+ )
204
+ })
205
+
136
206
  test('prepare:内容级扫描豁免 env 引用名(apiKeyEnv=DEEPSEEK_API_KEY 不误报)', () => {
137
207
  const settingsJson = JSON.stringify({ version: 1, namespaces: {} }, null, 2)
138
208
  const providersJson = JSON.stringify({
@@ -125,7 +125,8 @@ export function prepareMarketItem(input: MarketPrepareInput): MarketPrepareResul
125
125
  )
126
126
  }
127
127
  // 6c. 内容级秘密扫描(纵深防御,不依赖导出 containsSecrets 标记):
128
- // JSON 分区走 scanAndRedact(字段名+值形状,空值/env 引用名豁免,低误报);
128
+ // JSON 分区走 scanAndRedact 宽松档(literalValueOnly:占位符/模板引用/代码表达式/短标识符
129
+ // 放行,只有值像真实字面量凭据才拦截——消除 `"token": "${ENV}"`、`Bearer <token>` 等误报);
129
130
  // 文件类分区(skills/agentPresets/agentInstructions)走 scanText(只报告不改写)。
130
131
  const scanHits: string[] = []
131
132
  for (const sid of sections) {
@@ -150,7 +151,7 @@ export function prepareMarketItem(input: MarketPrepareInput): MarketPrepareResul
150
151
  } catch {
151
152
  continue
152
153
  }
153
- const { hits } = scanAndRedact(data)
154
+ const { hits } = scanAndRedact(data, { literalValueOnly: true })
154
155
  if (hits.length > 0) scanHits.push(`${sid}: ${hits.slice(0, 5).map((h) => h.path).join(', ')}`)
155
156
  }
156
157
  }