dsh-armory 1.9.4 → 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.
@@ -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
+ }
@@ -0,0 +1,151 @@
1
+ // 本地安装状态 + 来源锁(R8)—— ~/.dsh/armory/install-state.json 的读写层。
2
+ //
3
+ // 职责(设计文档 docs/system_design.md 8.3 / 类图 InstallStateStore):
4
+ // - record(item):按 kind+key 定位,已存在则覆盖并刷新 lastUpdated;
5
+ // 首次写入补 installedAt。connector 不记 filesDigest,plugin/skill 记关键文件摘要。
6
+ // - forget(kind, key):删除一条登记,不存在时为无害空操作。
7
+ // - listInstalled(kind):返回该 kind 全部条目(顺序 = 写入序),供面板做「已安装」
8
+ // **全量本地筛选**(不受分页影响,与技能侧 skillOnlyInstalled 只筛当前页不同)。
9
+ //
10
+ // 三类安装(connector=profile、skill=目录、plugin=宿主 bundle)在各自收尾点调用 record,
11
+ // 把分散的安装事实收敛到这一个文件里——这就是「来源锁」:登记条目带 source
12
+ // {marketplace, url, kind},面板据此区分「本插件装的」与「其它途径来的」。
13
+ //
14
+ // 纯数据层:零网络。存储根目录可注入(options.rootDir / 环境变量 DSH_ARMORY_HOME),
15
+ // 测试绝不碰真实 ~/.dsh。写盘与 lib/wb-registry.js 同用 staging + rename 原子替换。
16
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ export const INSTALL_STATE_VERSION = 1;
21
+
22
+ const KINDS = new Set(["connector", "skill", "plugin"]);
23
+
24
+ /** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 wb-registry 同一解析序)。 */
25
+ function armoryRoot(options = {}) {
26
+ return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
27
+ }
28
+
29
+ /** 状态文件绝对路径。 */
30
+ export function statePath(options = {}) {
31
+ return join(armoryRoot(options), "install-state.json");
32
+ }
33
+
34
+ function asString(value, fallback = "") {
35
+ return typeof value === "string" ? value : fallback;
36
+ }
37
+
38
+ function asTimestamp(value, fallback = 0) {
39
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
40
+ }
41
+
42
+ /**
43
+ * 把任意原始对象规范成一条完整的 InstallStateItem:
44
+ * 缺失字段防御性补全,畸形来源对象折成空对象而不是抛错。
45
+ */
46
+ export function normalizeItem(raw) {
47
+ const source = raw && typeof raw.source === "object" && raw.source !== null ? raw.source : {};
48
+ const filesDigest = raw && typeof raw.filesDigest === "object" && raw.filesDigest !== null && !Array.isArray(raw.filesDigest)
49
+ ? raw.filesDigest
50
+ : {};
51
+ return {
52
+ kind: KINDS.has(raw?.kind) ? raw.kind : "connector",
53
+ key: asString(raw?.key).trim(),
54
+ name: asString(raw?.name),
55
+ scope: asString(raw?.scope, "managed"),
56
+ installPath: asString(raw?.installPath),
57
+ version: asString(raw?.version),
58
+ dependencies: Array.isArray(raw?.dependencies) ? raw.dependencies.map((dep) => String(dep)) : [],
59
+ source: {
60
+ marketplace: asString(source.marketplace),
61
+ url: asString(source.url),
62
+ kind: asString(source.kind),
63
+ },
64
+ installedAt: asTimestamp(raw?.installedAt, 0),
65
+ lastUpdated: asTimestamp(raw?.lastUpdated, 0),
66
+ filesDigest,
67
+ };
68
+ }
69
+
70
+ /** 从磁盘读原始文档;不存在/损坏一律返回 null。 */
71
+ async function readRawDoc(path) {
72
+ try {
73
+ return JSON.parse(await readFile(path, "utf8"));
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** 拆出目录部分(路径既有 / 也有 \ 的 Windows 形态)。 */
80
+ function dirnameOf(path) {
81
+ const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
82
+ return cut > 0 ? path.slice(0, cut) : path;
83
+ }
84
+
85
+ /** staging + rename 原子写(与 wb-registry.writeAtomic 同一模式)。 */
86
+ async function writeAtomic(path, doc) {
87
+ await mkdir(dirnameOf(path), { recursive: true });
88
+ await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
89
+ await rm(path, { force: true });
90
+ await rename(`${path}.staging`, path);
91
+ }
92
+
93
+ /** 读全部条目(合并投影:文件缺失/损坏 = 空状态)。永不抛错。 */
94
+ export async function readState(options = {}) {
95
+ const raw = await readRawDoc(statePath(options));
96
+ const items = Array.isArray(raw?.items)
97
+ ? raw.items.filter((item) => item && typeof item === "object" && !Array.isArray(item)).map(normalizeItem)
98
+ : [];
99
+ const version = raw && typeof raw.version === "number" ? raw.version : INSTALL_STATE_VERSION;
100
+ return { version, items };
101
+ }
102
+
103
+ /**
104
+ * 登记一条安装:同 kind+key 覆盖(刷新 lastUpdated,保留首次的 installedAt),
105
+ * 首次写入补 installedAt。connector 不落 filesDigest(连接器装进 profile,
106
+ * 没有关键文件可记);plugin/skill 原样保留传入的 filesDigest。
107
+ */
108
+ export async function record(item, options = {}) {
109
+ const normalized = normalizeItem(item);
110
+ if (!normalized.key) throw new Error("安装状态条目缺少 key,无法登记");
111
+ const path = statePath(options);
112
+ const { items } = await readState(options);
113
+ const now = Date.now();
114
+ const index = items.findIndex((entry) => entry.kind === normalized.kind && entry.key === normalized.key);
115
+ if (index >= 0) {
116
+ const existing = items[index];
117
+ items[index] = {
118
+ ...normalized,
119
+ installedAt: existing.installedAt || now,
120
+ lastUpdated: now,
121
+ filesDigest: normalized.kind === "connector" ? {} : (Object.keys(normalized.filesDigest).length ? normalized.filesDigest : existing.filesDigest),
122
+ };
123
+ } else {
124
+ items.push({
125
+ ...normalized,
126
+ installedAt: now,
127
+ lastUpdated: now,
128
+ filesDigest: normalized.kind === "connector" ? {} : normalized.filesDigest,
129
+ });
130
+ }
131
+ await writeAtomic(path, { version: INSTALL_STATE_VERSION, items });
132
+ }
133
+
134
+ /** 删除一条登记(kind+key 定位)。不存在时为无害空操作。 */
135
+ export async function forget(kind, key, options = {}) {
136
+ const wantedKind = String(kind ?? "").trim();
137
+ const wantedKey = String(key ?? "").trim();
138
+ if (!wantedKind || !wantedKey) throw new Error("forget 需要 kind 与 key");
139
+ const path = statePath(options);
140
+ const { items } = await readState(options);
141
+ const kept = items.filter((entry) => !(entry.kind === wantedKind && entry.key === wantedKey));
142
+ if (kept.length === items.length) return; // 没删到东西:不写盘
143
+ await writeAtomic(path, { version: INSTALL_STATE_VERSION, items: kept });
144
+ }
145
+
146
+ /** 该 kind 的全部已安装条目(供「已安装」全量筛选)。 */
147
+ export async function listInstalled(kind, options = {}) {
148
+ const wanted = String(kind ?? "").trim();
149
+ const { items } = await readState(options);
150
+ return items.filter((entry) => entry.kind === wanted);
151
+ }