dsh-armory 2.3.0 → 2.3.1

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/mcp-config.js CHANGED
@@ -1,79 +1,14 @@
1
1
  import { parseDocument, Scalar, YAMLMap, YAMLSeq, isMap, isSeq } from 'yaml'
2
2
  import { isSafeExpression, makeEnvExpression, makeExpression } from './env-expression.js'
3
- import { MCP_CATALOG, CATEGORY_ORDER, CATEGORY_LABELS, catalogById } from './mcp-catalog.js'
4
- import { MCP_CATALOG_EN, CATEGORY_LABELS_EN, CATALOG_LABELS_EN } from './mcp-catalog-en.js'
5
3
 
6
4
  export const MCP_PLUGIN_NAME = '@deepseek-ai/dsh-mcp-client'
7
5
  const JS_TAG = 'tag:yaml.org,2002:js'
8
6
  const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/
9
7
  const MAX_TIMER_DELAY_MS = 2147483647
10
8
  const MANAGED_CONFIG_FIELDS = ['transport', 'command', 'args', 'env', 'cwd', 'url', 'headers', 'toolCallTimeoutMs', 'failOnStartupError', 'reconnect']
11
- // 分类取值域与标签都来自生成的目录文件:那里是唯一真相源,顺序由生成器的 CATEGORY_ORDER
12
- // 决定(生成器会按它重排 MCP_CATALOG,面板的 chips 因此取到的首次出现序就是它)。
13
- // 再抄一份到这里就会漂移(加了分类忘了改第二处,市场的 chips 会缺一个分组)。
14
- export const MCP_CATEGORIES = CATEGORY_ORDER
15
- export const MCP_CATEGORY_LABELS = CATEGORY_LABELS
16
9
  const INTERPOLATION = /\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}/g
17
10
  const UNSUPPORTED_TEMPLATE = '不支持的变量表达式(只允许 ${VAR}、${VAR:-fallback} 与 ${env:VAR})'
18
11
 
19
- // 内置目录 = 市场目录的投影。以前这里是硬编码的 5 条,现在是 `lib/mcp-catalog.js`
20
- // 的派生视图:目录加一条,内置清单与身份识别表同时跟上,不需要在两处各改一遍。
21
- //
22
- // `name`(写进配置的 serverName)取 id,是刻意的:id 一旦发布就不能改,而 serverName
23
- // 决定工具名 `mcp__<serverName>__<tool>`,让两者同源就不会出现「目录改名导致老配置变孤儿」。
24
- export const BUILTIN_MCP_SERVERS = MCP_CATALOG.map((entry) => {
25
- // P1:英文覆盖与生成目录分开维护(lib/mcp-catalog-en.js)。生成器没有随本仓库
26
- // 分发,直接改 mcp-catalog.js 会在下次生成时被冲掉;覆盖表缺失时逐字段回落中文。
27
- const en = MCP_CATALOG_EN[entry.id] || {}
28
- const server = {
29
- id: entry.id,
30
- label: entry.label,
31
- labelEn: CATALOG_LABELS_EN[entry.id] || entry.label,
32
- summary: entry.summary,
33
- summaryEn: en.summary || entry.summary,
34
- access: entry.access,
35
- accessEn: en.access || entry.access,
36
- // OPT-24:`auth` 是给面板判断「当前宿主装不装得上」的展示元数据,不是配置字段——
37
- // `builtinSpec()` 只挑 MANAGED_CONFIG_FIELDS、`makeEntry()` 只挑固定字段表,两者都
38
- // 不会带它,所以它和 authNote 落不进用户配置文件,mcp-client 收不到不认识的键。
39
- auth: entry.auth,
40
- // authNoteEn 与 authNote 同生同灭:`none` 条目没有说明,补空串会让面板分不清
41
- // 「没有说明」和「说明是空的」。
42
- ...(entry.authNote !== undefined ? { authNoteEn: en.authNote || entry.authNote } : {}),
43
- name: entry.id,
44
- transport: entry.transport,
45
- }
46
- for (const field of ['url', 'headers', 'command', 'args', 'authNote']) {
47
- if (entry[field] !== undefined) server[field] = structuredClone(entry[field])
48
- }
49
- return server
50
- })
51
-
52
- /**
53
- * 「这个已配置的 server 是不是目录里的某一条」——按 endpoint / 包名认,不只看名字。
54
- *
55
- * 从目录派生而不是手工维护:以前这张表只有 5 条、和上面的硬编码清单两处同步,加一条就得
56
- * 记得改两个地方。现在 hosts 取自 url、packages 取自目录的 `packages`(stdio 型可从 args
57
- * 推导),新增条目自动生效。
58
- *
59
- * 为什么需要它:用户可能把 Exa 配成了 `my-exa`。安装时靠它识别「已经装过」而跳过,
60
- * 而不是再插入一条重复的。
61
- */
62
- const BUILTIN_MCP_IDENTITIES = Object.fromEntries(MCP_CATALOG.map((entry) => {
63
- const hosts = []
64
- if (typeof entry.url === 'string') {
65
- try {
66
- hosts.push(new URL(entry.url).hostname.toLowerCase())
67
- } catch { /* url 不是合法 URL 时没有可认领的 host,交给 packages 兜底 */ }
68
- }
69
- // 包名优先取目录里显式写的(HTTP 型也可能有对应的本地 npx 包,那层知识推不出来),
70
- // 没有才从 args 推导:只认非开关的参数,`-y`、`--flag` 这类不是包名。
71
- const packages = entry.packages ?? (entry.args || []).filter((arg) => typeof arg === 'string'
72
- && !arg.startsWith('-')
73
- && /^[@a-z0-9]/i.test(arg))
74
- return [entry.id, { hosts, packages }]
75
- }))
76
-
77
12
  function isObject(value) {
78
13
  return value !== null && typeof value === 'object' && !Array.isArray(value)
79
14
  }
@@ -517,109 +452,3 @@ export function updateManagedMcpPatch(content, specs, { replace = false, removeN
517
452
  }
518
453
  return String(doc)
519
454
  }
520
-
521
- function packageTokenMatches(value, packageName) {
522
- if (typeof value !== 'string') return false
523
- return value.split(/\s+/).some((token) => token === packageName || token.startsWith(`${packageName}@`))
524
- }
525
-
526
- function matchesBuiltinIdentity(server, builtin) {
527
- const name = server?.name ?? server?.serverName
528
- if (name === builtin.name) return true
529
- const identity = BUILTIN_MCP_IDENTITIES[builtin.id]
530
- const invocation = [server?.url, server?.command, ...(Array.isArray(server?.args) ? server.args : [])].filter((value) => typeof value === 'string')
531
- if (identity.hosts?.some((host) => invocation.some((value) => {
532
- try {
533
- return new URL(value).hostname.toLowerCase() === host
534
- } catch {
535
- return value.includes(`://${host}/`) || value.includes(`://${host}?`)
536
- }
537
- }))) return true
538
- return identity.packages?.some((packageName) => invocation.some((value) => packageTokenMatches(value, packageName))) === true
539
- }
540
-
541
- function builtinSpec(builtin) {
542
- const spec = { name: builtin.name }
543
- for (const field of MANAGED_CONFIG_FIELDS) {
544
- if (builtin[field] !== undefined) spec[field] = structuredClone(builtin[field])
545
- }
546
- return spec
547
- }
548
-
549
- /**
550
- * 目录 → 面板要的形状。
551
- *
552
- * 浏览器那半是预构建 bundle,**拿不到本模块的常量**(不 import Host 侧代码),所以市场的
553
- * 展示字段(分类、提供方、主页、需要哪些密钥)必须随这次 RPC 一起过去。少带一个字段的
554
- * 后果是面板侧只能显示 undefined,而那是 jsdom 看不出来的。
555
- *
556
- * @param {ReadonlyArray<object>} effectiveServers - 当前生效的 MCP 配置(用来判「已配置」)。
557
- * @returns {object[]} 面板用的目录条目。
558
- */
559
- export function builtinMcpCatalog(effectiveServers) {
560
- return BUILTIN_MCP_SERVERS.map((builtin) => {
561
- const matches = effectiveServers.filter((server) => matchesBuiltinIdentity(server, builtin))
562
- const installedAs = [...new Set(matches.map((server) => server?.name ?? server?.serverName).filter(Boolean))]
563
- const source = catalogById().get(builtin.id) || {}
564
- return {
565
- id: builtin.id,
566
- label: builtin.label,
567
- labelEn: builtin.labelEn || builtin.label,
568
- summary: builtin.summary,
569
- summaryEn: builtin.summaryEn || builtin.summary,
570
- access: builtin.access,
571
- accessEn: builtin.accessEn || builtin.access,
572
- // OPT-24:`auth` 走 builtin(BUILTIN_MCP_SERVERS 已投影),面板据此降级提示
573
- // oauth 条目;`authNote` 只在存在时挂键——`none` 条目根本没有这个字段,补成空串
574
- // 会让面板分不清「没有说明」和「说明是空的」。
575
- auth: builtin.auth,
576
- ...(builtin.authNote !== undefined ? { authNote: builtin.authNote } : {}),
577
- ...(builtin.authNoteEn !== undefined ? { authNoteEn: builtin.authNoteEn } : {}),
578
- name: builtin.name,
579
- transport: builtin.transport,
580
- configuration: builtinSpec(builtin),
581
- installed: installedAs.length > 0,
582
- installedAs,
583
- // —— 市场展示字段(bundle 侧读不到常量,只能这里带过去)——
584
- category: source.category,
585
- categoryLabel: MCP_CATEGORY_LABELS[source.category],
586
- categoryLabelEn: CATEGORY_LABELS_EN[source.category] || MCP_CATEGORY_LABELS[source.category],
587
- vendor: source.vendor,
588
- homepage: source.homepage,
589
- envKeys: source.envKeys || [],
590
- // 连接参数(HOST/PORT/USER)与凭据分开:市场对前者显示「需填连接信息」而不是
591
- // 「需密钥」——Redis / ClickHouse 是本机服务,说成要申请 Key 是误导。
592
- configKeys: source.configKeys || [],
593
- // 含 `<占位符>` 的条目(如 filesystem 的 `<允许访问的目录>`)装完**不能直接用**:
594
- // 拿占位符当真实参数启动会失败。市场要据此提示用户先编辑,而不是显示「安装成功」。
595
- placeholders: source.placeholders || [],
596
- }
597
- })
598
- }
599
-
600
- function selectedBuiltins(ids) {
601
- if (!Array.isArray(ids) || !ids.length || !ids.every((id) => typeof id === 'string')) {
602
- throw new Error('内置 MCP ids 必须是非空字符串数组')
603
- }
604
- const selectedIds = new Set(ids)
605
- const knownIds = new Set(BUILTIN_MCP_SERVERS.map((builtin) => builtin.id))
606
- const unknown = [...selectedIds].filter((id) => !knownIds.has(id))
607
- if (unknown.length) throw new Error(`未知的内置 MCP:${unknown.join(', ')}`)
608
- return BUILTIN_MCP_SERVERS.filter((builtin) => selectedIds.has(builtin.id))
609
- }
610
-
611
- export function appendSelectedBuiltinMcpServers(content, effectiveServers, ids, { reservedIds = [] } = {}) {
612
- const selected = selectedBuiltins(ids)
613
- const skipped = selected.filter((builtin) => effectiveServers.some((server) => matchesBuiltinIdentity(server, builtin)))
614
- const skippedIds = new Set(skipped.map((builtin) => builtin.id))
615
- const additions = selected.filter((builtin) => !skippedIds.has(builtin.id))
616
- const updated = additions.length
617
- ? updateManagedMcpPatch(content, additions.map(builtinSpec), { replace: false, reservedIds })
618
- : content
619
-
620
- return {
621
- content: updated,
622
- added: additions.map((builtin) => builtin.id),
623
- skipped: skipped.map((builtin) => builtin.id),
624
- }
625
- }
package/lib/mcp-oauth.js CHANGED
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
29
29
  import { join } from "node:path";
30
30
 
31
31
  /** 插件版本:DCR 的 clientInfo 与 UA 共用一处,避免两边各自漂移。 */
32
- const PLUGIN_VERSION = "2.3.0";
32
+ const PLUGIN_VERSION = "2.3.1";
33
33
  const USER_AGENT = `dsh-armory/${PLUGIN_VERSION} (+mcp-oauth)`;
34
34
  const NET_TIMEOUT_MS = 15_000;
35
35
  /** 授权流程整体存活时间:超过就判过期,避免会话表无限增长。 */
@@ -801,6 +801,4 @@ export async function authorizationOverview(serverUrl) {
801
801
  refreshable: Boolean(str(record.refreshToken)),
802
802
  savedAt: str(record.savedAt)
803
803
  };
804
- }
805
-
806
- export const __testing = { sessions, recordPathFor, buildAuthorizeUrl, tokenRecordPatch };
804
+ }
@@ -0,0 +1,262 @@
1
+ // npm 下载量(只读):插件卡片底栏那个「↓N」的唯一真实来源。
2
+ //
3
+ // 为什么单独一层:
4
+ // - 社区目录(awesome-dsh-plugins 的 slim.json)每条只带 11 个字段,**没有任何下载量**,
5
+ // 也没有插件自身的更新时间。技能卡上的 ↓8.5M 来自 SkillHub 上游,插件这边对应的
6
+ // 真实信号只有 npm 的下载统计 —— 所以去 npm 取,绝不为让卡片"看起来丰富"而造数。
7
+ // - 一页 50 张卡片,全量拉取就是 50 次外网请求;因此采集必须**按可见卡片懒加载**,
8
+ // 缓存与限流收在这一层(口径照 lib/community-meta.js:命中缓存不联网、失败回落旧数据)。
9
+ //
10
+ // 边界:
11
+ // - 只读公开接口 https://api.npmjs.org/downloads/point/last-month/<pkg>,不带任何凭据。
12
+ // - 单次最多 12 个包;进程内并发 2 + 每次请求起手间隔 ≥220ms;单请求 8 秒超时。
13
+ // 这两个数是**实测出来的**(2026-10-24,本机出口):12 个请求并发打过去 429 占一半以上,
14
+ // 而 3 req/s 连发 15 个全绿 —— 所以宁可慢一点,也不要把公开接口打到限流。
15
+ // - 数字是**近 30 天窗口**,不是累计(SkillHub 那个 ↓ 是累计)—— 所以 UI 必须带
16
+ // tooltip 说明区间,不能让它被读成"总下载量"。
17
+ // - 缓存落在 ~/.dsh/armory/npm-downloads.json,staging + rename 原子写;测试可注入
18
+ // rootDir / fetchImpl / paceMs(paceMs 只是把节奏调快,逻辑与生产同一条路)。
19
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+
23
+ export const NPM_DOWNLOADS_VERSION = 1;
24
+ // npm 的下载统计本身按天更新,7 天缓存在"数字够新"和"少打外网"之间取值。
25
+ export const NPM_DOWNLOADS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
26
+ export const NPM_DOWNLOADS_MAX_PACKAGES = 12;
27
+ // 429 之后冷却 60 秒(不是 10 分钟):实测这个限流是**速率型**的,一分钟内就回血;
28
+ // 冷却太长会让整页卡片在用户来回翻看时一直空着。
29
+ export const NPM_DOWNLOADS_COOLDOWN_MS = 60_000;
30
+ const NPM_DOWNLOADS_CONCURRENCY = 2;
31
+ const NPM_DOWNLOADS_PACE_MS = 220;
32
+ const NPM_DOWNLOADS_TIMEOUT_MS = 8_000;
33
+ const NPM_DOWNLOADS_RETRY_MS = 900;
34
+
35
+ const NPM_STATS_API = "https://api.npmjs.org/downloads/point/last-month/";
36
+ const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)";
37
+
38
+ /** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory(与 community-meta 同一解析序)。 */
39
+ function armoryRoot(options = {}) {
40
+ return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
41
+ }
42
+
43
+ export function npmDownloadsPath(options = {}) {
44
+ return join(armoryRoot(options), "npm-downloads.json");
45
+ }
46
+
47
+ /**
48
+ * npm 包名合法性:unscoped `foo`、scoped `@scope/foo`,可带 . _ - 数字。
49
+ * 返回规范化后的名字,不合法返回 ""(脏数据绝不发进外网请求)。
50
+ */
51
+ export function normalizePackageName(raw) {
52
+ const text = String(raw || "").trim();
53
+ if (!text) return "";
54
+ if (/^@[a-z0-9][a-z0-9._-]{0,39}\/[a-z0-9][a-z0-9._~!()'*-]{0,214}$/i.test(text)) return text;
55
+ if (/^[a-z0-9][a-z0-9._~!()'*-]{0,214}$/i.test(text)) return text;
56
+ return "";
57
+ }
58
+
59
+ /** 入参归一:字符串 / 数组 / {npm|package} 对象都收,去重、去空、最多 12 条。 */
60
+ export function normalizePackageList(value) {
61
+ const rows = Array.isArray(value) ? value : [value];
62
+ const seen = new Set();
63
+ const out = [];
64
+ for (const row of rows) {
65
+ const raw = row && typeof row === "object" ? row.npm || row.package || row.name || "" : row;
66
+ const name = normalizePackageName(raw);
67
+ if (!name) continue;
68
+ const key = name.toLowerCase();
69
+ if (seen.has(key)) continue;
70
+ seen.add(key);
71
+ out.push(name);
72
+ if (out.length >= NPM_DOWNLOADS_MAX_PACKAGES) break;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** npm 统计响应 → 窄投影(不落全量 JSON,缓存小且字段稳定)。 */
78
+ export function projectDownloads(pkg, data) {
79
+ const downloads = typeof data?.downloads === "number" && Number.isFinite(data.downloads) ? data.downloads : null;
80
+ return {
81
+ package: String(data?.package || pkg),
82
+ downloads,
83
+ start: typeof data?.start === "string" ? data.start : "",
84
+ end: typeof data?.end === "string" ? data.end : "",
85
+ period: "last-month",
86
+ };
87
+ }
88
+
89
+ async function readCache(options = {}) {
90
+ try {
91
+ const raw = JSON.parse(await readFile(npmDownloadsPath(options), "utf8"));
92
+ const packages = raw && typeof raw.packages === "object" && raw.packages !== null && !Array.isArray(raw.packages) ? raw.packages : {};
93
+ return {
94
+ version: raw && typeof raw.version === "number" ? raw.version : NPM_DOWNLOADS_VERSION,
95
+ packages,
96
+ unavailableUntil: typeof raw?.unavailableUntil === "number" ? raw.unavailableUntil : 0,
97
+ };
98
+ } catch {
99
+ return { version: NPM_DOWNLOADS_VERSION, packages: {}, unavailableUntil: 0 };
100
+ }
101
+ }
102
+
103
+ function dirnameOf(path) {
104
+ const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
105
+ return cut > 0 ? path.slice(0, cut) : path;
106
+ }
107
+
108
+ async function writeCache(cache, options = {}) {
109
+ const path = npmDownloadsPath(options);
110
+ await mkdir(dirnameOf(path), { recursive: true });
111
+ // 唯一临时名 + rename:固定 `.staging` 在并发写(refresh 与懒加载两条路同时回来)时
112
+ // 会互相踩踏出撕坏的文件。rename 覆盖目标,不再需要先 rm(那一步还会留出空窗)。
113
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
114
+ try {
115
+ await writeFile(tmp, JSON.stringify(cache, null, 2));
116
+ await rename(tmp, path);
117
+ } catch (error) {
118
+ await rm(tmp, { force: true }).catch(() => {});
119
+ throw error;
120
+ }
121
+ }
122
+
123
+ function errorText(error) {
124
+ return String(error?.message || error || "读取失败").slice(0, 200);
125
+ }
126
+
127
+ /** scoped 名要把斜杠转义成 %2F,前导 @ 保留 —— npm 统计接口认这个写法。 */
128
+ function statsUrl(pkg) {
129
+ return `${NPM_STATS_API}${pkg.replace("/", "%2F")}`;
130
+ }
131
+
132
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
133
+
134
+ /** retry-after 可能是秒数,也可能是 HTTP-date;只认秒数,其它一律按 0(回落到默认退避)。 */
135
+ function retryAfterMs(res) {
136
+ const raw = Number(res?.headers?.get?.("retry-after"));
137
+ return Number.isFinite(raw) && raw > 0 ? Math.min(raw * 1000, 30_000) : 0;
138
+ }
139
+
140
+ /**
141
+ * 全局节流闸门:所有请求(含 429 重试)都排这一个队,保证**起手间隔 ≥paceMs**。
142
+ * 并发数管不住速率——两个 worker 各自连发照样能冲到 9 req/s,实测那就是 429 的成因。
143
+ * 单次调用内共享一个 gater,调用之间不跨批持有(每批重新起算,缓存命中根本不进这里)。
144
+ */
145
+ function createPacer(paceMs) {
146
+ let next = 0;
147
+ let chain = Promise.resolve();
148
+ return () => {
149
+ const run = () => {
150
+ const now = Date.now();
151
+ const at = Math.max(now, next);
152
+ next = at + paceMs;
153
+ return at > now ? wait(at - now) : undefined;
154
+ };
155
+ chain = chain.then(run, run);
156
+ return chain;
157
+ };
158
+ }
159
+
160
+ /** 取一个包的下载量;调用方已保证并发与节流,这里只管单次请求、一次退避重试与失败口径。 */
161
+ async function fetchOne(pkg, cache, options, now, fetchImpl, pace) {
162
+ const key = pkg.toLowerCase();
163
+ const entry = cache.packages[key];
164
+ const send = async () => fetchImpl(statsUrl(pkg), {
165
+ headers: { "user-agent": USER_AGENT, accept: "application/json" },
166
+ signal: AbortSignal.timeout(NPM_DOWNLOADS_TIMEOUT_MS),
167
+ });
168
+ try {
169
+ await pace();
170
+ let response = await send();
171
+ // 429 是速率限流、不是「这个包没有」:等一会儿再试一次,很多数字就是这么救回来的。
172
+ if (response.status === 429) {
173
+ const backoff = Math.min(retryAfterMs(response) || NPM_DOWNLOADS_RETRY_MS, 5_000);
174
+ await wait(backoff);
175
+ await pace();
176
+ response = await send();
177
+ }
178
+ if (response.status === 404) {
179
+ // 包下架 / 改名 / 从未被统计:这一条没有下载量,卡片就少这一枚,绝不显示 0 冒充"没人下"。
180
+ cache.packages[key] = { fetchedAt: now, missing: true, data: null };
181
+ return { ok: false, missing: true, package: pkg };
182
+ }
183
+ if (response.status === 429 || response.status === 500 || response.status === 502 || response.status === 503) {
184
+ cache.unavailableUntil = now + NPM_DOWNLOADS_COOLDOWN_MS;
185
+ throw new Error(`npm 统计接口暂时不可用(HTTP ${response.status})`);
186
+ }
187
+ if (!response.ok) throw new Error(`npm 统计接口返回 HTTP ${response.status}`);
188
+ const data = projectDownloads(pkg, await response.json());
189
+ if (data.downloads === null) {
190
+ cache.packages[key] = { fetchedAt: now, missing: true, data: null };
191
+ return { ok: false, missing: true, package: pkg };
192
+ }
193
+ cache.packages[key] = { fetchedAt: now, missing: false, data };
194
+ return { ok: true, cached: false, ...data };
195
+ } catch (error) {
196
+ const message = errorText(error);
197
+ // 有旧值就继续显示旧值(stale),网络抖动绝不能把卡片上的数字打没。
198
+ if (entry?.data) return { ok: true, cached: true, stale: true, ...entry.data, error: message };
199
+ return { ok: false, error: message, package: pkg };
200
+ }
201
+ }
202
+
203
+ /**
204
+ * 批量读取 npm 包近 30 天下载量。
205
+ *
206
+ * 入参:`{ packages: string[] | {npm}[] | string, refresh?: boolean }`
207
+ * 返回:`{ packages: { "<pkg>": { ok, downloads?, start?, end?, cached?, stale?, missing?, error? } }, fetchedAt }`
208
+ *
209
+ * 单条失败不抛整体错误:UI 要能区分「这个包查不到」和「整个接口挂了」。
210
+ * 失败的包**不写缓存**(下次翻页/重载会再试),确认没有的包才粘住 7 天。
211
+ */
212
+ export async function wbNpmDownloads(payload = {}, options = {}) {
213
+ const packages = normalizePackageList(payload?.packages ?? payload?.package ?? payload?.npm);
214
+ const now = Date.now();
215
+ if (!packages.length) return { packages: {}, fetchedAt: now };
216
+ const cache = await readCache(options);
217
+ const fetchImpl = typeof options.fetchImpl === "function" ? options.fetchImpl : fetch;
218
+ const pace = createPacer(Number.isFinite(Number(options.paceMs)) ? Math.max(0, Number(options.paceMs)) : NPM_DOWNLOADS_PACE_MS);
219
+ const results = {};
220
+ const pending = [];
221
+
222
+ for (const pkg of packages) {
223
+ const entry = cache.packages[pkg.toLowerCase()];
224
+ const fresh = entry && entry.data && now - Number(entry.fetchedAt || 0) < NPM_DOWNLOADS_TTL_MS;
225
+ if (entry && entry.missing && now - Number(entry.fetchedAt || 0) < NPM_DOWNLOADS_TTL_MS && payload?.refresh !== true) {
226
+ // 7 天内查过且确认没有(下架 / 未统计):不重复打外网。
227
+ results[pkg] = { ok: false, missing: true, cached: true, package: pkg };
228
+ continue;
229
+ }
230
+ if (fresh && payload?.refresh !== true) {
231
+ results[pkg] = { ok: true, cached: true, ...entry.data };
232
+ continue;
233
+ }
234
+ if (cache.unavailableUntil && now < cache.unavailableUntil) {
235
+ results[pkg] = entry?.data
236
+ ? { ok: true, cached: true, stale: true, ...entry.data, error: "npm 统计接口冷却中,显示的是缓存" }
237
+ : { ok: false, error: "npm 统计接口冷却中,稍后再试", package: pkg };
238
+ continue;
239
+ }
240
+ pending.push(pkg);
241
+ }
242
+
243
+ // 并发 2 + 起手节流:并发只是别把连接数堆起来,真正决定 429 的是**速率**。
244
+ const cursor = { i: 0 };
245
+ const workers = Array.from({ length: Math.min(NPM_DOWNLOADS_CONCURRENCY, pending.length) }, async () => {
246
+ for (;;) {
247
+ const index = cursor.i;
248
+ cursor.i += 1;
249
+ if (index >= pending.length) return;
250
+ const pkg = pending[index];
251
+ results[pkg] = await fetchOne(pkg, cache, options, now, fetchImpl, pace);
252
+ }
253
+ });
254
+ await Promise.all(workers);
255
+
256
+ try {
257
+ await writeCache(cache, options);
258
+ } catch {
259
+ // 缓存写失败不影响本次结果;下次只是多打一次网络。
260
+ }
261
+ return { packages: results, fetchedAt: now };
262
+ }
@@ -111,12 +111,18 @@ function dirnameOf(path) {
111
111
  return cut > 0 ? path.slice(0, cut) : path;
112
112
  }
113
113
 
114
- /** staging + rename 原子写(与 wb-registry.writeAtomic 同一模式;Windows rename 不覆盖)。 */
114
+ /** 唯一临时名 + rename 原子写(与 wb-registry.writeAtomic 同一模式):rename 覆盖已存在
115
+ * 文件(Windows 实测),固定 `.staging` 反而在并发保存时互相消费对方的临时文件。 */
115
116
  async function writeAtomic(path, doc) {
116
117
  await mkdir(dirnameOf(path), { recursive: true });
117
- await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
118
- await rm(path, { force: true });
119
- await rename(`${path}.staging`, path);
118
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
119
+ try {
120
+ await writeFile(tmp, JSON.stringify(doc, null, 2));
121
+ await rename(tmp, path);
122
+ } catch (error) {
123
+ await rm(tmp, { force: true }).catch(() => {});
124
+ throw error;
125
+ }
120
126
  }
121
127
 
122
128
  /** 读取时同样过一遍 normalize:手改坏的条目在读侧就被拉回合法形状。 */
package/lib/typert.js CHANGED
@@ -33,7 +33,6 @@ const mv = (method, params, resultSym) => ({
33
33
  const nameParam = { name: 'name', wire: 'name', source: 'json', codec: codec('String', $name) }
34
34
  const specParam = { name: 'spec', wire: 'spec', source: 'json', codec: codec('Spec', $any) }
35
35
  const payloadParam = { name: 'payload', wire: 'payload', source: 'json', codec: codec('ImportPayload', $any) }
36
- const builtinInstallParam = { name: 'payload', wire: 'payload', source: 'json', codec: codec('BuiltinInstallPayload', $any) }
37
36
 
38
37
  export const TYPERT = {
39
38
  package: 'dsh-armory',
@@ -46,8 +45,6 @@ export const TYPERT = {
46
45
  mv('disable', [nameParam], 'NoteResult'),
47
46
  mv('reconnect', [nameParam], 'NoteResult'),
48
47
  mv('tools', [payloadParam], 'ToolsResult'),
49
- mv('builtins', [], 'BuiltinCatalogResult'),
50
- mv('installBuiltins', [builtinInstallParam], 'BuiltinInstallResult'),
51
48
  mv('add', [specParam], 'NoteResult'),
52
49
  mv('update', [specParam], 'NoteResult'),
53
50
  mv('removeServer', [nameParam], 'NoteResult'),
@@ -63,7 +60,6 @@ export const TYPERT = {
63
60
  mv('updateWorkspaceServer', [payloadParam], 'NoteResult'),
64
61
  mv('removeWorkspaceServer', [payloadParam], 'NoteResult'),
65
62
  mv('setWorkspaceExclude', [payloadParam], 'NoteResult'),
66
- mv('installWorkspaceBuiltins', [builtinInstallParam], 'BuiltinInstallResult'),
67
63
  mv('previewWorkspaceImport', [payloadParam], 'ImportPreview'),
68
64
  mv('importWorkspaceJson', [payloadParam], 'ImportResult'),
69
65
  mv('revealWorkspaceServer', [payloadParam], 'RevealResult'),
@@ -92,11 +88,13 @@ export const TYPERT = {
92
88
  // lib/tool-ledger.js 校验,与 wb* 系列同一宽容度。
93
89
  mv('toolTailor', [payloadParam], 'TailorReport'),
94
90
  // WorkBuddy 市场直连(连接器 MCP + SkillHub 技能)。payload 全部 $any:数据形状由
95
- // lib/wb-market.js 校验,契约层与 builtins/importJson 同一宽容度。
91
+ // lib/wb-market.js 校验,契约层与 importJson 同一宽容度。
96
92
  mv('wbConnectorCatalog', [], 'WbCatalogResult'),
97
93
  // 顶栏「市场」chip 常驻计数:只读磁盘缓存,零网络;无缓存返回 { total: null }。
98
94
  mv('wbConnectorCachedCount', [], 'WbCachedCountResult'),
99
95
  mv('wbConnectorPreview', [payloadParam], 'WbPreviewResult'),
96
+ // 市场连接器卡片头像:payload {connectorId},出 {ok, dataUrl}。卡片进视口才调。
97
+ mv('wbConnectorIcon', [payloadParam], 'WbIconResult'),
100
98
  mv('wbConnectorInstall', [payloadParam], 'NoteResult'),
101
99
  mv('wbSkillCatalog', [payloadParam], 'WbSkillListResult'),
102
100
  mv('wbSkillDetail', [payloadParam], 'WbSkillDetailResult'),
@@ -106,21 +104,26 @@ export const TYPERT = {
106
104
  // T03/R4:多市场注册表(读 / 增改)。payload $any,形状由 lib/wb-registry.js 校验。
107
105
  mv('wbMarketRegistry', [], 'WbMarketRegistryResult'),
108
106
  mv('wbMarketRegistryUpsert', [payloadParam], 'WbMarketRegistryResult'),
109
- // T03/R5:codebuddy 插件市场(列表 / 详情)。payload $any,形状由 lib/wb-plugin-market.js 校验。
110
- mv('wbPluginCatalog', [], 'WbPluginCatalogResult'),
111
- mv('wbPluginDetail', [payloadParam], 'WbPluginDetailResult'),
112
107
  // T04/R8:安装状态登记与查询(宿主侧落盘,客户端「已安装」筛选的数据源)。
113
108
  mv('wbInstallState', [], 'InstallStateResult'),
114
109
  mv('wbInstallStateRecord', [payloadParam], 'NoteResult'),
115
110
  mv('wbInstallStateForget', [payloadParam], 'NoteResult'),
116
- // T05/R9:插件市场更新探测(只做条件请求,不落新包)。
117
- mv('wbPluginUpdateCheck', [], 'WbUpdateHintResult'),
118
111
  // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins,数据层 lib/wb-awesome-market.js)。
119
112
  mv('wbAwesomeCatalog', [payloadParam], 'AwesomeCatalogResult'),
120
113
  mv('wbAwesomeDetail', [payloadParam], 'AwesomeDetailResult'),
121
114
  // README 预览:点击卡片按需取一次(npm registry / GitHub raw),落盘缓存 7 天。
122
115
  mv('wbAwesomeReadme', [payloadParam], 'AwesomeReadmeResult'),
123
116
  mv('wbAwesomeUpdateCheck', [], 'WbUpdateHintResult'),
117
+ // 社区元信息:打开详情时按需查 GitHub 仓库(维护者 / 最近提交 / issue / star),
118
+ // 6 小时缓存。数据层 lib/community-meta.js,payload $any({repos, refresh})。
119
+ mv('wbRepoMeta', [payloadParam], 'WbRepoMetaResult'),
120
+ // npm 近 30 天下载量:插件卡片底栏的「↓N」。目录里没有这个字段,只能去 npm 统计接口取,
121
+ // 7 天缓存 + 按可见卡片懒加载。数据层 lib/npm-downloads.js,payload $any({packages, refresh})。
122
+ mv('wbNpmDownloads', [payloadParam], 'WbNpmDownloadsResult'),
123
+ // 装备清单:导出本机全局配置 / 与贴入的清单做差异。payload $any,形状由
124
+ // lib/loadout.js 校验;只读,不安装、不写盘、不接收任何凭据。
125
+ mv('wbLoadoutExport', [payloadParam], 'LoadoutResult'),
126
+ mv('wbLoadoutDiff', [payloadParam], 'LoadoutDiffResult'),
124
127
  // WorkBuddy 专家直连(召唤预设 + 捆绑技能)。payload 全部 $any:数据形状由
125
128
  // lib/expert-market.js 校验,与 wb* 系列同一宽容度。
126
129
  mv('wbExpertCatalog', [], 'WbExpertCatalogResult'),