dsh-plugin-manager-companion 0.1.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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +144 -0
  3. package/README.md +142 -0
  4. package/cordis.patch.yml +9 -0
  5. package/dist/about.d.ts +77 -0
  6. package/dist/about.js +179 -0
  7. package/dist/cli.d.ts +226 -0
  8. package/dist/cli.js +856 -0
  9. package/dist/client/AboutPage.d.ts +75 -0
  10. package/dist/client/ConsolePage.d.ts +79 -0
  11. package/dist/client/KindsPage.d.ts +21 -0
  12. package/dist/client/MarketplacePage.d.ts +36 -0
  13. package/dist/client/OfficialSlots.d.ts +35 -0
  14. package/dist/client/UpgradeRow.d.ts +108 -0
  15. package/dist/client/index.d.ts +26 -0
  16. package/dist/client/locales.d.ts +475 -0
  17. package/dist/client/pmSelect.d.ts +38 -0
  18. package/dist/client/shared.d.ts +928 -0
  19. package/dist/client/upgradeView.d.ts +278 -0
  20. package/dist/client/wire.d.ts +401 -0
  21. package/dist/client.js +9194 -0
  22. package/dist/diagnostics.d.ts +332 -0
  23. package/dist/diagnostics.js +2631 -0
  24. package/dist/envManager.d.ts +1047 -0
  25. package/dist/envManager.js +3214 -0
  26. package/dist/fix.d.ts +60 -0
  27. package/dist/fix.js +168 -0
  28. package/dist/guard.d.ts +133 -0
  29. package/dist/guard.js +232 -0
  30. package/dist/index.d.ts +121 -0
  31. package/dist/index.js +1150 -0
  32. package/dist/installSession.d.ts +111 -0
  33. package/dist/installSession.js +150 -0
  34. package/dist/kinds.d.ts +464 -0
  35. package/dist/kinds.js +1029 -0
  36. package/dist/marketView.d.ts +261 -0
  37. package/dist/marketView.js +406 -0
  38. package/dist/marketplace.d.ts +248 -0
  39. package/dist/marketplace.js +500 -0
  40. package/dist/match.d.ts +67 -0
  41. package/dist/match.js +203 -0
  42. package/dist/net.d.ts +108 -0
  43. package/dist/net.js +163 -0
  44. package/dist/official.d.ts +145 -0
  45. package/dist/official.js +205 -0
  46. package/dist/paths.d.ts +108 -0
  47. package/dist/paths.js +236 -0
  48. package/dist/presets.d.ts +299 -0
  49. package/dist/presets.js +578 -0
  50. package/dist/qualityGate.d.ts +66 -0
  51. package/dist/qualityGate.js +247 -0
  52. package/dist/rank.d.ts +88 -0
  53. package/dist/rank.js +164 -0
  54. package/dist/registry.d.ts +295 -0
  55. package/dist/registry.js +686 -0
  56. package/dist/rest.d.ts +122 -0
  57. package/dist/rest.js +219 -0
  58. package/dist/scan.d.ts +134 -0
  59. package/dist/scan.js +396 -0
  60. package/dist/settings.d.ts +447 -0
  61. package/dist/settings.js +263 -0
  62. package/dist/tags.d.ts +119 -0
  63. package/dist/tags.js +166 -0
  64. package/dist/tools.d.ts +131 -0
  65. package/dist/tools.js +377 -0
  66. package/dist/types.d.ts +651 -0
  67. package/dist/types.js +13 -0
  68. package/dist/upgrade.d.ts +428 -0
  69. package/dist/upgrade.js +1100 -0
  70. package/dist/upgradeView.d.ts +313 -0
  71. package/dist/upgradeView.js +273 -0
  72. package/docs/images/readme/01-console-health.png +0 -0
  73. package/docs/images/readme/02-console-envs.png +0 -0
  74. package/docs/images/readme/03-marketplace.png +0 -0
  75. package/docs/images/readme/04-official-plugin-page.png +0 -0
  76. package/package.json +104 -0
@@ -0,0 +1,686 @@
1
+ /**
2
+ * registry.ts — 社区索引(topic:dsh-plugin 全量表)的多源兜底链与磁盘缓存。
3
+ *
4
+ * 归属:A 类·重写(旧 src/registry.ts 仅作意图参考,未复制代码)。
5
+ * 旧实现参考:dsh-web-plugin-manager/src/registry.ts(理解意图用,未复制代码)——它解决的问题是:
6
+ * 社区索引(registry.json,CI 每两小时重建)能让市场拿到几千条条目而**完全不碰 GitHub API 配额**;
7
+ * CDN 会滞后所以需要新鲜度判断;单一来源不可靠所以需要多源兜底;全部失败时要有磁盘缓存兜底。
8
+ * 官方复用:无。官方没有市场概念,也没有索引/代理/缓存能力(见 DESIGN.md §1 的分工表)。
9
+ * 前提检查:旧实现的前提仍然成立(社区索引依旧是唯一零配额的全量来源),但它踩过的四个坑必须避开,
10
+ * 旧审计 docs/private/audit/correctness.md 有记录:
11
+ * 1) M14:GitHub api/raw 的响应**没有 generated_at**,旧实现因此只允许 CDN 结果落盘——一旦 CDN 不可达,
12
+ * 缓存永远不会被写入。本实现改成"**只允许更新,不允许回退**":候选索引的 generated_at 必须不旧于
13
+ * 已缓存的那个,才允许落盘(对任何来源都成立,比"按来源白名单"更精确)。
14
+ * 2) M15:五跳串行 × 单请求 15s 可以挂住 75s。本实现给整条链一个总预算(默认 60s)并**真正中止**
15
+ * 在途请求(AbortController),而不是只把 Promise.race 掉、让请求继续在后台跑。
16
+ * 3) 缓存写入用 pretty-print 的 JSON,3115 条时 1.93MB / 5.4ms(旧 perf 审计 §1.8)。这里一律紧凑输出。
17
+ * 4) 索引内容没有做"条目级"校验——非对象、缺 repo 名的条目会被当成合法条目流入下游。这里逐条严格归一化,
18
+ * 并如实报告丢掉了多少条(skipped),不静默。
19
+ *
20
+ * 数据源与旧仓库一致(**不扩数据源**:不做 catalog 目录抓取、不引入 dsh.so 覆盖层)。
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+ import { gunzipSync } from 'node:zlib';
25
+ import { dshHome } from "./paths.js";
26
+ /** 社区索引仓库。只读消费,永远是硬编码兜底,不是依赖。 */
27
+ export const REGISTRY_OWNER = 'bradeGithub';
28
+ export const REGISTRY_REPO = 'DSH-Plugins-Marketplace';
29
+ /** 索引文件名(仓库根目录下由 CI 生成)。 */
30
+ export const REGISTRY_FILE = 'registry.json';
31
+ /** 索引缓存的默认有效期(分钟级配置见 settings.marketplace.cacheTtlMinutes)。 */
32
+ export const REGISTRY_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
33
+ /** CDN 会滞后:标记为 requireFresh 的来源,其索引超过这个年龄就跳到下一跳。 */
34
+ export const CDN_MAX_INDEX_AGE_MS = 6 * 60 * 60 * 1000;
35
+ /** 整条兜底链的总预算:单请求超时由调用方给,总预算在这里封顶。 */
36
+ export const REGISTRY_TOTAL_BUDGET_MS = 60_000;
37
+ /** 官方自己的仓库不是插件,永远不进市场。 */
38
+ const EXCLUDED_REPOS = new Set(['deepseek-harness']);
39
+ /**
40
+ * 生态泛化主题词:它们是"生态标签"而不是功能信号,会挤掉卡片上有信息量的 topic。
41
+ *
42
+ * 这是一份**手写的小表**(不是从旧仓库或上游复制的 100+ 词表):只保留最容易泛滥的
43
+ * 十几个,宁可少过滤也不误杀有意义的主题。要扩充时按同一标准加("这个词能否区分两个插件")。
44
+ */
45
+ export const ECO_GENERIC_TOPICS = new Set([
46
+ 'ai', 'llm', 'agent', 'agents', 'cli', 'cordis', 'cordis-plugin', 'deepseek', 'deepseek-harness',
47
+ 'dsh', 'dsh-plugin', 'dsh-plugins', 'gui', 'javascript', 'plugin', 'plugins', 'python', 'react',
48
+ 'skill', 'skills', 'tool', 'tools', 'tui', 'typescript', 'ui', 'web', 'web-ui',
49
+ ]);
50
+ /** 一个主题列表 → 去掉生态泛化词后的功能主题(最多 8 个,卡片上只显示 2 个)。 */
51
+ export function functionalTopics(topics) {
52
+ if (topics === undefined || topics.length === 0)
53
+ return [];
54
+ const out = [];
55
+ for (const topic of topics) {
56
+ const value = String(topic).trim().toLowerCase();
57
+ if (value.length === 0 || ECO_GENERIC_TOPICS.has(value))
58
+ continue;
59
+ out.push(value);
60
+ if (out.length >= 8)
61
+ break;
62
+ }
63
+ return out;
64
+ }
65
+ const MARKET_ITEM_KINDS = ['cordis-plugin', 'skill', 'agent-preset', 'unknown'];
66
+ /** 取字符串字段;空白串视为缺失。 */
67
+ function str(value) {
68
+ if (typeof value !== 'string')
69
+ return undefined;
70
+ const trimmed = value.trim();
71
+ return trimmed.length === 0 ? undefined : trimmed;
72
+ }
73
+ /**
74
+ * 归一化一条原始索引条目;不可用时返回 null。
75
+ *
76
+ * 严格性是有意的:索引是外部 JSON,字段缺失/类型漂移必须在这里被挡住,
77
+ * 否则下游的类型假设会变成运行时 TypeError(旧仓库在卡片渲染里就被 topic 非字符串炸过)。
78
+ *
79
+ * @param raw - 索引里的一条记录(registry.json 或 search API 的形状都兼容)。
80
+ * @returns 归一化条目;非对象、无 repo 名、或被排除的仓库返回 null。
81
+ */
82
+ export function normalizeRegistryRepo(raw) {
83
+ if (raw === null || typeof raw !== 'object')
84
+ return null;
85
+ const record = raw;
86
+ const fullName = str(record['full_name']) ?? str(record['repo']);
87
+ if (fullName === undefined || !/^[^/\s]+\/[^/\s]+$/.test(fullName))
88
+ return null;
89
+ const name = str(record['name']) ?? fullName.slice(fullName.lastIndexOf('/') + 1);
90
+ if (EXCLUDED_REPOS.has(name.toLowerCase()))
91
+ return null;
92
+ const topics = Array.isArray(record['topics'])
93
+ ? record['topics'].filter((topic) => typeof topic === 'string')
94
+ : [];
95
+ const stars = typeof record['stargazers_count'] === 'number' && Number.isFinite(record['stargazers_count'])
96
+ ? record['stargazers_count']
97
+ : null;
98
+ const kind = MARKET_ITEM_KINDS.includes(record['kind'])
99
+ ? record['kind']
100
+ : undefined;
101
+ const upstream = normalizeUpstreamFields(record);
102
+ const category = str(record['category']);
103
+ const packageName = str(record['pkg_name']) ?? str(record['package_name']);
104
+ const latestVersion = str(record['version']);
105
+ return {
106
+ repo: fullName,
107
+ name,
108
+ description: str(record['description']) ?? '',
109
+ stars,
110
+ updatedAt: str(record['updated_at']) ?? null,
111
+ topics,
112
+ ...(category === undefined ? {} : { category }),
113
+ ...(packageName === undefined ? {} : { packageName }),
114
+ ...(latestVersion === undefined ? {} : { latestVersion }),
115
+ ...(kind === undefined ? {} : { kind }),
116
+ ...upstream,
117
+ };
118
+ }
119
+ /** 上游风险明细的透传上限:多到够说明问题,又不至于把 13,998 条撑大。 */
120
+ const RISK_FLAG_LIMIT = 12;
121
+ /** 收录标记的透传上限(上游目前最多 2 个)。 */
122
+ const MARKET_TAG_LIMIT = 8;
123
+ /**
124
+ * 解析上游元数据(task-46 新增)。
125
+ *
126
+ * 入参是**上游字段名**的对象:网络路径直接传原始记录,缓存路径把自有记录形映射成同名再传进来
127
+ * (见 {@link normalizeCachedRepo})——这样"值域怎么校验、截断几条、外链认什么协议"只有一处实现。
128
+ *
129
+ * 三条边界写在这里而不是散在各处:
130
+ * 1. **原样透传,不改写**:installable / risk_tier / market_tags 的值域原样保留,host 不做任何取舍;
131
+ * 2. **证据簇只在 verdict=pass 时透传**:reportUrl / verifiedBy / verifiedAt 单看没有意义,
132
+ * 与"这条被独立验证过"这个结论绑定;没有 verdict 时它们一律丢弃,避免客户端把孤立的链接当结论;
133
+ * 3. **外链只认 https**:reportUrl 会被渲染成 href,必须挡住 javascript: 之类的取巧值。
134
+ *
135
+ * @param record - 原始条目。
136
+ * @returns 可直接展开进 RegistryRepo 的字段。
137
+ */
138
+ function normalizeUpstreamFields(record) {
139
+ const installableValue = str(record['installable']);
140
+ const riskTierValue = str(record['risk_tier']);
141
+ const riskFlags = Array.isArray(record['risk_flags'])
142
+ ? record['risk_flags']
143
+ .map((raw) => {
144
+ if (raw === null || typeof raw !== 'object')
145
+ return null;
146
+ const flag = raw;
147
+ const id = str(flag['id']);
148
+ if (id === undefined)
149
+ return null;
150
+ return { id, severity: str(flag['severity']) ?? '', category: str(flag['category']) ?? '' };
151
+ })
152
+ .filter((flag) => flag !== null)
153
+ .slice(0, RISK_FLAG_LIMIT)
154
+ : [];
155
+ const marketTags = Array.isArray(record['market_tags'])
156
+ ? record['market_tags'].map((tag) => String(tag).trim()).filter((tag) => tag.length > 0).slice(0, MARKET_TAG_LIMIT)
157
+ : [];
158
+ const verdictPass = str(record['verdict']) === 'pass';
159
+ const reportUrlRaw = str(record['reportUrl']);
160
+ const reportUrl = verdictPass && reportUrlRaw !== undefined && reportUrlRaw.startsWith('https://') ? reportUrlRaw : undefined;
161
+ const starsDelta7d = typeof record['stars_delta_7d'] === 'number' && Number.isFinite(record['stars_delta_7d'])
162
+ ? record['stars_delta_7d']
163
+ : undefined;
164
+ return {
165
+ ...(installableValue === 'manual' || installableValue === 'non-plugin' ? { installable: installableValue } : {}),
166
+ ...(riskTierValue === 'safe' || riskTierValue === 'caution' || riskTierValue === 'risk' ? { riskTier: riskTierValue } : {}),
167
+ ...(riskFlags.length === 0 ? {} : { riskFlags }),
168
+ ...(reportUrl === undefined ? {} : { reportUrl }),
169
+ ...(marketTags.length === 0 ? {} : { marketTags }),
170
+ ...(record['archived'] === true ? { archived: true } : {}),
171
+ ...(starsDelta7d === undefined ? {} : { starsDelta7d }),
172
+ ...(str(record['license']) === undefined ? {} : { license: str(record['license']) }),
173
+ ...(verdictPass && str(record['verifiedBy']) !== undefined ? { verifiedBy: str(record['verifiedBy']) } : {}),
174
+ ...(verdictPass && str(record['verifiedAt']) !== undefined ? { verifiedAt: str(record['verifiedAt']) } : {}),
175
+ };
176
+ }
177
+ /**
178
+ * 解析一份索引载荷(registry.json 的 `{generated_at, repos}`、search API 的 `{items}`、或裸数组)。
179
+ *
180
+ * 按 repo 名去重(保留先出现的那条:索引自己已排好序,先出现的是上游的取舍)。
181
+ *
182
+ * @param payload - 已 JSON.parse 的载荷。
183
+ * @returns 解析结果;没有任何可用条目时返回 null(调用方据此跳到下一跳)。
184
+ */
185
+ export function parseRegistryPayload(payload) {
186
+ let list;
187
+ let generatedAt = null;
188
+ if (Array.isArray(payload)) {
189
+ list = payload;
190
+ }
191
+ else if (payload !== null && typeof payload === 'object') {
192
+ const record = payload;
193
+ const candidate = record['repos'] ?? record['items'] ?? record['plugins'];
194
+ if (!Array.isArray(candidate))
195
+ return null;
196
+ list = candidate;
197
+ generatedAt = str(record['generated_at']) ?? str(record['generatedAt']) ?? null;
198
+ }
199
+ else {
200
+ return null;
201
+ }
202
+ const seen = new Set();
203
+ const repos = [];
204
+ let skipped = 0;
205
+ for (const entry of list) {
206
+ const repo = normalizeRegistryRepo(entry);
207
+ if (repo === null) {
208
+ skipped += 1;
209
+ continue;
210
+ }
211
+ const key = repo.repo.toLowerCase();
212
+ if (seen.has(key))
213
+ continue;
214
+ seen.add(key);
215
+ repos.push(repo);
216
+ }
217
+ return repos.length === 0 ? null : { repos, generatedAt, skipped };
218
+ }
219
+ /**
220
+ * 兜底链的跳数顺序:api.github → jsDelivr(.gz) → raw(.gz) → jsDelivr → raw。
221
+ *
222
+ * 顺序的由来:api.github.com 带 token 时最可靠且能读到刚 push 的文件(CDN 会滞后),
223
+ * 但无 token 时配额只有 60/h,所以 CDN 的 .gz(体积最小)紧跟其后;raw 是最不依赖 CDN 的一跳。
224
+ *
225
+ * settings.marketplace.indexUrl 非空时作为**首选**跳插入队首,但内置链仍然保留:
226
+ * 用户填错地址不该让整个市场变成空的(配置是"优选"而不是"唯一",这一点在配置项注释里也写了)。
227
+ *
228
+ * @param options - indexUrl(用户自定义源)与 branch(fork/镜像用)。
229
+ * @returns 按尝试顺序排列的来源列表。
230
+ */
231
+ export function registryIndexSources(options = {}) {
232
+ const branch = options.branch ?? 'main';
233
+ const base = `${REGISTRY_OWNER}/${REGISTRY_REPO}`;
234
+ const sources = [];
235
+ const custom = options.indexUrl?.trim();
236
+ if (custom !== undefined && custom.length > 0) {
237
+ sources.push({ id: 'custom', url: custom, gzip: custom.endsWith('.gz'), token: false, requireFresh: false });
238
+ }
239
+ sources.push({ id: 'api', url: `https://api.github.com/repos/${base}/contents/${REGISTRY_FILE}.gz`, gzip: true, token: true, requireFresh: false }, { id: 'jsdelivr-gz', url: `https://cdn.jsdelivr.net/gh/${base}@${branch}/${REGISTRY_FILE}.gz`, gzip: true, token: false, requireFresh: true }, { id: 'raw-gz', url: `https://raw.githubusercontent.com/${base}/${branch}/${REGISTRY_FILE}.gz`, gzip: true, token: false, requireFresh: false }, { id: 'jsdelivr', url: `https://cdn.jsdelivr.net/gh/${base}@${branch}/${REGISTRY_FILE}`, gzip: false, token: false, requireFresh: true }, { id: 'raw', url: `https://raw.githubusercontent.com/${base}/${branch}/${REGISTRY_FILE}`, gzip: false, token: false, requireFresh: false });
240
+ return sources;
241
+ }
242
+ /**
243
+ * 磁盘缓存的落盘格式版本。
244
+ *
245
+ * 为什么必须显式带版本:缓存里存的是**我们归一化后的记录形**(stars / updatedAt / riskTier …),
246
+ * 与上游 registry.json 的 snake_case 完全不同。旧版本(无该字段)的读法把缓存记录又喂给上游解析器,
247
+ * 于是 11 个字段静默丢失(stars 变 null、riskTier/marketTags/starsDelta7d 等整块消失)——
248
+ * 表现为"缓存命中的那次加载功能少一半,冷抓取却正常"。
249
+ *
250
+ * 版本不符按**无缓存**处理:宁可重新抓一次,也不读出一份被削过的数据。
251
+ */
252
+ export const REGISTRY_CACHE_FORMAT = 2;
253
+ /** 索引磁盘缓存路径(本插件自己的缓存目录,不与旧包共用)。 */
254
+ export function registryCachePath() {
255
+ return join(dshHome(), 'plugin-manager-companion', 'registry-index.json');
256
+ }
257
+ /**
258
+ * 解析一条**已归一化**的缓存记录(我们自己的记录形)。
259
+ *
260
+ * 与 {@link normalizeRegistryRepo} 的唯一区别是字段名:那个读上游的 snake_case
261
+ * (stargazers_count / updated_at / risk_tier …),这个读我们自己写出去的名字
262
+ * (stars / updatedAt / riskTier …)。**两者不能互相复用**——这正是本轮缺陷的成因。
263
+ *
264
+ * 校验仍然严格:缓存是外部文件(用户可能手改、磁盘可能截断),形状对不上的条目
265
+ * 一律丢弃并计数,绝不产出一条"看着像记录、其实缺了半数字段"的对象。
266
+ *
267
+ * @param raw - 缓存文件里的一条记录。
268
+ * @returns 归一化条目;形状不可用时 null。
269
+ */
270
+ export function normalizeCachedRepo(raw) {
271
+ if (raw === null || typeof raw !== 'object')
272
+ return null;
273
+ const record = raw;
274
+ const repoName = str(record['repo']);
275
+ if (repoName === undefined || !/^[^/\s]+\/[^/\s]+$/.test(repoName))
276
+ return null;
277
+ const name = str(record['name']) ?? repoName.slice(repoName.lastIndexOf('/') + 1);
278
+ const topics = Array.isArray(record['topics'])
279
+ ? record['topics'].filter((topic) => typeof topic === 'string')
280
+ : [];
281
+ const stars = typeof record['stars'] === 'number' && Number.isFinite(record['stars']) ? record['stars'] : null;
282
+ const updatedAt = str(record['updatedAt']) ?? null;
283
+ const kind = MARKET_ITEM_KINDS.includes(record['kind']) ? record['kind'] : undefined;
284
+ const category = str(record['category']);
285
+ const packageName = str(record['packageName']);
286
+ const latestVersion = str(record['latestVersion']);
287
+ const license = str(record['license']);
288
+ // 上游元数据走与上游解析同一套校验(值域、截断、外链只认 https),只是字段名不同。
289
+ const upstream = normalizeUpstreamFields({
290
+ installable: record['installable'],
291
+ risk_tier: record['riskTier'],
292
+ risk_flags: record['riskFlags'],
293
+ reportUrl: record['reportUrl'],
294
+ market_tags: record['marketTags'],
295
+ archived: record['archived'],
296
+ stars_delta_7d: record['starsDelta7d'],
297
+ verdict: record['verifiedBy'] === undefined && record['verifiedAt'] === undefined ? undefined : 'pass',
298
+ verifiedBy: record['verifiedBy'],
299
+ verifiedAt: record['verifiedAt'],
300
+ });
301
+ return {
302
+ repo: repoName,
303
+ name,
304
+ description: str(record['description']) ?? '',
305
+ stars,
306
+ updatedAt,
307
+ topics,
308
+ ...(category === undefined ? {} : { category }),
309
+ ...(packageName === undefined ? {} : { packageName }),
310
+ ...(latestVersion === undefined ? {} : { latestVersion }),
311
+ ...(kind === undefined ? {} : { kind }),
312
+ ...upstream,
313
+ ...(license === undefined ? {} : { license }),
314
+ };
315
+ }
316
+ /**
317
+ * 缓存里有记录被丢弃时的说明;没有丢弃就返回空数组。
318
+ *
319
+ * 为什么要如实说:静默丢弃会重演本轮缺陷的另一半——数据看着"读到了",其实少了一截。
320
+ *
321
+ * @param skipped - {@link readRegistryCacheFile} 报出的丢弃条数。
322
+ * @returns 一条说明(或空)。
323
+ */
324
+ function cacheShapeNote(skipped) {
325
+ return skipped === 0 ? [] : ['磁盘缓存有 ' + String(skipped) + ' 条记录形状不符,已丢弃(将重新抓取)'];
326
+ }
327
+ /**
328
+ * 解析缓存文件里的记录列表(我们自己的记录形):严格校验 + 按 repo 去重。
329
+ *
330
+ * @param list - 缓存文件里的 repos 数组。
331
+ * @returns 记录与丢弃计数;没有任何可用记录时 null。
332
+ */
333
+ export function parseCachedRepos(list) {
334
+ if (!Array.isArray(list))
335
+ return null;
336
+ const seen = new Set();
337
+ const repos = [];
338
+ let skipped = 0;
339
+ for (const entry of list) {
340
+ const repo = normalizeCachedRepo(entry);
341
+ if (repo === null) {
342
+ skipped += 1;
343
+ continue;
344
+ }
345
+ const key = repo.repo.toLowerCase();
346
+ if (seen.has(key))
347
+ continue;
348
+ seen.add(key);
349
+ repos.push(repo);
350
+ }
351
+ return repos.length === 0 ? null : { repos, skipped };
352
+ }
353
+ /**
354
+ * 读取磁盘缓存;不存在/损坏/格式版本不符/形状不对时返回 null。
355
+ *
356
+ * 三条边界:
357
+ * 1. **不抛错**:缓存是可重建的派生物,抛错会把一次网络抖动升级成市场页整页失败;
358
+ * 2. **格式版本不符即视为无缓存**(旧文件按新读法读会得到一份被削过的数据,宁可重抓);
359
+ * 3. **按我们自己的记录形解析**({@link parseCachedRepos}),不再复用上游解析器——
360
+ * 复用会让 11 个字段在往返里静默消失(本轮 task-69 的根因)。
361
+ */
362
+ export function readRegistryCacheFile() {
363
+ const path = registryCachePath();
364
+ if (!existsSync(path))
365
+ return null;
366
+ try {
367
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
368
+ if (raw['formatVersion'] !== REGISTRY_CACHE_FORMAT)
369
+ return null;
370
+ const parsed = parseCachedRepos(raw['repos']);
371
+ if (parsed === null)
372
+ return null;
373
+ const savedAt = typeof raw['savedAt'] === 'number' && Number.isFinite(raw['savedAt']) ? raw['savedAt'] : null;
374
+ if (savedAt === null)
375
+ return null;
376
+ return {
377
+ savedAt,
378
+ generatedAt: str(raw['generatedAt']) ?? null,
379
+ repos: parsed.repos,
380
+ skipped: parsed.skipped,
381
+ };
382
+ }
383
+ catch {
384
+ return null;
385
+ }
386
+ }
387
+ /**
388
+ * 写入磁盘缓存(紧凑 JSON)。
389
+ *
390
+ * 写失败**不抛**:缓存是派生物,磁盘满/无权限不该让一次成功的抓取变成失败。
391
+ *
392
+ * @returns 是否真的写成功了(调用方据此在 notes 里如实记账)。
393
+ */
394
+ export function writeRegistryCacheFile(entry) {
395
+ try {
396
+ const path = registryCachePath();
397
+ mkdirSync(dirname(path), { recursive: true });
398
+ writeFileSync(path, JSON.stringify({
399
+ // formatVersion 必须与读侧一致:它声明"这份文件里存的是我们归一化后的记录形"。
400
+ formatVersion: REGISTRY_CACHE_FORMAT,
401
+ savedAt: entry.savedAt,
402
+ generatedAt: entry.generatedAt,
403
+ repos: entry.repos,
404
+ }) + '\n');
405
+ return true;
406
+ }
407
+ catch {
408
+ return false;
409
+ }
410
+ }
411
+ /**
412
+ * 缓存是否新鲜(纯函数,TTL 语义的唯一实现处)。
413
+ *
414
+ * 两条都要满足:**文件年龄**在 TTL 内(savedAt),且**内容年龄**(generatedAt,若可知)也在 TTL 内。
415
+ * 只看 savedAt 会让"今天才存下来的三天前索引"被当成新鲜;只看 generatedAt 则无法处理
416
+ * 载荷不带生成时间的来源(那时只能靠文件年龄)。
417
+ *
418
+ * @param entry - 缓存的 savedAt 与 generatedAt。
419
+ * @param now - 当前时刻(epoch ms),由调用方注入以便测试。
420
+ * @param ttlMs - 有效期。
421
+ */
422
+ export function isRegistryCacheFresh(entry, now, ttlMs) {
423
+ if (!Number.isFinite(entry.savedAt) || now - entry.savedAt > ttlMs)
424
+ return false;
425
+ if (entry.generatedAt === null)
426
+ return true;
427
+ const at = Date.parse(entry.generatedAt);
428
+ if (Number.isNaN(at))
429
+ return true;
430
+ return now - at <= ttlMs;
431
+ }
432
+ /**
433
+ * 候选索引是否允许覆盖已缓存的那个(M14,纯函数)。
434
+ *
435
+ * 规则只有一条:**只许更新,不许回退**。
436
+ * - 候选没有 generatedAt:无法证明它不旧,拒绝落盘(宁可下次再抓,也不拿一个无法判断年龄的
437
+ * 快照覆盖掉已知新鲜的缓存);
438
+ * - 没有缓存:允许;
439
+ * - 有缓存:候选的 generatedAt 必须 >= 缓存的那个。
440
+ */
441
+ export function shouldPersistRegistryIndex(candidateGeneratedAt, cachedGeneratedAt) {
442
+ if (candidateGeneratedAt === null)
443
+ return false;
444
+ if (cachedGeneratedAt === null)
445
+ return true;
446
+ const candidate = Date.parse(candidateGeneratedAt);
447
+ const cached = Date.parse(cachedGeneratedAt);
448
+ if (Number.isNaN(candidate) || Number.isNaN(cached))
449
+ return true;
450
+ return candidate >= cached;
451
+ }
452
+ /**
453
+ * 内容哈希(FNV-1a,32 位十六进制)。
454
+ *
455
+ * 逐段就地计算,不拼接中间大字符串。段间混入一个分隔符,避免 (`ab`,`c`) 与 (`a`,`bc`) 撞。
456
+ * 放在 registry.ts 而不是 marketplace.ts:内容身份首先是"索引内容"的概念,
457
+ * 下游(管线缓存键)只是复用同一个哈希。
458
+ */
459
+ export function hashIdentity(parts) {
460
+ let hash = 0x811c9dc5;
461
+ for (const part of parts) {
462
+ for (let index = 0; index < part.length; index += 1) {
463
+ hash ^= part.charCodeAt(index);
464
+ hash = Math.imul(hash, 0x01000193);
465
+ }
466
+ hash ^= 0x1f;
467
+ hash = Math.imul(hash, 0x01000193);
468
+ }
469
+ return (hash >>> 0).toString(16).padStart(8, '0');
470
+ }
471
+ /**
472
+ * 一份索引的**内容印章**:只覆盖下游管线结果真正依赖的字段
473
+ * (repo / name / category / kind / packageName)+ 索引生成时间。
474
+ *
475
+ * stars 与 updatedAt 故意不进印章:它们只影响展示,不影响已安装标记与分类计数,
476
+ * 为它们推进代际只会让下游缓存白白失效。
477
+ */
478
+ function contentStamp(repos, generatedAt) {
479
+ const parts = [generatedAt ?? '', String(repos.length)];
480
+ for (const repo of repos) {
481
+ parts.push(repo.repo, repo.name, repo.category ?? '', repo.kind ?? '', repo.packageName ?? '');
482
+ }
483
+ return hashIdentity(parts);
484
+ }
485
+ /** 进程内镜像(避免每次请求都读 1.5MB 的磁盘缓存)。 */
486
+ let memoryCache = null;
487
+ /**
488
+ * 内容代际:**只有内容印章变化时才 +1**。
489
+ *
490
+ * 旧审计 m-3 的教训:把"写入时刻"当代际,会让内容一模一样的重建(磁盘缓存重读、
491
+ * TTL 到期重建)也把下游管线缓存顶掉,13k 条目白跑一遍。这里按内容推进。
492
+ */
493
+ let memoryGeneration = 0;
494
+ /** 上一次发布的内容印章(决定代际是否推进)。 */
495
+ let lastStamp = null;
496
+ /** 在途的网络走链:并发刷新共享同一次抓取,避免 N 个页签打出 N 份请求。 */
497
+ let inFlight = null;
498
+ /** 当前镜像的内容代际;无镜像时为 0。 */
499
+ export function registryGeneration() {
500
+ return memoryCache?.index.generation ?? 0;
501
+ }
502
+ /** 清空进程内镜像与在途状态(测试与插件卸载用)。 */
503
+ export function resetRegistryMemory() {
504
+ memoryCache = null;
505
+ memoryGeneration = 0;
506
+ lastStamp = null;
507
+ inFlight = null;
508
+ }
509
+ /** 把一份结果放进镜像并推进代际。 */
510
+ function publish(repos, generatedAt, savedAt, notes, cached, stale, source, skipped) {
511
+ const stamp = contentStamp(repos, generatedAt);
512
+ if (stamp !== lastStamp) {
513
+ memoryGeneration += 1;
514
+ lastStamp = stamp;
515
+ }
516
+ const index = {
517
+ repos, generatedAt, savedAt, cached, stale, source, notes,
518
+ generation: memoryGeneration,
519
+ skipped,
520
+ };
521
+ memoryCache = { at: savedAt, index };
522
+ return index;
523
+ }
524
+ /** GitHub token(有就用,没有就匿名)。 */
525
+ function githubToken(env) {
526
+ const token = env['GITHUB_TOKEN'] ?? env['GH_TOKEN'];
527
+ return token === undefined || token.trim().length === 0 ? undefined : token.trim();
528
+ }
529
+ /**
530
+ * 逐跳走网络。
531
+ *
532
+ * 总预算用 AbortController **真正中止**在途请求(旧实现只 race 掉 Promise,请求还在后台跑)。
533
+ */
534
+ async function walkSources(sources, options) {
535
+ const notes = [];
536
+ const controller = new AbortController();
537
+ const timer = setTimeout(() => { controller.abort(); }, options.totalBudgetMs);
538
+ const token = githubToken(options.env);
539
+ try {
540
+ for (const source of sources) {
541
+ if (controller.signal.aborted) {
542
+ notes.push(`总预算 ${options.totalBudgetMs}ms 用尽,剩余来源已跳过`);
543
+ break;
544
+ }
545
+ try {
546
+ const headers = { 'user-agent': 'dsh-plugin-manager-companion' };
547
+ if (source.token) {
548
+ headers['accept'] = 'application/vnd.github.raw';
549
+ if (token !== undefined)
550
+ headers['authorization'] = `Bearer ${token}`;
551
+ }
552
+ const response = await options.fetcher(source.url, {
553
+ headers,
554
+ timeoutMs: options.timeoutMs,
555
+ signal: controller.signal,
556
+ });
557
+ if (!response.ok) {
558
+ notes.push(`${source.id}: HTTP ${response.status}`);
559
+ continue;
560
+ }
561
+ const buffer = Buffer.from(await response.arrayBuffer());
562
+ let text;
563
+ if (source.gzip) {
564
+ try {
565
+ text = gunzipSync(buffer).toString('utf8');
566
+ }
567
+ catch (error) {
568
+ notes.push(`${source.id}: gzip 解压失败(${error instanceof Error ? error.message : String(error)})`);
569
+ continue;
570
+ }
571
+ }
572
+ else {
573
+ text = buffer.toString('utf8');
574
+ }
575
+ let parsed;
576
+ try {
577
+ parsed = parseRegistryPayload(JSON.parse(text));
578
+ }
579
+ catch (error) {
580
+ notes.push(`${source.id}: JSON 解析失败(${error instanceof Error ? error.message : String(error)})`);
581
+ continue;
582
+ }
583
+ if (parsed === null) {
584
+ notes.push(`${source.id}: 没有可用条目`);
585
+ continue;
586
+ }
587
+ if (source.requireFresh) {
588
+ const at = parsed.generatedAt === null ? Number.NaN : Date.parse(parsed.generatedAt);
589
+ if (Number.isNaN(at) || options.now - at > CDN_MAX_INDEX_AGE_MS) {
590
+ notes.push(`${source.id}: 索引过旧(CDN 会滞后),跳到下一跳`);
591
+ continue;
592
+ }
593
+ }
594
+ if (parsed.skipped > 0)
595
+ notes.push(`${source.id}: 丢弃 ${parsed.skipped} 条不合法条目`);
596
+ return { parsed, source: `network:${source.id}`, notes };
597
+ }
598
+ catch (error) {
599
+ notes.push(`${source.id}: ${error instanceof Error ? error.message : String(error)}`);
600
+ }
601
+ }
602
+ return { parsed: null, notes };
603
+ }
604
+ finally {
605
+ clearTimeout(timer);
606
+ }
607
+ }
608
+ /**
609
+ * 加载索引:新鲜镜像 → 新鲜磁盘缓存 → 网络兜底链 → 过期磁盘缓存 → 空。
610
+ *
611
+ * 每一跳的结果都记进 `notes`:调用方(诊断页/市场页)能把"这次数据是怎么来的、
612
+ * 哪几跳失败了"如实展示给用户,而不是给一个空列表让人以为是"没有插件"。
613
+ *
614
+ * @param options - 见 {@link LoadRegistryIndexOptions}。
615
+ * @returns 索引结果;全部来源都不可用时返回空 items 的 `source: 'empty'`(**不抛错**)。
616
+ */
617
+ export async function loadRegistryIndex(options = {}) {
618
+ const now = options.now ?? Date.now();
619
+ const ttlMs = options.ttlMs ?? REGISTRY_CACHE_TTL_MS;
620
+ const refresh = options.refresh === true;
621
+ if (!refresh) {
622
+ if (memoryCache !== null && isRegistryCacheFresh(memoryCache.index, now, ttlMs))
623
+ return memoryCache.index;
624
+ const disk = readRegistryCacheFile();
625
+ if (disk !== null && isRegistryCacheFresh(disk, now, ttlMs)) {
626
+ return publish(disk.repos, disk.generatedAt, now, [
627
+ '来自磁盘缓存(未过期)',
628
+ ...cacheShapeNote(disk.skipped),
629
+ ], true, false, 'cache', 0);
630
+ }
631
+ }
632
+ const fetcher = options.fetcher ?? (await import("./net.js")).fetchWithProxy;
633
+ const walk = () => (async () => {
634
+ const sources = registryIndexSources({ ...(options.indexUrl === undefined ? {} : { indexUrl: options.indexUrl }), ...(options.branch === undefined ? {} : { branch: options.branch }) });
635
+ const result = await walkSources(sources, {
636
+ timeoutMs: options.timeoutMs ?? 15_000,
637
+ totalBudgetMs: options.totalBudgetMs ?? REGISTRY_TOTAL_BUDGET_MS,
638
+ now,
639
+ fetcher,
640
+ env: options.env ?? process.env,
641
+ });
642
+ if (result.parsed !== null) {
643
+ const cached = readRegistryCacheFile();
644
+ const persist = shouldPersistRegistryIndex(result.parsed.generatedAt, cached?.generatedAt ?? null);
645
+ if (persist) {
646
+ const ok = writeRegistryCacheFile({ savedAt: now, generatedAt: result.parsed.generatedAt, repos: result.parsed.repos });
647
+ if (!ok)
648
+ result.notes.push('磁盘缓存写入失败(不影响本次结果)');
649
+ }
650
+ else if (result.parsed.generatedAt === null) {
651
+ result.notes.push('本次索引未携带 generated_at,为不覆盖已知更新鲜的缓存而跳过落盘');
652
+ }
653
+ else {
654
+ result.notes.push('本次索引比磁盘缓存旧,跳过落盘');
655
+ }
656
+ return publish(result.parsed.repos, result.parsed.generatedAt, now, result.notes, false, false, result.source, result.parsed.skipped);
657
+ }
658
+ const stale = readRegistryCacheFile();
659
+ if (stale !== null) {
660
+ return publish(stale.repos, stale.generatedAt, now, [
661
+ ...result.notes,
662
+ '全部网络来源失败,回退到过期磁盘缓存',
663
+ ...cacheShapeNote(stale.skipped),
664
+ ], true, true, 'cache-stale', 0);
665
+ }
666
+ // cached=false 是有意的:这一支**什么都没有拿到**,说"来自缓存"是撒谎;调用方据 source='empty'
667
+ // 与 stale=true 呈现"索引不可用,可重试"(旧版本这里传 true,UI 会画出"来自缓存"的假象)。
668
+ return publish([], null, now, [...result.notes, '全部网络来源失败且无磁盘缓存'], false, true, 'empty', 0);
669
+ })();
670
+ // 在途去重:并发的刷新共享同一次走链。
671
+ if (inFlight !== null) {
672
+ const shared = await inFlight;
673
+ // 共享结果可能早于本次调用(用户点了刷新而另一次走链刚结束),此时自己再走一次。
674
+ if (shared.source !== 'cache' && shared.source !== 'cache-stale')
675
+ return shared;
676
+ }
677
+ const started = walk();
678
+ inFlight = started;
679
+ try {
680
+ return await started;
681
+ }
682
+ finally {
683
+ if (inFlight === started)
684
+ inFlight = null;
685
+ }
686
+ }