dsh-m 0.4.2 → 0.5.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * 目录适配层(M1 Task 4 / DESIGN.md §2.5「目录适配层」/ Q40 / Q43):
3
+ * 原生条目(宽松校验的字段袋)→ 收录条目 CommunityEntry。
4
+ * 条目层宽松语义(Q43):单条不合格跳过 + 计数(skippedDirty / skippedSubpathNoNpm),不整份拒绝。
5
+ * id 合成:小写 `owner--name`,非法字符折叠为 `-`,超 64 截断加哈希尾缀,适配层内冲突追加序号。
6
+ * 旁路字段(stars/downloads/capabilities/capabilityRedLines/screenshots)不进 RegistryEntry
7
+ * 本体,仅随 CommunityEntry 透传、由市场层展示(Q44:卡片不打标,详情折叠区才展示)。
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ /** 与主清单 v1 同款约束(registry.ts MAX_ID / ID_RE / NPM_RE / GITHUB_RE 同语义,锚定不漂移)。 */
11
+ const MAX_ID = 64;
12
+ const MAX_DESCRIPTION = 500;
13
+ const ID_ILLEGAL_RE = /[^a-z0-9._-]+/g;
14
+ const ID_LEADING_ILLEGAL_RE = /^[._-]+/;
15
+ const NPM_SHAPE_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
16
+ const GITHUB_SHAPE_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
17
+ /** 社区开放分类的安全 slug(计划 Task 4 契约;原生值保留不转译,Q40)。 */
18
+ const COMMUNITY_CATEGORY_RE = /^[a-z0-9-]{1,32}$/;
19
+ const HASH_SUFFIX_LEN = 8;
20
+ const TRUNCATED_BODY_LEN = MAX_ID - (HASH_SUFFIX_LEN + 1);
21
+ function isBlank(v) {
22
+ return typeof v !== 'string' || v.trim() === '';
23
+ }
24
+ function strArray(v) {
25
+ if (!Array.isArray(v))
26
+ return [];
27
+ return v.filter((x) => typeof x === 'string' && x.trim() !== '');
28
+ }
29
+ function numOrNull(v) {
30
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
31
+ }
32
+ /** npm 形状成立(非空字符串且满足 npm 包名形状)→ source: npm。 */
33
+ function npmShape(raw) {
34
+ if (typeof raw.npm !== 'string')
35
+ return null;
36
+ const v = raw.npm.trim();
37
+ return NPM_SHAPE_RE.test(v) ? v : null;
38
+ }
39
+ /** url → `owner/repo`(仅接受 github.com http(s) 链接;形状须满足 GITHUB_SHAPE_RE)。 */
40
+ function githubFromUrl(url) {
41
+ if (typeof url !== 'string')
42
+ return null;
43
+ const m = url.match(/^https?:\/\/github\.com\/([^/?#\s]+)\/([^/?#\s]+)/i);
44
+ if (!m)
45
+ return null;
46
+ const candidate = `${m[1]}/${m[2]}`;
47
+ return GITHUB_SHAPE_RE.test(candidate) ? candidate : null;
48
+ }
49
+ /** 子包形态(Q43):monorepo 子路径(url 含 `/tree/`)或上游 `name` 含 `#` 子包命名。 */
50
+ function isSubpathShape(raw) {
51
+ const urlHasTree = typeof raw.url === 'string' && raw.url.includes('/tree/');
52
+ const nameHasHash = typeof raw.name === 'string' && raw.name.includes('#');
53
+ return urlHasTree || nameHasHash;
54
+ }
55
+ /** id 合成:小写 owner--name → 非法字符折叠 `-` → 剥前导非法段 → 超 64 截断加 sha256 尾缀。 */
56
+ function synthesizeId(owner, name) {
57
+ let id = `${owner}--${name}`.toLowerCase().replace(ID_ILLEGAL_RE, '-').replace(ID_LEADING_ILLEGAL_RE, '');
58
+ if (id === '')
59
+ return '';
60
+ if (id.length > MAX_ID) {
61
+ const hash = createHash('sha256').update(id).digest('hex').slice(0, HASH_SUFFIX_LEN);
62
+ id = `${id.slice(0, TRUNCATED_BODY_LEN)}-${hash}`;
63
+ }
64
+ return id;
65
+ }
66
+ /** 适配层内冲突追加序号(-2/-3/…;追加后超长则截主体保序号完整)。 */
67
+ function dedupeId(id, used) {
68
+ if (!used.has(id)) {
69
+ used.add(id);
70
+ return id;
71
+ }
72
+ for (let n = 2;; n++) {
73
+ const suffix = `-${n}`;
74
+ const candidate = id.length + suffix.length > MAX_ID ? id.slice(0, MAX_ID - suffix.length) + suffix : id + suffix;
75
+ if (!used.has(candidate)) {
76
+ used.add(candidate);
77
+ return candidate;
78
+ }
79
+ }
80
+ }
81
+ /**
82
+ * 适配入口:输入为已过容器校验的 CommunityCatalog(fetch 链产物)。
83
+ * 每条原生条目按序判定:name/owner/category/description 基础字段 → 子包两态 → 来源归一
84
+ * (npm 形状 → npm;否则 url → github;皆否跳过)→ id 合成去重。脏条目跳过计数,绝不中断整份。
85
+ */
86
+ export function adaptCommunityCatalog(catalog) {
87
+ const entries = [];
88
+ const warnings = [];
89
+ const used = new Set();
90
+ let skippedDirty = 0;
91
+ let skippedSubpathNoNpm = 0;
92
+ let fallbackEn = 0;
93
+ let truncated = 0;
94
+ for (const raw of catalog.plugins) {
95
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
96
+ skippedDirty += 1;
97
+ continue;
98
+ }
99
+ if (isBlank(raw.name) || isBlank(raw.owner)) {
100
+ skippedDirty += 1;
101
+ continue;
102
+ }
103
+ const name = raw.name.trim();
104
+ const owner = raw.owner.trim();
105
+ if (typeof raw.category !== 'string' || !COMMUNITY_CATEGORY_RE.test(raw.category)) {
106
+ skippedDirty += 1;
107
+ continue;
108
+ }
109
+ // description:zh 优先,缺省回退 en;两者皆无 → 脏条目(>500 截断并计数,同 registry MAX_DESCRIPTION 口径)
110
+ const desc = raw.description;
111
+ const zh = typeof desc?.zh === 'string' ? desc.zh.trim() : '';
112
+ const en = typeof desc?.en === 'string' ? desc.en.trim() : '';
113
+ let description = zh !== '' ? zh : en;
114
+ if (description === '') {
115
+ skippedDirty += 1;
116
+ continue;
117
+ }
118
+ if (zh === '')
119
+ fallbackEn += 1;
120
+ if (description.length > MAX_DESCRIPTION) {
121
+ description = description.slice(0, MAX_DESCRIPTION);
122
+ truncated += 1;
123
+ }
124
+ const npm = npmShape(raw);
125
+ if (npm === null && isSubpathShape(raw)) {
126
+ // 无 npm 的 monorepo 子包:repo 根不是正确安装目标(Q43),跳过并计数
127
+ skippedSubpathNoNpm += 1;
128
+ continue;
129
+ }
130
+ let source;
131
+ let npmName;
132
+ let github;
133
+ if (npm !== null) {
134
+ source = 'npm';
135
+ npmName = npm;
136
+ }
137
+ else {
138
+ const gh = githubFromUrl(raw.url);
139
+ if (gh === null) {
140
+ skippedDirty += 1;
141
+ continue;
142
+ }
143
+ source = 'github';
144
+ github = gh;
145
+ }
146
+ const id = synthesizeId(owner, name);
147
+ if (id === '') {
148
+ skippedDirty += 1;
149
+ continue;
150
+ }
151
+ // url → homepage(仅 http(s);github 源必有 url,npm 源可能缺省)
152
+ const rawUrl = typeof raw.url === 'string' ? raw.url.trim() : '';
153
+ const homepage = rawUrl.startsWith('https://') || rawUrl.startsWith('http://') ? rawUrl : undefined;
154
+ entries.push({
155
+ id: dedupeId(id, used),
156
+ name,
157
+ description,
158
+ category: raw.category,
159
+ tags: [],
160
+ source,
161
+ ...(npmName !== undefined ? { npm: npmName } : {}),
162
+ ...(github !== undefined ? { github } : {}),
163
+ ...(homepage !== undefined ? { homepage } : {}),
164
+ stars: numOrNull(raw.stars),
165
+ downloads: numOrNull(raw.downloads),
166
+ capabilities: strArray(raw.capabilities),
167
+ capabilityRedLines: strArray(raw.capabilityRedLines),
168
+ screenshots: strArray(raw.screenshots),
169
+ descriptionEn: en,
170
+ });
171
+ }
172
+ if (fallbackEn > 0)
173
+ warnings.push(`zh 描述缺省回退 en:${fallbackEn} 条`);
174
+ if (truncated > 0)
175
+ warnings.push(`描述超过 ${MAX_DESCRIPTION} 字符已截断:${truncated} 条`);
176
+ if (skippedSubpathNoNpm > 0)
177
+ warnings.push(`无 npm 的子包条目跳过:${skippedSubpathNoNpm} 条`);
178
+ if (skippedDirty > 0)
179
+ warnings.push(`脏条目跳过:${skippedDirty} 条(缺 name/owner/category/描述,或来源无法判定)`);
180
+ return { entries, warnings, skippedDirty, skippedSubpathNoNpm };
181
+ }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * 社区清单(DESIGN.md §2.5 / ADR-0003):awesome-dsh-plugin 全量目录的只读叠加层。
3
+ * 容器层(常量/类型/校验/标签)+ 获取链(Task 3:版本探测 → 三线路正文 → 版本缓存)。
4
+ * 校验语义(Q43):容器层严格;条目层宽松(适配层跳过计数,见 community-adapter.ts)。
5
+ * deadline 语义(v8-v10 定稿):共享 flight 只受自身 30s hard cap 约束;
6
+ * 调用者 deadline/signal 不进入共享 loader——waiter 在调用层各自 race(本模块只接受
7
+ * 每调用者的 signal 用于取消「自己的等待」,最后一个 waiter 离开才 abort 共享加载)。
8
+ */
9
+ import { mkdir, readFile, rm, rename, writeFile } from 'node:fs/promises';
10
+ import { isAbsolute, join, relative } from 'node:path';
11
+ import { cacheDir } from './env.js';
12
+ import { decodeUtf8Fatal, fetchBytesLimited, describeFetchFailure } from './httpx.js';
13
+ import { isExactVersion, npmLatest } from './versions.js';
14
+ import { communityOutcome } from './market.js';
15
+ export const COMMUNITY_NPM_PACKAGE = 'dsh-plugin-catalog';
16
+ export const MAX_COMMUNITY_BYTES = 32 * 1024 * 1024;
17
+ export const MAX_COMMUNITY_ENTRIES = 30_000;
18
+ export const CATALOG_BODY_TIMEOUT_MS = 15_000;
19
+ export const COMMUNITY_CHAIN_BUDGET_MS = 30_000;
20
+ /**
21
+ * 上游 23 个分类 id 的全量中文标签(以 fixture `categories` 键为准逐条核对)。
22
+ * 其中 ui/tools/market 与精选分类同名共享过滤桶、不进筛选栏社区组,标签供详情页等处使用。
23
+ */
24
+ export const COMMUNITY_KNOWN_CATEGORIES = {
25
+ agi: 'AGI 架构探索',
26
+ ui: 'UI 增强',
27
+ usage: '用量与计费',
28
+ theme: '主题与外观',
29
+ model: '模型与账号接入',
30
+ identity: '身份与通信',
31
+ session: '会话与消息',
32
+ memory: '记忆',
33
+ tools: '工具与能力',
34
+ wsl: 'WSL 与 Windows 互操作',
35
+ browser: '浏览器与网页',
36
+ vision: '视觉与多模态',
37
+ voice: '语音与音频',
38
+ docs: '文档与渲染',
39
+ skill: '技能包',
40
+ workflow: '工作流与自动化',
41
+ git: 'Git 与代码评审',
42
+ notify: '通知与集成',
43
+ dev: '开发与运行时',
44
+ security: '安全与权限',
45
+ remote: '远程与移动端',
46
+ market: '插件市场与管理',
47
+ fun: '娱乐',
48
+ };
49
+ const TOP_LEVEL_KEYS = new Set(['name', 'url', 'source', 'updated', 'count', 'categories', 'plugins']);
50
+ /**
51
+ * 容器层严格校验(条目层由适配层负责):
52
+ * - 根必须是对象,未知顶层键报错;
53
+ * - `plugins` 缺失/非数组/超过 MAX_COMMUNITY_ENTRIES → 整份拒收(不截断、不部分加载)。
54
+ */
55
+ export function validateCommunityContainer(raw) {
56
+ const errors = [];
57
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
58
+ return { ok: false, errors: ['社区目录根必须是对象'], catalog: null };
59
+ }
60
+ const obj = raw;
61
+ for (const key of Object.keys(obj)) {
62
+ if (!TOP_LEVEL_KEYS.has(key))
63
+ errors.push(`${key}: 未知顶层字段`);
64
+ }
65
+ const plugins = obj.plugins;
66
+ if (!Array.isArray(plugins)) {
67
+ errors.push('plugins: 必须是数组');
68
+ return { ok: false, errors, catalog: null };
69
+ }
70
+ if (plugins.length > MAX_COMMUNITY_ENTRIES) {
71
+ errors.push(`plugins: ${plugins.length} 条超过上限 ${MAX_COMMUNITY_ENTRIES},拒绝整份目录`);
72
+ return { ok: false, errors, catalog: null };
73
+ }
74
+ for (let i = 0; i < plugins.length; i++) {
75
+ const item = plugins[i];
76
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
77
+ errors.push(`plugins[${i}]: 必须是对象`);
78
+ }
79
+ }
80
+ if (errors.length > 0)
81
+ return { ok: false, errors, catalog: null };
82
+ const categories = obj.categories && typeof obj.categories === 'object' && !Array.isArray(obj.categories)
83
+ ? obj.categories
84
+ : {};
85
+ return {
86
+ ok: true,
87
+ errors: [],
88
+ catalog: {
89
+ name: typeof obj.name === 'string' ? obj.name : '',
90
+ url: typeof obj.url === 'string' ? obj.url : '',
91
+ source: typeof obj.source === 'string' ? obj.source : '',
92
+ updated: typeof obj.updated === 'string' ? obj.updated : '',
93
+ count: typeof obj.count === 'number' ? obj.count : plugins.length,
94
+ categories,
95
+ plugins: plugins,
96
+ },
97
+ };
98
+ }
99
+ const DEFAULT_FILE_BASES = [
100
+ 'https://cdn.jsdelivr.net/npm/dsh-plugin-catalog@{version}/plugins.json',
101
+ 'https://registry.npmmirror.com/dsh-plugin-catalog/{version}/files/plugins.json',
102
+ 'https://unpkg.com/dsh-plugin-catalog@{version}/plugins.json',
103
+ ];
104
+ const ROUTE_LABELS = ['jsdelivr', 'npmmirror', 'unpkg'];
105
+ const META_LATEST = 'meta.json';
106
+ const metaPinName = (v) => `meta-pin-${v}.json`;
107
+ const bodyName = (v) => `catalog-${v}.json`;
108
+ function awesomeDir(namespace) {
109
+ return join(cacheDir(), namespace, 'awesome');
110
+ }
111
+ /** 路径 containment:目标必须仍在 dir 内(防御拼接穿越)。 */
112
+ function contained(dir, p) {
113
+ const rel = relative(dir, p);
114
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
115
+ }
116
+ async function atomicWrite(path, text) {
117
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
118
+ await writeFile(tmp, text);
119
+ await rename(tmp, path);
120
+ }
121
+ /** 读取顺序(v8 定稿):meta 结构校验 → isExactVersion(meta.version) → body containment → 才读正文;损坏文件清理。 */
122
+ async function readCache(dir, pin) {
123
+ const metaPath = join(dir, pin ? metaPinName(pin) : META_LATEST);
124
+ let meta = null;
125
+ try {
126
+ const raw = JSON.parse(await readFile(metaPath, 'utf8'));
127
+ if (raw &&
128
+ typeof raw === 'object' &&
129
+ typeof raw.version === 'string' &&
130
+ typeof raw.checkedAt === 'string' &&
131
+ typeof raw.fetchedAt === 'string' &&
132
+ (raw.route === null || typeof raw.route === 'string')) {
133
+ meta = raw;
134
+ }
135
+ }
136
+ catch {
137
+ meta = null;
138
+ }
139
+ if (!meta) {
140
+ await rm(metaPath, { force: true }).catch(() => undefined);
141
+ return null;
142
+ }
143
+ if (!isExactVersion(meta.version)) {
144
+ await rm(metaPath, { force: true }).catch(() => undefined);
145
+ return null;
146
+ }
147
+ const bodyPath = join(dir, bodyName(meta.version));
148
+ if (!contained(dir, bodyPath))
149
+ return null;
150
+ let data;
151
+ try {
152
+ data = JSON.parse(await readFile(bodyPath, 'utf8'));
153
+ }
154
+ catch {
155
+ await rm(bodyPath, { force: true }).catch(() => undefined);
156
+ return null;
157
+ }
158
+ const v = validateCommunityContainer(data);
159
+ if (!v.ok || !v.catalog) {
160
+ await rm(bodyPath, { force: true }).catch(() => undefined);
161
+ return null;
162
+ }
163
+ return { meta, catalog: v.catalog };
164
+ }
165
+ async function writeMeta(dir, pin, meta) {
166
+ await mkdir(dir, { recursive: true });
167
+ await atomicWrite(join(dir, pin ? metaPinName(pin) : META_LATEST), JSON.stringify(meta, null, 2));
168
+ }
169
+ function withinTtl(cfg, meta) {
170
+ const t = Date.parse(meta.checkedAt || '');
171
+ if (!Number.isFinite(t))
172
+ return false; // checkedAt 非法 → 视为过期(⑪)
173
+ return Date.now() - t < Math.max(0, cfg.cacheTtlMin ?? 60) * 60_000;
174
+ }
175
+ function routeError(label, err, ms) {
176
+ return describeFetchFailure({ label, err, elapsedMs: ms });
177
+ }
178
+ function stateOf(partial) {
179
+ return {
180
+ enabled: true,
181
+ version: null,
182
+ checkedAt: null,
183
+ fetchedAt: null,
184
+ route: null,
185
+ count: 0,
186
+ errors: [],
187
+ warnings: [],
188
+ ...partial,
189
+ };
190
+ }
191
+ function disabledState() {
192
+ return stateOf({ enabled: false, status: 'disabled' });
193
+ }
194
+ function trimPin(cfg) {
195
+ const raw = typeof cfg.communityCatalogPin === 'string' ? cfg.communityCatalogPin.trim() : '';
196
+ return raw === '' ? null : raw;
197
+ }
198
+ async function runChain(cfg, opts, ctrl) {
199
+ const namespace = opts.namespace ?? 'host';
200
+ const dir = awesomeDir(namespace);
201
+ const pin = trimPin(cfg);
202
+ const budgetMs = opts.flightBudgetMs ?? COMMUNITY_CHAIN_BUDGET_MS;
203
+ const startedAt = Date.now();
204
+ const remaining = () => budgetMs - (Date.now() - startedAt);
205
+ // 缓存快速路径:pin 命中即用(版本不可变);latest 需 TTL 内(checkedAt 新鲜)
206
+ if (!opts.force) {
207
+ const cached = await readCache(dir, pin);
208
+ if (cached && (pin !== null || withinTtl(cfg, cached.meta))) {
209
+ return {
210
+ state: stateOf({
211
+ status: 'ready',
212
+ version: cached.meta.version,
213
+ checkedAt: cached.meta.checkedAt,
214
+ fetchedAt: cached.meta.fetchedAt,
215
+ route: cached.meta.route,
216
+ count: cached.catalog.plugins.length,
217
+ }),
218
+ catalog: cached.catalog,
219
+ };
220
+ }
221
+ }
222
+ const errors = [];
223
+ let version = pin;
224
+ let probeFailed = false;
225
+ if (pin === null) {
226
+ const probeTimeout = Math.max(1, Math.min(8_000, remaining()));
227
+ const t0 = Date.now();
228
+ try {
229
+ const latest = await npmLatest(COMMUNITY_NPM_PACKAGE, probeTimeout, ctrl.signal, opts.routes?.registryBase);
230
+ if (!isExactVersion(latest.version))
231
+ throw new Error(`dist-tags 返回非精确版本: ${latest.version}`);
232
+ version = latest.version;
233
+ }
234
+ catch (err) {
235
+ probeFailed = true;
236
+ errors.push(routeError('版本探测', err, Date.now() - t0));
237
+ }
238
+ }
239
+ if (probeFailed || version === null) {
240
+ const cached = await readCache(dir, pin);
241
+ if (cached) {
242
+ return {
243
+ state: stateOf({
244
+ status: 'stale',
245
+ version: cached.meta.version,
246
+ checkedAt: cached.meta.checkedAt,
247
+ fetchedAt: cached.meta.fetchedAt,
248
+ route: cached.meta.route,
249
+ count: cached.catalog.plugins.length,
250
+ errors,
251
+ }),
252
+ catalog: cached.catalog,
253
+ };
254
+ }
255
+ return { state: stateOf({ status: 'unavailable', errors }), catalog: null };
256
+ }
257
+ // 同版本短路(⑫):探测成功但版本未变 → 只更新 checkedAt,不重拉正文
258
+ if (!opts.force) {
259
+ const cachedSame = await readCache(dir, pin);
260
+ if (cachedSame && cachedSame.meta.version === version) {
261
+ const checkedAt = new Date().toISOString();
262
+ await writeMeta(dir, pin, { ...cachedSame.meta, checkedAt });
263
+ return {
264
+ state: stateOf({
265
+ status: 'ready',
266
+ version,
267
+ checkedAt,
268
+ fetchedAt: cachedSame.meta.fetchedAt,
269
+ route: cachedSame.meta.route,
270
+ count: cachedSame.catalog.plugins.length,
271
+ }),
272
+ catalog: cachedSame.catalog,
273
+ };
274
+ }
275
+ }
276
+ const bases = opts.routes?.fileBases ?? DEFAULT_FILE_BASES;
277
+ for (let i = 0; i < bases.length; i++) {
278
+ if (remaining() <= 0) {
279
+ errors.push('社区目录加载预算耗尽,稍后重试可恢复');
280
+ break;
281
+ }
282
+ const label = ROUTE_LABELS[i] ?? `route${i}`;
283
+ const url = bases[i].replace('{version}', encodeURIComponent(version));
284
+ const t0 = Date.now();
285
+ try {
286
+ const { bytes } = await fetchBytesLimited(url, {
287
+ timeoutMs: Math.max(1, Math.min(CATALOG_BODY_TIMEOUT_MS, remaining())),
288
+ maxBytes: MAX_COMMUNITY_BYTES,
289
+ signal: ctrl.signal,
290
+ });
291
+ const text = decodeUtf8Fatal(bytes);
292
+ const data = JSON.parse(text);
293
+ const v = validateCommunityContainer(data);
294
+ if (!v.ok || !v.catalog)
295
+ throw new Error(`目录校验失败 — ${v.errors.slice(0, 3).join('; ')}`);
296
+ const now = new Date().toISOString();
297
+ await mkdir(dir, { recursive: true });
298
+ await atomicWrite(join(dir, bodyName(version)), text);
299
+ await writeMeta(dir, pin, { version, checkedAt: now, fetchedAt: now, route: label });
300
+ return {
301
+ state: stateOf({
302
+ status: 'ready',
303
+ version,
304
+ checkedAt: now,
305
+ fetchedAt: now,
306
+ route: label,
307
+ count: v.catalog.plugins.length,
308
+ }),
309
+ catalog: v.catalog,
310
+ };
311
+ }
312
+ catch (err) {
313
+ if (ctrl.signal.aborted && remaining() <= 0) {
314
+ errors.push('社区目录加载预算耗尽,稍后重试可恢复');
315
+ break;
316
+ }
317
+ errors.push(routeError(label, err, Date.now() - t0));
318
+ }
319
+ }
320
+ const cached = await readCache(dir, pin);
321
+ if (cached) {
322
+ return {
323
+ state: stateOf({
324
+ status: 'stale',
325
+ version: cached.meta.version,
326
+ checkedAt: cached.meta.checkedAt,
327
+ fetchedAt: cached.meta.fetchedAt,
328
+ route: cached.meta.route,
329
+ count: cached.catalog.plugins.length,
330
+ errors,
331
+ }),
332
+ catalog: cached.catalog,
333
+ };
334
+ }
335
+ return { state: stateOf({ status: 'unavailable', errors }), catalog: null };
336
+ }
337
+ const flights = new Map();
338
+ function flightKey(opts, pin) {
339
+ const ns = opts.namespace ?? 'host';
340
+ const routesSig = opts.routes
341
+ ? `${opts.routes.registryBase ?? ''}|${(opts.routes.fileBases ?? []).join(',')}`
342
+ : 'default';
343
+ return [ns, pin ?? '', opts.force ? 'force' : 'normal', routesSig].join('§');
344
+ }
345
+ function abortError() {
346
+ const err = new Error('Aborted');
347
+ err.name = 'AbortError';
348
+ return err;
349
+ }
350
+ async function raceWithSignal(promise, signal) {
351
+ if (!signal)
352
+ return promise;
353
+ if (signal.aborted)
354
+ throw abortError();
355
+ return new Promise((resolve, reject) => {
356
+ const onAbort = () => reject(abortError());
357
+ signal.addEventListener('abort', onAbort, { once: true });
358
+ promise.then((value) => {
359
+ signal.removeEventListener('abort', onAbort);
360
+ resolve(value);
361
+ }, (err) => {
362
+ signal.removeEventListener('abort', onAbort);
363
+ reject(err);
364
+ });
365
+ });
366
+ }
367
+ function releaseFlight(key, flight) {
368
+ flight.refs -= 1;
369
+ if (flight.refs <= 0) {
370
+ clearTimeout(flight.timer);
371
+ flight.ctrl.abort();
372
+ if (flights.get(key) === flight)
373
+ flights.delete(key);
374
+ }
375
+ }
376
+ /**
377
+ * 社区清单加载入口(Q39/Q43):
378
+ * - disabled → 直接返回(不触网络);
379
+ * - pin 非法 → unavailable(零网络零写入);
380
+ * - in-flight 合并:key = namespace + 生效 pin + force + routes;各 waiter 的 signal 只取消各自等待。
381
+ */
382
+ export async function fetchCommunityCatalog(cfg = {}, opts = {}) {
383
+ if (cfg.communityCatalog === false)
384
+ return { state: disabledState(), catalog: null };
385
+ const pin = trimPin(cfg);
386
+ if (pin !== null && !isExactVersion(pin)) {
387
+ return {
388
+ state: stateOf({
389
+ status: 'unavailable',
390
+ errors: [`communityCatalogPin 非法(需精确 semver): ${pin};请检查设置`],
391
+ }),
392
+ catalog: null,
393
+ };
394
+ }
395
+ const key = flightKey(opts, pin);
396
+ let flight = flights.get(key);
397
+ if (!flight) {
398
+ const ctrl = new AbortController();
399
+ const budgetMs = opts.flightBudgetMs ?? COMMUNITY_CHAIN_BUDGET_MS;
400
+ const timer = setTimeout(() => ctrl.abort(), budgetMs);
401
+ const runPromise = runChain(cfg, opts, ctrl);
402
+ const created = { refs: 0, ctrl, timer, promise: runPromise };
403
+ runPromise
404
+ .finally(() => {
405
+ clearTimeout(timer);
406
+ if (flights.get(key) === created && created.refs <= 0)
407
+ flights.delete(key);
408
+ })
409
+ .catch(() => undefined);
410
+ flights.set(key, created);
411
+ flight = created;
412
+ }
413
+ flight.refs += 1;
414
+ try {
415
+ return await raceWithSignal(flight.promise, opts.signal);
416
+ }
417
+ finally {
418
+ releaseFlight(key, flight);
419
+ }
420
+ }
421
+ /**
422
+ * 单一深接口(计划 Task 6):fetch → adapt → merge 计数 → CommunityRegistrySummary。
423
+ * - primaryEntries 直接取本次 registry snapshot 的 primary plugins(displaced 与本次响应同代,
424
+ * 不重复加载主清单;内部禁止调用 loadRegistry——单一来源约束);
425
+ * - 3s 是本 waiter 的等待上限(waiter-scoped race):到点未 materialize → unavailable +
426
+ * 「社区目录状态获取超时」errors,调用方主响应照常;共享 flight 按 30s hard cap 继续;
427
+ * - 数据源 cache-first:fetchCommunityCatalog 的版本缓存 + TTL 保证 registry 刷新无重复网络压力。
428
+ */
429
+ export async function getCommunitySummary(primaryEntries, cfg = {}, opts) {
430
+ const task = fetchCommunityCatalog(cfg, { signal: opts.signal, force: opts.force });
431
+ const outcome = await communityOutcome(task, opts.deadlineAt, primaryEntries);
432
+ return outcome.summary;
433
+ }