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,500 @@
1
+ /**
2
+ * marketplace.ts — 索引 × 已安装状态 → MarketItem[](服务端合并、已安装判定、分类计数、内容身份缓存)。
3
+ *
4
+ * 归属:A 类·重写(旧 src/marketplaceMerge.ts 仅作意图参考,未复制代码)。
5
+ * 官方复用:paths.ts 的 dshHome/environmentDir/readEnvironmentManifest(本模块不自造路径与 manifest 解析)。
6
+ * 旧实现参考:dsh-web-plugin-manager/src/marketplaceMerge.ts(理解意图用,未复制代码)——它解决的问题是:
7
+ * 索引只描述"世界上有哪些插件",而页面要回答"这台机器上装了什么、能不能更新",
8
+ * 于是需要一处把两侧合并、判定已安装(包名 / repository / git 源 / 目录探测四条通道),
9
+ * 并给出分类计数供筛选器渲染。
10
+ * 前提检查:旧实现的前提仍然成立,但它踩过的两个坑必须避开(docs/private/audit/correctness.md):
11
+ * 1) M-1:最终管线的缓存键**不含条目内容身份**,只用了时间戳。于是"同一时间戳 + 完全不同的条目"
12
+ * 会命中旧缓存,新抓到的结果被静默丢弃。本实现的内容身份由 **条目的内容哈希** +
13
+ * 调用方给的单调代际共同构成(见 itemsIdentity / marketplaceCacheKey),时间戳不再当身份用。
14
+ * 2) m-3:把"缓存项写入时刻"当内容代际,导致内容没变也会重跑整条管线。本实现的身份来自
15
+ * 索引内容(hash)与**已安装索引的内容身份**(InstalledIndex.identity):TTL 到期重建但内容
16
+ * 不变时,身份不变、管线不重跑。
17
+ * 与旧实现的差异(由新契约决定):不做 catalog/dsh.so 覆盖层与同包去重("不要扩数据源"),
18
+ * 不读 kinds.ts 的安装记录(那是另一个模块的资产,且本模块必须能在没有它时独立工作)。
19
+ *
20
+ * 接线方式(host 侧一行即可):
21
+ * const index = await loadRegistryIndex({ refresh })
22
+ * const result = cachedMarketplace({
23
+ * profile, items: index.repos.map(registryItem), generation: index.generation,
24
+ * installed: buildInstalledIndex(profile), generatedAt: index.generatedAt, cached: index.cached,
25
+ * })
26
+ */
27
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { dshHome, environmentDir, readEnvironmentManifest } from "./paths.js";
30
+ import { hashIdentity } from "./registry.js";
31
+ import { categoryCounts } from "./tags.js";
32
+ /** 已安装索引的进程内缓存有效期(毫秒)。 */
33
+ export const INSTALLED_INDEX_TTL_MS = 5_000;
34
+ /** 技能与预设的落地根目录(与 kinds.ts 的安装目标一致)。 */
35
+ export const SKILLS_DIR = 'skills';
36
+ export const PRESETS_DIR = '.agent-presets';
37
+ /**
38
+ * 索引条目 → 市场候选条目(纯函数)。
39
+ *
40
+ * @param repo - 归一化后的索引条目。
41
+ * @returns 候选条目;`kind` 默认 `plugin`——这个索引本身就是 topic:dsh-plugin 的全量表,
42
+ * 上游若显式给了 kind 则透传。
43
+ */
44
+ export function registryItem(repo) {
45
+ return {
46
+ repo: repo.repo,
47
+ name: repo.name,
48
+ description: repo.description,
49
+ stars: repo.stars,
50
+ updatedAt: repo.updatedAt,
51
+ topics: repo.topics,
52
+ ...(repo.category === undefined ? {} : { category: repo.category }),
53
+ ...(repo.packageName === undefined ? {} : { packageName: repo.packageName }),
54
+ ...(repo.latestVersion === undefined ? {} : { latestVersion: repo.latestVersion }),
55
+ kind: repo.kind ?? 'cordis-plugin',
56
+ // 上游元数据**原样**带过去,host 不替用户做取舍:installable=non-plugin 的条目照样进结果,
57
+ // 是否隐藏由客户端 filterInstallable 决定(政策 §3.2;registry.ts 的 upstreamFacts 有同样说明)。
58
+ ...(repo.installable === undefined ? {} : { installable: repo.installable }),
59
+ ...(repo.riskTier === undefined ? {} : { riskTier: repo.riskTier }),
60
+ ...(repo.riskFlags === undefined ? {} : { riskFlags: repo.riskFlags }),
61
+ ...(repo.reportUrl === undefined ? {} : { reportUrl: repo.reportUrl }),
62
+ ...(repo.marketTags === undefined ? {} : { marketTags: repo.marketTags }),
63
+ ...(repo.archived === undefined ? {} : { archived: repo.archived }),
64
+ ...(repo.starsDelta7d === undefined ? {} : { starsDelta7d: repo.starsDelta7d }),
65
+ ...(repo.license === undefined ? {} : { license: repo.license }),
66
+ ...(repo.verifiedBy === undefined ? {} : { verifiedBy: repo.verifiedBy }),
67
+ ...(repo.verifiedAt === undefined ? {} : { verifiedAt: repo.verifiedAt }),
68
+ };
69
+ }
70
+ /** 批量映射(唯一的"索引 → 条目"入口,避免各处自己拼对象)。 */
71
+ export function registryItems(repos) {
72
+ return repos.map(registryItem);
73
+ }
74
+ /** npm 包名形态(与官方 install-spec 的 PACKAGE_NAME 同形:小写 URL 安全段,可选 scope)。 */
75
+ const NPM_PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
76
+ /**
77
+ * 一个条目该用哪个安装 spec —— **host 侧的唯一决定点**。
78
+ *
79
+ * 为什么必须有这么一个函数:官方 `parseInstallSpec` 只接受四种形态(registry 名 / 绝对路径 /
80
+ * git URL / tarball),而市场条目的天然键是 `owner/repo`——它**不在**这四种里,官方直接判
81
+ * invalid-spec 拒绝(实测:`拒绝安装:invalid-spec —— not a package name the registry accepts`)。
82
+ * 修法不是让客户端去拼字符串(那等于把官方的 spec 规则复制一份,索引字段一变要改两处),
83
+ * 而是让 host 在这里定好,客户端原样送。
84
+ *
85
+ * 规则(顺序即优先级):
86
+ * 1. 索引给了合法的 npm 包名 → 用它。npm 通道更快、可钉版本、不必整仓克隆。
87
+ * 2. 否则 → `github:` + owner/repo。这是官方认的 git 形态,永远可用(代价是整仓克隆)。
88
+ *
89
+ * 已知缺口(明确记账,不在本函数里假装解决):这里**不做**反抢注校验——npm 上的同名包未必
90
+ * 就是该仓库发布的。真正的校验要查 registry 的 repository 字段是否指回本仓库(竞品
91
+ * dsh-plugin-mall 专门为此做了一条规则),需要额外网络往返,属后续排期(见
92
+ * docs/private/market-benchmark.md §3.6)。
93
+ *
94
+ * @param repo - owner/repo。
95
+ * @param packageName - 索引采集到的 npm 包名(可选)。
96
+ * @returns 可直接交给官方 inspect/installBundle 的 spec。
97
+ */
98
+ export function installSpecFor(repo, packageName) {
99
+ const candidate = packageName?.trim().toLowerCase();
100
+ if (candidate !== undefined && candidate.length > 0 && NPM_PACKAGE_NAME.test(candidate))
101
+ return candidate;
102
+ return 'github:' + repo;
103
+ }
104
+ /**
105
+ * 把候选条目投影回**严格的 wire 形状**。
106
+ *
107
+ * 单一出口的好处:缺失字段不会变成 undefined 键,上游 JSON 的形状漂移在这一处被收敛;
108
+ * `installSpec` 也在这里定死——客户端拿到的就是"该送什么",不需要(也不允许)自己拼。
109
+ */
110
+ export function toWireItem(item) {
111
+ return {
112
+ repo: item.repo,
113
+ name: item.name,
114
+ description: item.description,
115
+ stars: item.stars,
116
+ updatedAt: item.updatedAt,
117
+ topics: item.topics,
118
+ ...(item.category === undefined ? {} : { category: item.category }),
119
+ ...(item.installed === undefined ? {} : { installed: item.installed }),
120
+ ...(item.installedVersion === undefined ? {} : { installedVersion: item.installedVersion }),
121
+ ...(item.latestVersion === undefined ? {} : { latestVersion: item.latestVersion }),
122
+ ...(item.kind === undefined ? {} : { kind: item.kind }),
123
+ ...(item.packageName === undefined ? {} : { packageName: item.packageName }),
124
+ installSpec: installSpecFor(item.repo, item.packageName),
125
+ ...(item.installable === undefined ? {} : { installable: item.installable }),
126
+ ...(item.riskTier === undefined ? {} : { riskTier: item.riskTier }),
127
+ ...(item.riskFlags === undefined ? {} : { riskFlags: item.riskFlags }),
128
+ ...(item.reportUrl === undefined ? {} : { reportUrl: item.reportUrl }),
129
+ ...(item.marketTags === undefined ? {} : { marketTags: item.marketTags }),
130
+ ...(item.archived === undefined ? {} : { archived: item.archived }),
131
+ ...(item.starsDelta7d === undefined ? {} : { starsDelta7d: item.starsDelta7d }),
132
+ ...(item.license === undefined ? {} : { license: item.license }),
133
+ ...(item.verifiedBy === undefined ? {} : { verifiedBy: item.verifiedBy }),
134
+ ...(item.verifiedAt === undefined ? {} : { verifiedAt: item.verifiedAt }),
135
+ };
136
+ }
137
+ /**
138
+ * 解析 package.json 的 repository 字段为 `owner/repo`(小写)。
139
+ *
140
+ * 支持 npm 生态里的全部常见写法:`owner/repo` 简写、`github:owner/repo`、
141
+ * `git+https://github.com/owner/repo.git`、`git://…`、`git@github.com:owner/repo.git`、
142
+ * 以及裸 URL。**非 GitHub 主机一律返回 null**:市场索引里的 `repo` 都是 GitHub 全名,
143
+ * 拿一个 GitLab 仓库去匹配只会制造假的"已安装"。
144
+ *
145
+ * @param value - repository 字段(字符串或对象里的 url 都可,调用方负责取字符串)。
146
+ * @returns 小写 `owner/repo`;无法判定时 null。
147
+ */
148
+ export function normalizeRepoRef(value) {
149
+ if (typeof value !== 'string')
150
+ return null;
151
+ let text = value.trim();
152
+ if (text.length === 0)
153
+ return null;
154
+ text = text.replace(/^github:/i, '');
155
+ const scpLike = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(text);
156
+ if (scpLike !== null) {
157
+ if (!isGitHubHost(scpLike[1]))
158
+ return null;
159
+ text = scpLike[2];
160
+ }
161
+ else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) {
162
+ let url;
163
+ try {
164
+ url = new URL(text.replace(/^git\+/i, ''));
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ if (!isGitHubHost(url.hostname))
170
+ return null;
171
+ text = url.pathname;
172
+ }
173
+ text = text.replace(/^\/+/, '').replace(/\/+$/, '');
174
+ if (text.toLowerCase().endsWith('.git'))
175
+ text = text.slice(0, -4);
176
+ const parts = text.split('/').filter((part) => part.length > 0);
177
+ if (parts.length !== 2)
178
+ return null;
179
+ return (parts[0] + '/' + parts[1]).toLowerCase();
180
+ }
181
+ /** 是否 GitHub 主机(www. 前缀也算)。 */
182
+ function isGitHubHost(host) {
183
+ const value = host.trim().toLowerCase();
184
+ return value === 'github.com' || value === 'www.github.com';
185
+ }
186
+ /**
187
+ * git 源的身份:`github.com-owner-repo`。
188
+ *
189
+ * 两侧都用**同一个函数**生成身份,所以仓库名里带连字符也不会歧义(不拆 owner/repo,
190
+ * 只做"整段小写 + 去掉路径分隔"的规范化)。除了依赖声明里的 git spec,也认旧 git-cache
191
+ * 目录名形态(`…/github.com-owner-repo`),因为官方安装通道会把 git 源链接到这个目录。
192
+ *
193
+ * @param source - 依赖声明里的 source 值(如 `github:owner/repo`、`link:…`)。
194
+ * @returns 身份串;不是 github 源时 null。
195
+ */
196
+ export function gitSourceIdentity(source) {
197
+ if (typeof source !== 'string')
198
+ return null;
199
+ const text = source.trim();
200
+ if (text.length === 0)
201
+ return null;
202
+ const ref = normalizeRepoRef(text);
203
+ if (ref !== null)
204
+ return 'github.com-' + ref.replace('/', '-');
205
+ const match = /github\.com-([^/\\]+?)\/?$/.exec(text);
206
+ return match === null ? null : 'github.com-' + match[1].toLowerCase();
207
+ }
208
+ /**
209
+ * 目录名 slug(技能/预设的落地目录名)。
210
+ *
211
+ * 规则刻意简单(小写 + 非字母数字折叠成 `-`):它必须与写入侧的落地目录名一致,
212
+ * 因此不含任何 locale 相关处理。**注意**:本函数只用于"探测已安装",探测不到只丢一个
213
+ * 提示(假阴性),绝不误报(假阳性会让用户以为装过了)。
214
+ */
215
+ export function directorySlug(name) {
216
+ return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
217
+ }
218
+ /**
219
+ * 条目集合的**内容身份**(缓存键的一半)。
220
+ *
221
+ * 长度进键是刻意的:哈希只是内容的指纹,长度把"哈希恰好相撞且长度不同"这种可能彻底排除。
222
+ *
223
+ * @param items - 候选条目。
224
+ * @returns `<count>-<hash>`。
225
+ */
226
+ export function itemsIdentity(items) {
227
+ return String(items.length) + '-' + hashIdentity(items.map((item) => item.repo));
228
+ }
229
+ /**
230
+ * 读一个已安装包的 package.json(不存在/损坏时 null)。
231
+ *
232
+ * 只有**真的存在**才算已安装:manifest 里声明了但 node_modules 里没有的包(装到一半、
233
+ * 手工删过)不该被市场页标成"已安装"——那正是诊断层要报的问题,不能在这里被掩盖。
234
+ */
235
+ function readInstalledPackageFacts(profileDir, name) {
236
+ const path = join(profileDir, 'node_modules', name, 'package.json');
237
+ if (!existsSync(path))
238
+ return null;
239
+ try {
240
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
241
+ const version = typeof raw['version'] === 'string' ? raw['version'].trim() : '';
242
+ const repositoryRaw = raw['repository'];
243
+ let repository = null;
244
+ if (typeof repositoryRaw === 'string')
245
+ repository = repositoryRaw;
246
+ else if (repositoryRaw !== null && typeof repositoryRaw === 'object') {
247
+ const url = repositoryRaw['url'];
248
+ if (typeof url === 'string')
249
+ repository = url;
250
+ }
251
+ return { version, repository };
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ }
257
+ /** 一个目录下的子目录名(小写);目录不存在时返回空集合。 */
258
+ function readSubdirectoryNames(dir) {
259
+ try {
260
+ const names = new Set();
261
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
262
+ if (entry.isDirectory())
263
+ names.add(entry.name.toLowerCase());
264
+ }
265
+ return names;
266
+ }
267
+ catch {
268
+ return new Set();
269
+ }
270
+ }
271
+ /** profile 名 → 缓存条目(含未找到 profile 的 null,避免每次请求都去探一次磁盘)。 */
272
+ const installedIndexCache = new Map();
273
+ /**
274
+ * 构建(或复用)一个 profile 的已安装索引。
275
+ *
276
+ * 未知/非法 profile 名返回 null:调用方据此把全部条目判为"未安装",而不是抛错。
277
+ * 非法名先经 paths.ts 的 environmentDir 校验(路径穿越防线不在这里重造)。
278
+ *
279
+ * @param profile - profile 名。
280
+ * @param options - ttlMs(默认 {@link INSTALLED_INDEX_TTL_MS})与 now(注入时钟,测试用)。
281
+ * @returns 已安装索引,或 null(profile 不可用)。
282
+ */
283
+ export function buildInstalledIndex(profile, options = {}) {
284
+ const now = options.now ?? Date.now();
285
+ const ttlMs = options.ttlMs ?? INSTALLED_INDEX_TTL_MS;
286
+ const cached = installedIndexCache.get(profile);
287
+ if (cached !== undefined && now - cached.at < ttlMs)
288
+ return cached.index;
289
+ const index = buildInstalledIndexUncached(profile);
290
+ installedIndexCache.set(profile, { at: now, index });
291
+ return index;
292
+ }
293
+ /** 丢弃一个 profile(或全部)的已安装索引缓存。装/卸/更新后必须调用,不能等 TTL。 */
294
+ export function invalidateInstalledIndex(profile) {
295
+ if (profile === undefined)
296
+ installedIndexCache.clear();
297
+ else
298
+ installedIndexCache.delete(profile);
299
+ }
300
+ /** 清空已安装索引缓存(测试用,等价于 invalidateInstalledIndex())。 */
301
+ export function clearInstalledIndexCache() {
302
+ installedIndexCache.clear();
303
+ }
304
+ /** 无缓存的构建。 */
305
+ function buildInstalledIndexUncached(profile) {
306
+ let dir;
307
+ try {
308
+ dir = environmentDir(profile);
309
+ }
310
+ catch {
311
+ return null;
312
+ }
313
+ if (!existsSync(dir))
314
+ return null;
315
+ const manifest = readEnvironmentManifest(dir);
316
+ const dependencies = manifest.raw['dependencies'];
317
+ const specs = dependencies !== null && typeof dependencies === 'object'
318
+ ? dependencies
319
+ : {};
320
+ const packages = new Map();
321
+ const repos = new Map();
322
+ const gitSources = new Map();
323
+ for (const name of Object.keys(specs)) {
324
+ const facts = readInstalledPackageFacts(dir, name);
325
+ if (facts === null)
326
+ continue;
327
+ const key = name.toLowerCase();
328
+ packages.set(key, facts.version);
329
+ if (facts.repository !== null) {
330
+ const ref = normalizeRepoRef(facts.repository);
331
+ if (ref !== null)
332
+ repos.set(ref, facts.version);
333
+ }
334
+ const identity = gitSourceIdentity(specs[name]);
335
+ if (identity !== null)
336
+ gitSources.set(identity, facts.version);
337
+ }
338
+ const skills = readSubdirectoryNames(join(dshHome(), SKILLS_DIR));
339
+ const presets = readSubdirectoryNames(join(dshHome(), PRESETS_DIR));
340
+ const identity = hashIdentity([
341
+ [...packages.keys()].sort().join(','),
342
+ [...repos.keys()].sort().join(','),
343
+ [...gitSources.keys()].sort().join(','),
344
+ [...skills].sort().join(','),
345
+ [...presets].sort().join(','),
346
+ ]);
347
+ return { packages, repos, gitSources, skills, presets, identity };
348
+ }
349
+ /**
350
+ * 判定一个条目是否已安装,并补齐已安装版本 / 形态(纯函数:只读索引,不碰磁盘)。
351
+ *
352
+ * 四条通道(任一命中即已安装):
353
+ * 1. **repository 身份**:条目 `repo`(owner/repo)命中任一已装包的 repository 字段;
354
+ * 2. **包名**:条目的 npm 包名(索引 pkg_name)或仓库名命中已装包名——包名与仓库名不一致时
355
+ * 这两条通道互补,覆盖对方看不见的情况;
356
+ * 3. **git 源**:依赖声明是 `github:…` / `git+https://github.com/…` / link 到 git-cache 目录;
357
+ * 4. **目录探测**:`<dshHome>/skills` 或 `<dshHome>/.agent-presets` 下存在对应 slug,
358
+ * 命中时形态改写为 skill / agent-preset(这正是"装成什么"的答案)。
359
+ *
360
+ * 目录探测的**已知假阴性**(与 kinds.ts 核对过落地名规则后确认):skill 的落地目录名优先取
361
+ * SKILL.md frontmatter 里的 `name`,只有缺失时才回落到仓库名末段的 slug。frontmatter 名与仓库名
362
+ * 不同时(如仓库 who/skill-pack、SKILL.md 里 name: memory-keeper),这里探不到——市场索引不携带
363
+ * SKILL.md 内容,凭空猜一个名字只会制造假阳性。要彻底修好需要把 kinds.ts 的安装记录作为第五条
364
+ * 通道传进来(loadKindRecords() 的 repo → kind/version),那需要同时把记录的内容身份纳入管线缓存键;
365
+ * 本模块先不做,等宿主侧确实需要时再加(假阴性只影响一个徽标,假阳性会误导用户)。
366
+ *
367
+ * @param item - 候选条目。
368
+ * @param index - 已安装索引;null(未知 profile)时一律判为未安装。
369
+ * @param options - 歧义 slug 集。
370
+ * @returns 新的条目对象(不修改入参)。
371
+ */
372
+ export function flagInstalled(item, index, options = {}) {
373
+ if (index === null)
374
+ return { ...item, installed: false };
375
+ let installed = false;
376
+ let version;
377
+ let kind = item.kind;
378
+ const hit = (found, hitVersion) => {
379
+ if (!found)
380
+ return;
381
+ installed = true;
382
+ if (version === undefined && hitVersion !== undefined && hitVersion.length > 0)
383
+ version = hitVersion;
384
+ };
385
+ hit(index.repos.has(item.repo.toLowerCase()), index.repos.get(item.repo.toLowerCase()));
386
+ const packageCandidates = [item.packageName?.toLowerCase(), item.name.toLowerCase()];
387
+ for (const candidate of packageCandidates) {
388
+ if (candidate === undefined || candidate.length === 0)
389
+ continue;
390
+ hit(index.packages.has(candidate), index.packages.get(candidate));
391
+ }
392
+ const gitIdentity = gitSourceIdentity('https://github.com/' + item.repo);
393
+ if (gitIdentity !== null)
394
+ hit(index.gitSources.has(gitIdentity), index.gitSources.get(gitIdentity));
395
+ const slug = directorySlug(item.name);
396
+ if (slug.length > 0 && options.ambiguousSlugs?.has(slug) !== true) {
397
+ if (index.skills.has(slug)) {
398
+ installed = true;
399
+ kind = 'skill';
400
+ }
401
+ else if (index.presets.has(slug)) {
402
+ installed = true;
403
+ kind = 'agent-preset';
404
+ }
405
+ }
406
+ return {
407
+ ...item,
408
+ installed,
409
+ ...(version === undefined ? {} : { installedVersion: version }),
410
+ ...(kind === undefined ? {} : { kind }),
411
+ };
412
+ }
413
+ /** notes 送到 UI 的上限:多到能说清"哪几跳失败",又不至于把工具栏淹掉。 */
414
+ export const MARKET_NOTES_LIMIT = 4;
415
+ /**
416
+ * 管线缓存键:profile | 代际 | **条目内容身份** | 已安装索引身份 | 索引生成时间 | 缓存标记 | 来源 | 过期。
417
+ *
418
+ * 后两项也必须进键:两份内容完全相同的索引(同一份磁盘缓存)在"网络刚成功"与"六跳全失败后回退"
419
+ * 两种情形下,notes/stale 是不同的——键里不带它们,后到的失败原因会被先到的成功结果顶掉。
420
+ *
421
+ * 内容身份是这一处的核心(旧审计 M-1:键里只有时间戳,于是"同一时间戳 + 不同条目"命中旧结果,
422
+ * 新数据被静默丢弃)。这里同时带上代际与内容哈希:代际负责"同内容不重算",哈希负责
423
+ * "不同内容一定重算",两者互补。
424
+ */
425
+ export function marketplaceCacheKey(input) {
426
+ return [
427
+ input.profile,
428
+ String(input.generation),
429
+ itemsIdentity(input.items),
430
+ input.installed?.identity ?? 'none',
431
+ input.generatedAt ?? '',
432
+ input.cached ? 'cached' : 'fresh',
433
+ input.source ?? '',
434
+ input.stale === true ? 'stale' : 'fresh',
435
+ ].join('|');
436
+ }
437
+ /**
438
+ * 最终管线(纯函数,不做缓存):标已安装 → 投影回 wire 形状 → 分类计数。
439
+ *
440
+ * 分类计数在**最终**条目集上算(投影之后),因此筛选器列出的分类与卡片能显示的分类完全一致。
441
+ *
442
+ * @param input - 见 {@link MarketplaceInput}。
443
+ * @returns wire 结果。
444
+ */
445
+ export function finalizeMarketplace(input) {
446
+ const ambiguous = input.installed === null ? undefined : ambiguousSlugs(input.items);
447
+ const items = [];
448
+ for (const item of input.items) {
449
+ items.push(toWireItem(flagInstalled(item, input.installed, ambiguous === undefined ? {} : { ambiguousSlugs: ambiguous })));
450
+ }
451
+ const notes = input.notes === undefined ? undefined : input.notes.slice(0, MARKET_NOTES_LIMIT);
452
+ return {
453
+ items,
454
+ generatedAt: input.generatedAt ?? '',
455
+ cached: input.cached,
456
+ categories: categoryCounts(items),
457
+ ...(input.source === undefined ? {} : { source: input.source }),
458
+ ...(input.stale === undefined ? {} : { stale: input.stale }),
459
+ ...(notes === undefined || notes.length === 0 ? {} : { notes }),
460
+ };
461
+ }
462
+ /** 本次 listing 里 slug 冲突的条目名(无法安全归属到目录探测)。 */
463
+ function ambiguousSlugs(items) {
464
+ const counts = new Map();
465
+ for (const item of items) {
466
+ const slug = directorySlug(item.name);
467
+ if (slug.length === 0)
468
+ continue;
469
+ counts.set(slug, (counts.get(slug) ?? 0) + 1);
470
+ }
471
+ const out = new Set();
472
+ for (const [slug, count] of counts) {
473
+ if (count > 1)
474
+ out.add(slug);
475
+ }
476
+ return out;
477
+ }
478
+ /** 管线缓存:每个 profile 一格(代际只前进,所以一格足够)。 */
479
+ const pipelineCache = new Map();
480
+ /**
481
+ * 带缓存的最终管线。
482
+ *
483
+ * 命中时返回**同一个对象实例**:调用方(REST 层的序列化缓存)可以拿它当 key。
484
+ *
485
+ * @param input - 见 {@link MarketplaceInput}。
486
+ * @returns wire 结果。
487
+ */
488
+ export function cachedMarketplace(input) {
489
+ const key = marketplaceCacheKey(input);
490
+ const cached = pipelineCache.get(input.profile);
491
+ if (cached !== undefined && cached.key === key)
492
+ return cached.result;
493
+ const result = finalizeMarketplace(input);
494
+ pipelineCache.set(input.profile, { key, result });
495
+ return result;
496
+ }
497
+ /** 清空管线缓存(测试 / 插件卸载用)。 */
498
+ export function clearMarketplaceCache() {
499
+ pipelineCache.clear();
500
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * match.ts — plugin_search 的匹配纯函数:分词、加权打分、版本比较(无 fs / 无 ctx / 无网络)。
3
+ *
4
+ * 归属:B 类·参考重写(旧 src/match.ts 仅作意图参考,未复制代码;用例语义在 tests/marketplace.test.mjs 重写)。
5
+ * 旧实现参考:dsh-web-plugin-manager/src/match.ts(理解意图用,未复制代码)——它解决的问题是:
6
+ * 模型侧的自然语言查询("帮我找记住上下文的插件")需要一个不看 UI 的纯函数打分器,
7
+ * 并且要能回答"这个装好的包是不是该更新了"(版本比较)。
8
+ * 官方复用:无。官方没有市场/搜索概念;版本比较也不能用官方 plugin_manager 工具代替
9
+ * (它做安装,不做"哪个更新"的判断)。
10
+ * 前提检查:旧实现的两条权重(名称 3 / 主题 2 / 描述 1)仍然成立,但旧审计 m-1
11
+ * (docs/private/audit/correctness.md)指出 compareVersions 对**非法 semver 宽容解析**:
12
+ * `1.0.0-01`(数字标识符前导零)、`1.0.0-`(空 pre)在语义上非法,旧实现却当成合法版本走
13
+ * 数值比较,得到 `compareVersions('1.0.0-','1.0.0') === 0` 这种"非法输入等于合法版本"的结论。
14
+ * 本实现改为:**解析失败即回退字符串比较**(结果确定、可预期,且绝不把非法输入当合法)。
15
+ * 主题命中沿用旧实现修过的规则:短 token(≤3)只做精确匹配——旧的"反向子串"
16
+ * (token.includes(topic))会让每个 2–3 字母主题命中一大堆无关条目。
17
+ */
18
+ import type { MarketItem } from './types.ts';
19
+ /**
20
+ * 把查询切成小写 token(字母数字 + CJK 连续段)。
21
+ *
22
+ * 标点/空白都当分隔符:查询是自然语言,不该要求用户记住连字符位置。
23
+ *
24
+ * @param query - 用户/模型输入。
25
+ * @returns 小写 token 列表;空查询返回空数组。
26
+ */
27
+ export declare function tokenize(query: string): string[];
28
+ /**
29
+ * 一个条目对一组 token 的加权分。
30
+ *
31
+ * 权重:名称(display name 或 owner/repo,命中一次只计一次 3 分)/ 主题 2 分 / 描述 1 分,
32
+ * 逐 token 累加。名称与 repo 合并计分是有意的:owner 段(如 `termanli`)也是有效信号,
33
+ * 但同一个 token 同时命中两者不该被算成 6 分(那会让"名字里出现两次"压过真正的相关性)。
34
+ *
35
+ * @param item - 市场条目。
36
+ * @param tokens - {@link tokenize} 的结果。
37
+ * @returns 分数;0 表示不相关。
38
+ */
39
+ export declare function scoreItem(item: MarketItem, tokens: readonly string[]): number;
40
+ /**
41
+ * plugin_search 的排序:分数降序 → 星数降序 → 名称升序。
42
+ *
43
+ * 空查询返回**星数最高的前 limit 条**(旧行为保留:模型常问"有什么好用的插件")。
44
+ * 星数相同时按名称升序而不是保持输入顺序:输入顺序来自上游索引,可能与调用方无关,
45
+ * 显式 tie-break 让同样的输入永远给出同样的答案。
46
+ *
47
+ * @param items - 市场条目。
48
+ * @param query - 查询串。
49
+ * @param limit - 返回条数上限(负数/0 视为 0)。
50
+ * @returns 排好序的前 limit 条(不修改入参)。
51
+ */
52
+ export declare function findPluginMatches(items: readonly MarketItem[], query: string, limit: number): MarketItem[];
53
+ /**
54
+ * 轻量 semver 比较。
55
+ *
56
+ * 语义(semver §11):`1.2.3-rc.1 < 1.2.3`;`rc.10 > rc.9`;`1.0` / `1` 视作 `1.0.0`;
57
+ * 预发布标识符逐个比较,**数字标识符的优先级低于字母数字**,共享前缀相同时字段少的更小
58
+ * (alpha < alpha.1);build metadata 不参与优先级。
59
+ *
60
+ * 任一侧非法(`1.0.0-01`、`1.0.0-`、`abc`)时**回退原始串比较**:
61
+ * 非法输入不能被当成合法版本参与数值比较(旧审计 m-1)。
62
+ *
63
+ * @param left - 版本串。
64
+ * @param right - 版本串。
65
+ * @returns -1 / 0 / 1。
66
+ */
67
+ export declare function compareVersions(left: string, right: string): number;