dsh-armory 1.10.0 → 2.0.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.
package/lib/typert.js CHANGED
@@ -94,6 +94,22 @@ export const TYPERT = {
94
94
  mv('wbSkillInstall', [payloadParam], 'NoteResult'),
95
95
  mv('wbSkillUninstall', [payloadParam], 'NoteResult'),
96
96
  mv('wbSkillInstalled', [], 'WbInstalledResult'),
97
+ // T03/R4:多市场注册表(读 / 增改)。payload $any,形状由 lib/wb-registry.js 校验。
98
+ mv('wbMarketRegistry', [], 'WbMarketRegistryResult'),
99
+ mv('wbMarketRegistryUpsert', [payloadParam], 'WbMarketRegistryResult'),
100
+ // T03/R5:codebuddy 插件市场(列表 / 详情)。payload $any,形状由 lib/wb-plugin-market.js 校验。
101
+ mv('wbPluginCatalog', [], 'WbPluginCatalogResult'),
102
+ mv('wbPluginDetail', [payloadParam], 'WbPluginDetailResult'),
103
+ // T04/R8:安装状态登记与查询(宿主侧落盘,客户端「已安装」筛选的数据源)。
104
+ mv('wbInstallState', [], 'InstallStateResult'),
105
+ mv('wbInstallStateRecord', [payloadParam], 'NoteResult'),
106
+ mv('wbInstallStateForget', [payloadParam], 'NoteResult'),
107
+ // T05/R9:插件市场更新探测(只做条件请求,不落新包)。
108
+ mv('wbPluginUpdateCheck', [], 'WbUpdateHintResult'),
109
+ // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins,数据层 lib/wb-awesome-market.js)。
110
+ mv('wbAwesomeCatalog', [payloadParam], 'AwesomeCatalogResult'),
111
+ mv('wbAwesomeDetail', [payloadParam], 'AwesomeDetailResult'),
112
+ mv('wbAwesomeUpdateCheck', [], 'WbUpdateHintResult'),
97
113
  // WorkBuddy 专家直连(召唤预设 + 捆绑技能)。payload 全部 $any:数据形状由
98
114
  // lib/expert-market.js 校验,与 wb* 系列同一宽容度。
99
115
  mv('wbExpertCatalog', [], 'WbExpertCatalogResult'),
@@ -108,6 +124,14 @@ export const TYPERT = {
108
124
  mv('expertAttach', [payloadParam], 'NoteResult'),
109
125
  mv('expertDetach', [payloadParam], 'NoteResult'),
110
126
  mv('expertActive', [], 'ExpertActiveResult'),
127
+ // R8:连接器 MCP 的浏览器授权(RFC 9728/8414/7591/7636)。副作用是起本地回调
128
+ // 监听 + 拉系统浏览器,所以 begin/poll 都是独立 RPC,面板按需轮询。
129
+ mv('wbOAuthBegin', [payloadParam], 'NoteResult'),
130
+ mv('wbOAuthPoll', [payloadParam], 'NoteResult'),
131
+ mv('wbOAuthCancel', [payloadParam], 'NoteResult'),
132
+ mv('wbOAuthToken', [payloadParam], 'NoteResult'),
133
+ mv('wbOAuthOverview', [payloadParam], 'NoteResult'),
134
+ mv('wbOAuthOpen', [payloadParam], 'NoteResult'),
111
135
  ],
112
136
  model: { services: [], events: [], objects: [] },
113
137
  }
@@ -0,0 +1,128 @@
1
+ // auth_injection_rules 解析 + mcpServers token 占位符工具(R1 / R7 的纯函数部分)。
2
+ //
3
+ // 设计边界(X9):WB 的 auth_injection_rules 是 WB 账号体系的**代注入**机制,DSH 不做
4
+ // 注入——本模块只解析、只关联、只展示(R1);R7 的令牌引导安装用的是**用户自填 token**
5
+ // 对 `${...}` 占位符的显式替换,与 WB 代注入是两回事。
6
+ //
7
+ // 纯函数库:零 IO、零网络、零 Remote/UI 依赖。所有函数对畸形输入一律防御性返回
8
+ // 空集合/原样副本,绝不抛错——上游 JSON 是远端 zip 里的内容,任何形状都可能出现。
9
+ //
10
+ // 实测参考结构(仅防御解析参考,不硬编码依赖):
11
+ // 规则 = { id, when:{active_in:[]}, applies_to_connectors:[], requires_connected_connectors:[],
12
+ // timing:"request", inject:[{from_connector, token_type, header, value_template}] }
13
+
14
+ /**
15
+ * 解析 connectors.json 顶层的 auth_injection_rules 数组。
16
+ * 非数组 / 字段缺失 / 元素不是对象 → 返回 [](绝不抛错)。
17
+ * 元素原样保留(下游只读 id / applies_to_connectors / inject 等字段做展示)。
18
+ */
19
+ export function parseInjectionRules(indexJson) {
20
+ const rules = indexJson?.auth_injection_rules;
21
+ if (!Array.isArray(rules)) return [];
22
+ return rules.filter((rule) => rule && typeof rule === "object" && !Array.isArray(rule));
23
+ }
24
+
25
+ /**
26
+ * 按 connectorId 命中规则:applies_to_connectors 数组包含该 id 即命中。
27
+ * 返回命中规则数组(空输入 / 非数组 applies_to_connectors 一律不命中)。
28
+ */
29
+ export function rulesFor(rules, connectorId) {
30
+ if (!Array.isArray(rules)) return [];
31
+ const id = String(connectorId ?? "").trim();
32
+ if (!id) return [];
33
+ return rules.filter((rule) => {
34
+ const applies = rule?.applies_to_connectors;
35
+ return Array.isArray(applies) && applies.map((entry) => String(entry ?? "").trim()).includes(id);
36
+ });
37
+ }
38
+
39
+ /** 一个待填 token 槽位:占位符在哪个 server 的哪个容器字段里。 */
40
+ // 形状:{ server, container: "env"|"header", key, template, placeholder, value }
41
+
42
+ const PLACEHOLDER_PATTERN = /\$\{([^}]+)\}/g;
43
+ // 非全局版:.test() 不会推进 lastIndex(全局正则连续 test 会漏判残留占位符)。
44
+ const HAS_PLACEHOLDER = /\$\{[^}]+\}/;
45
+
46
+ /**
47
+ * 扫描 mcpServers 的 env 与 headers 值里的 `${...}` 模板占位符。
48
+ * 返回槽位清单:[{ server, container: "env"|"header", key, template, placeholder, value }]
49
+ * - container 字段容器:env → env 对象;header → headers 对象(对外统一命名)
50
+ * - template 原始模板串(如 "Bearer ${token}")
51
+ * - placeholder 第一个占位符(如 "token");模板无占位符的条目不进清单
52
+ * - value 原值(供 applyTokens 做精确字符串替换与回显)
53
+ * mcpServers / env / headers 畸形(null、数组、值非字符串)一律跳过,不抛错。
54
+ */
55
+ export function scanTokenSlots(mcpServers) {
56
+ if (!mcpServers || typeof mcpServers !== "object" || Array.isArray(mcpServers)) return [];
57
+ const slots = [];
58
+ for (const [server, rawServer] of Object.entries(mcpServers)) {
59
+ // transport 可能嵌套(wb-market.serverInstallability 的 nested 形态):env/headers
60
+ // 在 server 本体或 server.transport 上都认。
61
+ const nested = rawServer && typeof rawServer === "object" && typeof rawServer.transport === "object" && rawServer.transport !== null
62
+ ? rawServer.transport
63
+ : (typeof rawServer === "object" && rawServer !== null ? rawServer : null);
64
+ if (!nested) continue;
65
+ const bags = [["env", nested.env], ["header", nested.headers]];
66
+ for (const [container, bag] of bags) {
67
+ if (!bag || typeof bag !== "object" || Array.isArray(bag)) continue;
68
+ for (const [key, value] of Object.entries(bag)) {
69
+ if (typeof value !== "string") continue;
70
+ const matches = [...value.matchAll(PLACEHOLDER_PATTERN)];
71
+ if (!matches.length) continue;
72
+ slots.push({
73
+ server,
74
+ container,
75
+ key,
76
+ template: value,
77
+ placeholder: matches[0][1].trim(),
78
+ value,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ return slots;
84
+ }
85
+
86
+ /**
87
+ * 按槽位把占位符替换为用户 token,产出「可安全写盘」的 mcpServers 副本。
88
+ * - tokens: Record<placeholder, value>,键为占位符名(如 { access_token: "xxx" })。
89
+ * - 未提供 token 的槽位**保留原样**并在 missingSlots 里标记(供 UI 提示缺槽)。
90
+ * 返回 { mcpServers, missingSlots }:
91
+ * mcpServers 深拷贝后的替换结果(原对象永不改动)
92
+ * missingSlots [{ server, container, key, placeholder, template }] 未填槽位清单
93
+ * 输入畸形时返回 { mcpServers: {}, missingSlots: [] };对每个槽位同时应用模板内**所有**
94
+ * 占位符("Bearer ${a}/${b}" 两处都能换),全替换后仍含 `${` 的值按未填槽位上报。
95
+ */
96
+ export function applyTokens(mcpServers, tokens) {
97
+ const source = mcpServers && typeof mcpServers === "object" && !Array.isArray(mcpServers) ? mcpServers : {};
98
+ const tokenMap = tokens && typeof tokens === "object" && !Array.isArray(tokens) ? tokens : {};
99
+ const missingSlots = [];
100
+ const result = structuredClone(source);
101
+ const slots = scanTokenSlots(result);
102
+ for (const slot of slots) {
103
+ const target = slot.container === "header"
104
+ ? result[slot.server]?.transport?.headers ?? result[slot.server]?.headers
105
+ : result[slot.server]?.transport?.env ?? result[slot.server]?.env;
106
+ if (!target || typeof target !== "object") continue;
107
+ let filled = String(target[slot.key]);
108
+ for (const match of filled.matchAll(PLACEHOLDER_PATTERN)) {
109
+ const token = tokenMap[match[1].trim()];
110
+ if (typeof token === "string" && token.length) {
111
+ filled = filled.split(match[0]).join(token);
112
+ }
113
+ }
114
+ if (HAS_PLACEHOLDER.test(filled)) {
115
+ // 仍有残留占位符 → 该槽位缺 token:保留原样并上报。
116
+ missingSlots.push({
117
+ server: slot.server,
118
+ container: slot.container,
119
+ key: slot.key,
120
+ placeholder: slot.placeholder,
121
+ template: slot.template,
122
+ });
123
+ } else {
124
+ target[slot.key] = filled;
125
+ }
126
+ }
127
+ return { mcpServers: result, missingSlots };
128
+ }
@@ -0,0 +1,453 @@
1
+ // awesome-dsh-plugins 市场数据层(T01,docs/system-design-awesome.md)。
2
+ //
3
+ // 与既有 codebuddy 插件市场(lib/wb-plugin-market.js 的 zip 管线)完全解耦:
4
+ // awesome 源是单文件裸 JSON(~13.4MB,全量 5 千余条,其中 unverified/broken 占
5
+ // 绝大多数),本模块只做「拉取链 → 瘦身过滤 → 原子缓存」三件事,纯数据层,
6
+ // 不做安装、不做 UI 投影。纪律(与 wb-registry.js 同款):
7
+ // - 源 URL 只活在本文件常量 AWESOME_SOURCES 里(裁定 1:registry 不感知 awesome,
8
+ // 禁止出现第二处硬编码真相)。
9
+ // - 缓存目录 <root>/awesome-market/:slim.json(瘦身影射数组)+ meta.json
10
+ // ({etag, fetchedAt, updated, counts}),落盘一律 staging + rm + rename 原子写。
11
+ // - 测试可通过 options.rootDir 或 DSH_ARMORY_HOME 把根目录指到临时目录,
12
+ // 绝不碰真实 ~/.dsh。
13
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ /** 市场标识:client 接收 catalog 响应后按 res.marketplace 给条目统一贴标。 */
18
+ export const AWESOME_MARKETPLACE_ID = "awesome-dsh-plugins";
19
+
20
+ // 双 CDN 拉取链(jsdelivr 主源 → fastly 备源,按序尝试,单源失败切下一源)。
21
+ const AWESOME_SOURCES = [
22
+ "https://cdn.jsdelivr.net/gh/dshworks/awesome-dsh-plugins@main/data/plugins.json",
23
+ "https://fastly.jsdelivr.net/gh/dshworks/awesome-dsh-plugins@main/data/plugins.json",
24
+ ];
25
+
26
+ /**
27
+ * slim.json 体积验收上限(3MB)。真实 13.4MB 源过滤+瘦身后的产物约 1.5-2MB;
28
+ * 若未来源膨胀超限,降级旋钮是把 slimEntries 的 description 截断从 120 收到 80
29
+ * (本文件 truncateText 调用点),预估可再省 ~15%。不在此做动态降级逻辑,
30
+ * 体积断言由 test/wb-awesome-market.mjs 钉住。
31
+ */
32
+ export const SLIM_MAX_BYTES = 3 * 1024 * 1024;
33
+
34
+ const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)";
35
+ const DOWNLOAD_TIMEOUT_MS = 120_000; // 13.4MB 全量下载的超时余量
36
+ const PROBE_TIMEOUT_MS = 30_000; // HEAD 条件探测轻量快速
37
+
38
+ /**
39
+ * 首屏提速(stale-while-revalidate):磁盘/内存有货就立刻返回,网络再验证挪到后台。
40
+ * 缓存龄期超过该值才触发一次后台再验证,期间调用方秒回旧数据、UI 不转圈。
41
+ */
42
+ const REVALIDATE_AFTER_MS = 5 * 60_000;
43
+ /** 后台再验证失败后的退避窗口,避免每次进面板都重试两个 CDN(各 120s 超时)。 */
44
+ const REVALIDATE_BACKOFF_MS = 60_000;
45
+
46
+ /** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 wb-registry.js 同款)。 */
47
+ function awesomeRootDir(options = {}) {
48
+ return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
49
+ }
50
+
51
+ /** 缓存子目录:<root>/awesome-market。 */
52
+ function awesomeMarketDir(options = {}) {
53
+ return join(awesomeRootDir(options), "awesome-market");
54
+ }
55
+
56
+ function slimPathOf(options = {}) {
57
+ return join(awesomeMarketDir(options), "slim.json");
58
+ }
59
+
60
+ function metaPathOf(options = {}) {
61
+ return join(awesomeMarketDir(options), "meta.json");
62
+ }
63
+
64
+ /** 读 JSON 文件;不存在/损坏一律返回 null(调用方决定回落语义),永不抛错。 */
65
+ async function readJsonAt(path) {
66
+ try {
67
+ return JSON.parse(await readFile(path, "utf8"));
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** staging + rename 原子写(wb-registry.js writeAtomic 同款):先写 <file>.staging,
74
+ * Windows rename 不覆盖所以先 rm 旧文件再 rename。文本一律 UTF-8。 */
75
+ async function writeAtomic(path, text) {
76
+ const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
77
+ const dir = cut > 0 ? path.slice(0, cut) : path;
78
+ await mkdir(dir, { recursive: true });
79
+ await writeFile(`${path}.staging`, text, "utf8");
80
+ await rm(path, { force: true });
81
+ await rename(`${path}.staging`, path);
82
+ }
83
+
84
+ // ── 瘦身(纯函数)──────────────────────────────────────────────────────────
85
+
86
+ /** npm 字段防御提取:非空字符串直取;{name} 形态取 npm.name;其余出局(返回 "")。 */
87
+ function extractNpm(value) {
88
+ if (typeof value === "string") {
89
+ const trimmed = value.trim();
90
+ return trimmed || "";
91
+ }
92
+ if (value && typeof value === "object" && !Array.isArray(value)) {
93
+ if (typeof value.name === "string") {
94
+ const trimmed = value.name.trim();
95
+ return trimmed || "";
96
+ }
97
+ }
98
+ return "";
99
+ }
100
+
101
+ /** description 防御截断:非字符串回落 "";字符串截断 ≤120 字符(降级旋钮见 SLIM_MAX_BYTES)。 */
102
+ function truncateText(value, maxChars = 120) {
103
+ return typeof value === "string" ? value.slice(0, maxChars) : "";
104
+ }
105
+
106
+ /** tags 防御归一:非数组 → [];数组只留字符串且最多 2 个。 */
107
+ function normalizeTags(value) {
108
+ if (!Array.isArray(value)) return [];
109
+ return value.filter((tag) => typeof tag === "string").slice(0, 2);
110
+ }
111
+
112
+ /**
113
+ * 源数据 plugins 数组 → 瘦身影射数组(R11 口径)。
114
+ *
115
+ * 过滤:只保留 status === "verified" 且 npm 非空(对象形态取 npm.name)的条目;
116
+ * unverified/broken 在这一步出局,永不进缓存。单条畸形(name 非非空字符串等致命
117
+ * 字段缺失)直接丢弃该条,不抛错、不拖垮整目录。
118
+ *
119
+ * 映射:瘦身后 12 个字段全部必填有默认(见下)。名称排序不在此做(UI 层排序),
120
+ * 保持源顺序输出。畸形输入(非数组)返回 []。
121
+ */
122
+ export function slimEntries(rawArray) {
123
+ if (!Array.isArray(rawArray)) return [];
124
+ const slim = [];
125
+ for (const raw of rawArray) {
126
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
127
+ if (raw.status !== "verified") continue;
128
+ const npm = extractNpm(raw.npm);
129
+ if (!npm) continue;
130
+ const name = typeof raw.name === "string" ? raw.name.trim() : "";
131
+ if (!name) continue;
132
+ const category = typeof raw.category === "string" && raw.category.trim() ? raw.category.trim() : "plugin";
133
+ slim.push({
134
+ name,
135
+ repo: typeof raw.repo === "string" ? raw.repo : "",
136
+ npm,
137
+ description: truncateText(raw.description, 120),
138
+ category,
139
+ status: "verified",
140
+ tags: normalizeTags(raw.tags),
141
+ stars: typeof raw.stars === "number" && Number.isFinite(raw.stars) ? raw.stars : 0,
142
+ official: Boolean(raw.official),
143
+ verifiedAgainst: typeof raw.verifiedAgainst === "string" ? raw.verifiedAgainst : "",
144
+ lastVerified: typeof raw.lastVerified === "string" ? raw.lastVerified : "",
145
+ });
146
+ }
147
+ return slim;
148
+ }
149
+
150
+ /** 瘦身结果 → counts 统计:{ total, byCategory }。 */
151
+ function countsOf(entries) {
152
+ const byCategory = {};
153
+ for (const entry of entries) {
154
+ byCategory[entry.category] = (byCategory[entry.category] || 0) + 1;
155
+ }
156
+ return { total: entries.length, byCategory };
157
+ }
158
+
159
+ // ── 拉取链与缓存落盘 ────────────────────────────────────────────────────────
160
+
161
+ /**
162
+ * 按 AWESOME_SOURCES 顺序拉取:单源失败(网络异常 / 非 2xx / 响应体畸形)→ 试下一源。
163
+ * 带 If-None-Match 条件头(仅当 etag 非空);304 且有 etag → { ok, notModified }。
164
+ * 全源失败 → { ok: false, error }。下载全程带 120s 超时。
165
+ */
166
+ async function fetchChain(etag) {
167
+ const headers = { "user-agent": USER_AGENT };
168
+ if (etag) headers["if-none-match"] = etag;
169
+ let lastError = "";
170
+ for (const sourceUrl of AWESOME_SOURCES) {
171
+ let response;
172
+ try {
173
+ response = await fetch(sourceUrl, { headers, signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
174
+ } catch (error) {
175
+ lastError = `${sourceUrl} → ${error.message}`;
176
+ continue;
177
+ }
178
+ if (response.status === 304 && etag) return { ok: true, notModified: true };
179
+ if (!response.ok) {
180
+ lastError = `${sourceUrl} → HTTP ${response.status}`;
181
+ continue;
182
+ }
183
+ try {
184
+ const text = Buffer.from(await response.arrayBuffer()).toString("utf8");
185
+ const data = JSON.parse(text);
186
+ if (!data || typeof data !== "object" || Array.isArray(data) || !Array.isArray(data.plugins)) {
187
+ throw new Error("响应缺少 plugins 数组");
188
+ }
189
+ return { ok: true, data, etag: response.headers.get("etag") || "" };
190
+ } catch (error) {
191
+ lastError = `${sourceUrl} → ${error.message}`;
192
+ continue;
193
+ }
194
+ }
195
+ return { ok: false, error: `全部 awesome 源拉取失败:${lastError}` };
196
+ }
197
+
198
+ /** 瘦身结果 + 元信息原子落盘(slim.json + meta.json),返回 counts。
199
+ * 同时刷新进程内缓存层,后续调用免掉 readFile + JSON.parse。 */
200
+ async function persistSlim(entries, etag, updated, options = {}) {
201
+ const counts = countsOf(entries);
202
+ const fetchedAt = Date.now();
203
+ await writeAtomic(slimPathOf(options), JSON.stringify(entries));
204
+ await writeAtomic(metaPathOf(options), JSON.stringify({ etag, fetchedAt, updated, counts }));
205
+ setMemSlim(options, entries, { etag, fetchedAt, updated, counts });
206
+ return counts;
207
+ }
208
+
209
+ // ── 进程内缓存 + 单航班后台再验证 ─────────────────────────────────────────
210
+ // slim.json 约 1.5-2MB,每次 catalog 都 readFile + JSON.parse 纯属浪费;更要命的是
211
+ // 过去「有缓存也先等一次条件 GET」(两个源串行,最坏 2 × 120s)才肯返回,这是
212
+ // 「市场初次加载慢」的服务端一半。现在内存层命中即返回,过期后只后台再验证。
213
+ let memSlim = null; // { rootKey, entries, meta }
214
+ let revalidating = null; // Promise | null(单航班,避免并发重复拉取)
215
+ let revalidateFailedAt = 0;
216
+
217
+ /** 内存层分键:不同 rootDir(测试/多实例)不得串味。 */
218
+ function memKeyOf(options = {}) {
219
+ return awesomeMarketDir(options);
220
+ }
221
+
222
+ function getMemSlim(options = {}) {
223
+ const key = memKeyOf(options);
224
+ return memSlim && memSlim.rootKey === key ? memSlim : null;
225
+ }
226
+
227
+ function setMemSlim(options = {}, entries, meta) {
228
+ memSlim = { rootKey: memKeyOf(options), entries, meta: meta || {} };
229
+ }
230
+
231
+ /** 缓存龄期(ms);meta 无有效 fetchedAt 视为无限陈旧。 */
232
+ function ageOfMeta(meta) {
233
+ const at = Number(meta && meta.fetchedAt);
234
+ return Number.isFinite(at) && at > 0 ? Date.now() - at : Number.POSITIVE_INFINITY;
235
+ }
236
+
237
+ /**
238
+ * 读瘦身缓存:slim.json 可解析为数组即视为有缓存;meta.json 缺失/损坏回落 {}。
239
+ * 两个文件任一损坏都不抛错。`useMem: false` 用于强制走磁盘(refresh 路径要看到
240
+ * 外部刚写入的缓存,不能被进程内旧值遮住)。
241
+ */
242
+ async function readSlimCache(options = {}, { useMem = true } = {}) {
243
+ if (useMem) {
244
+ const mem = getMemSlim(options);
245
+ if (mem) return { entries: mem.entries, meta: mem.meta };
246
+ }
247
+ const entries = await readJsonAt(slimPathOf(options));
248
+ if (!Array.isArray(entries)) return null;
249
+ const meta = (await readJsonAt(metaPathOf(options))) || {};
250
+ setMemSlim(options, entries, meta);
251
+ return { entries, meta };
252
+ }
253
+
254
+ /**
255
+ * 后台再验证:不阻塞调用方;单航班 + 失败退避。
256
+ * 成功时 refreshFromNetwork 已把新数据原子落盘并刷新内存层,下次请求即拿新结果;
257
+ * 失败只静默保留旧缓存(陈旧但可用,绝不把错误抛给 UI)。
258
+ */
259
+ function revalidateInBackground(options = {}) {
260
+ if (revalidating) return revalidating;
261
+ if (revalidateFailedAt > 0 && Date.now() - revalidateFailedAt < REVALIDATE_BACKOFF_MS) return null;
262
+ revalidating = (async () => {
263
+ try {
264
+ const cache = await readSlimCache(options);
265
+ const etag = cache && typeof cache.meta.etag === "string" ? cache.meta.etag : "";
266
+ const chain = await fetchChain(etag);
267
+ if (!chain.ok) {
268
+ revalidateFailedAt = Date.now();
269
+ return;
270
+ }
271
+ if (!chain.notModified) await refreshFromNetwork(chain, options);
272
+ revalidateFailedAt = 0;
273
+ } catch {
274
+ revalidateFailedAt = Date.now();
275
+ } finally {
276
+ revalidating = null;
277
+ }
278
+ })();
279
+ return revalidating;
280
+ }
281
+
282
+ /**
283
+ * 304 命中:内容未变,只把 meta.fetchedAt 推到当前时刻以重置 stale 计时
284
+ * (否则缓存会永远被判为「陈旧」,每次进面板都白跑一次网络验证)。
285
+ * 写盘失败不影响返回值:内存层已更新。
286
+ */
287
+ async function touchSlimMeta(options = {}, cache) {
288
+ const meta = { ...(cache.meta || {}), fetchedAt: Date.now() };
289
+ setMemSlim(options, cache.entries, meta);
290
+ try {
291
+ await writeAtomic(metaPathOf(options), JSON.stringify(meta));
292
+ } catch {
293
+ /* 元信息落盘失败可接受:下次仍会再验证一次 */
294
+ }
295
+ return meta;
296
+ }
297
+
298
+ /** 统一组装 catalog 响应体(cache/disk 复用):counts 优先用 meta 里落盘的统计。 */
299
+ function catalogResult(entries, meta, loadedFrom, extra = {}) {
300
+ const counts =
301
+ meta && typeof meta.counts === "object" && meta.counts !== null && Number.isFinite(meta.counts.total)
302
+ ? meta.counts
303
+ : countsOf(entries);
304
+ const fetchedAt = Number(meta && meta.fetchedAt);
305
+ return {
306
+ ok: true,
307
+ loadedFrom,
308
+ updated: typeof meta.updated === "string" ? meta.updated : "",
309
+ fetchedAt: Number.isFinite(fetchedAt) && fetchedAt > 0 ? fetchedAt : 0,
310
+ counts,
311
+ plugins: entries,
312
+ marketplace: AWESOME_MARKETPLACE_ID,
313
+ ...extra,
314
+ };
315
+ }
316
+
317
+ /** 拉取链成功后的统一走线:瘦身 → 原子落盘 → 组装 network 响应。 */
318
+ async function refreshFromNetwork(chain, options = {}) {
319
+ const entries = slimEntries(chain.data.plugins);
320
+ const counts = await persistSlim(entries, chain.etag, typeof chain.data.updated === "string" ? chain.data.updated : "", options);
321
+ return {
322
+ ok: true,
323
+ loadedFrom: "network",
324
+ updated: typeof chain.data.updated === "string" ? chain.data.updated : "",
325
+ fetchedAt: Date.now(),
326
+ counts,
327
+ plugins: entries,
328
+ marketplace: AWESOME_MARKETPLACE_ID,
329
+ };
330
+ }
331
+
332
+ // ── 四个对外 API ───────────────────────────────────────────────────────────
333
+
334
+ /**
335
+ * 读 awesome 目录(缓存优先 + 后台再验证 → 首屏秒出):
336
+ * 1. refresh: true(UI「重新加载」按钮)→ 同步强刷:条件 GET,304 复用缓存
337
+ * (loadedFrom: "cache");200 重新瘦身落盘("network");全源失败且有缓存
338
+ * 回落 "disk" + stale: true;无缓存 { ok: false }。
339
+ * 2. 缓存新鲜(龄期 < REVALIDATE_AFTER_MS)→ 直接返内存/磁盘缓存("cache"),
340
+ * 完全不碰网络。
341
+ * 3. 缓存陈旧 → 仍立刻返回旧缓存("cache" + refreshing: true),网络验证丢给
342
+ * 后台单航班;下次进面板即拿到新数据,失败静默保留旧缓存。
343
+ * 4. 无缓存(真正的首次)→ 全量拉取 → 瘦身落盘("network")。
344
+ * 响应统一带 fetchedAt(缓存抓取时刻)与(陈旧分支的)refreshing 标记,
345
+ * 供 UI 直接显示「后台更新中」而无需二次请求。
346
+ * 永不抛错(拉取/解析/写盘异常都折进返回体)。
347
+ */
348
+ export async function wbAwesomeCatalog(options = {}) {
349
+ const wantRefresh = Boolean(options) && options.refresh === true;
350
+ try {
351
+ const cache = await readSlimCache(options, { useMem: !wantRefresh });
352
+ if (cache) {
353
+ const etag = typeof cache.meta.etag === "string" ? cache.meta.etag : "";
354
+ if (!wantRefresh && ageOfMeta(cache.meta) < REVALIDATE_AFTER_MS) {
355
+ return catalogResult(cache.entries, cache.meta, "cache");
356
+ }
357
+ if (!wantRefresh) {
358
+ // 陈旧缓存:先秒回旧数据,网络验证交给后台(单航班 + 失败退避)。
359
+ revalidateInBackground(options);
360
+ return catalogResult(cache.entries, cache.meta, "cache", { refreshing: true });
361
+ }
362
+ const chain = await fetchChain(etag);
363
+ if (chain.ok && chain.notModified) return catalogResult(cache.entries, await touchSlimMeta(options, cache), "cache");
364
+ if (chain.ok) return await refreshFromNetwork(chain, options);
365
+ // 全源失败但磁盘有货:返回陈旧缓存,stale 标记交给 UI 出提示条。
366
+ return catalogResult(cache.entries, cache.meta, "disk", { stale: true });
367
+ }
368
+ const chain = await fetchChain("");
369
+ if (!chain.ok) return { ok: false, error: chain.error };
370
+ return await refreshFromNetwork(chain, options);
371
+ } catch (error) {
372
+ return { ok: false, error: `awesome 目录加载异常:${error.message}` };
373
+ }
374
+ }
375
+
376
+ /** 按名字查瘦身缓存单条;无缓存先 ensureSlim(走 wbAwesomeCatalog 全量链);
377
+ * 名字不存在 / 加载失败返回 null。 */
378
+ export async function wbAwesomeDetail(name, options = {}) {
379
+ const wanted = typeof name === "string" ? name.trim() : "";
380
+ if (!wanted) return null;
381
+ try {
382
+ let cache = await readSlimCache(options);
383
+ if (!cache) {
384
+ const fresh = await wbAwesomeCatalog(options);
385
+ if (!fresh.ok) return null;
386
+ return fresh.plugins.find((entry) => entry.name === wanted) ?? null;
387
+ }
388
+ return cache.entries.find((entry) => entry.name === wanted) ?? null;
389
+ } catch {
390
+ return null;
391
+ }
392
+ }
393
+
394
+ /**
395
+ * ETag 条件探测(wbPluginUpdateCheck 同款语义,源固定取 AWESOME_SOURCES[0]):
396
+ * **绝不落盘**,只取 headers。reason 五枚举:no-source / probe-failed / no-cache /
397
+ * up-to-date / update-available —— 本实现源是模块常量恒有源,no-source 分支不出现,
398
+ * 保留枚举位仅为与 UI 侧契约对齐(wb-update-hint 的 reason 映射表)。
399
+ * 无缓存(meta 无 etag)→ hasUpdate: false + no-cache(与插件市场不同:awesome 首次
400
+ * 由 catalog 链兜底加载,探测层不引导更新)。
401
+ */
402
+ export async function wbAwesomeUpdateCheck(options = {}) {
403
+ try {
404
+ const meta = (await readJsonAt(metaPathOf(options))) || {};
405
+ const localEtag = typeof meta.etag === "string" ? meta.etag : "";
406
+ if (!localEtag) {
407
+ return { ok: true, hasUpdate: false, reason: "no-cache", localEtag: "", remoteEtag: "", checkedAt: Date.now() };
408
+ }
409
+ const sourceUrl = AWESOME_SOURCES[0];
410
+ const headers = { "user-agent": USER_AGENT, "if-none-match": localEtag };
411
+ let response;
412
+ try {
413
+ response = await fetch(sourceUrl, { method: "HEAD", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
414
+ if (response.status === 405 || response.status === 501) {
415
+ // HEAD 不被支持 → 降级 GET,立刻 cancel() 丢弃响应体,不读进内存。
416
+ response.body?.cancel?.();
417
+ response = await fetch(sourceUrl, { method: "GET", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
418
+ response.body?.cancel?.();
419
+ }
420
+ } catch (error) {
421
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `更新探测失败:${error.message}` };
422
+ }
423
+ if (response.status === 304) {
424
+ // 304:ETag 命中,服务端不回显 ETag;回显 localEtag 便于客户端展示。
425
+ return { ok: true, hasUpdate: false, reason: "up-to-date", localEtag, remoteEtag: localEtag, checkedAt: Date.now() };
426
+ }
427
+ if (response.ok) {
428
+ const remoteEtag = response.headers.get("etag") || "";
429
+ if (!remoteEtag) {
430
+ // 探测只信 ETag:响应没有 ETag 就无法判定,宁可报探测失败也不误报「有更新」。
431
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: "更新探测失败:响应缺少 ETag" };
432
+ }
433
+ const hasUpdate = remoteEtag !== localEtag;
434
+ return { ok: true, hasUpdate, reason: hasUpdate ? "update-available" : "up-to-date", localEtag, remoteEtag, checkedAt: Date.now() };
435
+ }
436
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `awesome 市场更新探测失败:HTTP ${response.status}` };
437
+ } catch (error) {
438
+ return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag: "", remoteEtag: "", checkedAt: Date.now(), error: `更新探测异常:${error.message}` };
439
+ }
440
+ }
441
+
442
+ /** 缓存元信息(只读,不联网):{ hasCache, etag, fetchedAt, updated }。 */
443
+ export async function wbAwesomeCachedInfo(options = {}) {
444
+ const cache = await readSlimCache(options);
445
+ if (!cache) return { hasCache: false, etag: "", fetchedAt: 0, updated: "" };
446
+ const meta = cache.meta;
447
+ return {
448
+ hasCache: true,
449
+ etag: typeof meta.etag === "string" ? meta.etag : "",
450
+ fetchedAt: typeof meta.fetchedAt === "number" ? meta.fetchedAt : 0,
451
+ updated: typeof meta.updated === "string" ? meta.updated : "",
452
+ };
453
+ }