dsh-armory 2.2.1 → 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/README.en.md +26 -22
- package/README.md +29 -21
- package/docs/installation.md +0 -2
- package/lib/client.js +2495 -744
- package/lib/community-meta.js +222 -0
- package/lib/expert-market.js +119 -23
- package/lib/index.js +276 -92
- package/lib/loadout.js +178 -0
- package/lib/mcp-config.js +0 -171
- package/lib/mcp-oauth.js +2 -4
- package/lib/npm-downloads.js +262 -0
- package/lib/suites-registry.js +10 -4
- package/lib/tool-ledger.js +93 -0
- package/lib/typert.js +13 -10
- package/lib/wb-awesome-market.js +706 -214
- package/lib/wb-install-state.js +10 -4
- package/lib/wb-market.js +99 -27
- package/lib/wb-registry.js +32 -31
- package/lib/whats-new-en.js +31 -0
- package/lib/whats-new.js +37 -0
- package/package.json +2 -2
- package/docs/mcp-market-design.md +0 -617
- package/lib/mcp-catalog-en.js +0 -244
- package/lib/mcp-catalog.js +0 -806
- package/lib/wb-plugin-market.js +0 -291
package/lib/loadout.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// 装备清单(loadout):把「这台机器装了什么」变成一份可分享、可对比的清单。
|
|
2
|
+
//
|
|
3
|
+
// 边界(决定了这里为什么只做投影,不做安装):
|
|
4
|
+
// - 只记录名称 / 类型 / 来源 / 版本这类**身份信息**,绝不写入 env、header、
|
|
5
|
+
// token、authorization 或任何配置值;MCP 最多保留 env/header 的**键名**。
|
|
6
|
+
// - 导入只做解析与差异计算,不写盘、不安装、不调用任何市场写链。
|
|
7
|
+
// - 清单是用户主动导出、主动分享的文件;没有后端、没有账号、没有遥测。
|
|
8
|
+
//
|
|
9
|
+
// 数据层保持纯函数:宿主只负责收集本机条目,校验与 diff 都在这里,可独立测试。
|
|
10
|
+
export const LOADOUT_SCHEMA = "dsh-armory/loadout@1";
|
|
11
|
+
export const LOADOUT_MAX_ITEMS = 200;
|
|
12
|
+
export const LOADOUT_MAX_BYTES = 256 * 1024;
|
|
13
|
+
export const LOADOUT_DEFAULT_NAME = "我的 DSH 装备清单";
|
|
14
|
+
|
|
15
|
+
const KINDS = new Set(["mcp", "skill", "expert", "plugin"]);
|
|
16
|
+
const TRANSPORTS = new Set(["http", "stdio", "local", "?"]);
|
|
17
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]+/g;
|
|
18
|
+
|
|
19
|
+
function text(value, limit) {
|
|
20
|
+
return String(value ?? "").replace(CONTROL_CHARS, " ").trim().slice(0, limit);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function keyOf(item) {
|
|
24
|
+
return `${item.kind}\u0000${item.name}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 单条投影:只放行白名单字段,其余(含任何看起来像密钥的键)一律丢弃。 */
|
|
28
|
+
function normalizeItem(raw) {
|
|
29
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
30
|
+
const kind = text(raw.kind, 20).toLowerCase();
|
|
31
|
+
if (!KINDS.has(kind)) return null;
|
|
32
|
+
const name = text(raw.name, 120);
|
|
33
|
+
if (!name) return null;
|
|
34
|
+
|
|
35
|
+
const item = { kind, name };
|
|
36
|
+
const source = text(raw.source, 200);
|
|
37
|
+
if (source) item.source = source;
|
|
38
|
+
const version = text(raw.version, 100);
|
|
39
|
+
if (version) item.version = version;
|
|
40
|
+
const updatedAt = text(raw.updatedAt, 60);
|
|
41
|
+
if (updatedAt) item.updatedAt = updatedAt;
|
|
42
|
+
|
|
43
|
+
if (kind === "mcp") {
|
|
44
|
+
const transport = text(raw.transport, 12).toLowerCase();
|
|
45
|
+
if (TRANSPORTS.has(transport)) item.transport = transport;
|
|
46
|
+
const endpointHost = text(raw.endpointHost, 200);
|
|
47
|
+
if (endpointHost) item.endpointHost = endpointHost;
|
|
48
|
+
if (Array.isArray(raw.configKeys)) {
|
|
49
|
+
const keys = [];
|
|
50
|
+
for (const value of raw.configKeys) {
|
|
51
|
+
const key = text(value, 80);
|
|
52
|
+
if (!key || keys.includes(key)) continue;
|
|
53
|
+
keys.push(key);
|
|
54
|
+
if (keys.length >= 20) break;
|
|
55
|
+
}
|
|
56
|
+
if (keys.length) item.configKeys = keys;
|
|
57
|
+
}
|
|
58
|
+
if (typeof raw.enabled === "boolean") item.enabled = raw.enabled;
|
|
59
|
+
} else if (kind === "expert") {
|
|
60
|
+
const plugin = text(raw.plugin, 160);
|
|
61
|
+
if (plugin) item.plugin = plugin;
|
|
62
|
+
} else if (kind === "plugin") {
|
|
63
|
+
const key = text(raw.key, 160);
|
|
64
|
+
if (key) item.key = key;
|
|
65
|
+
}
|
|
66
|
+
return item;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function countItems(items) {
|
|
70
|
+
const counts = { total: items.length, mcp: 0, skill: 0, expert: 0, plugin: 0 };
|
|
71
|
+
for (const item of items) counts[item.kind] += 1;
|
|
72
|
+
return counts;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function countStatuses(rows) {
|
|
76
|
+
const counts = { total: rows.length, installed: 0, update: 0, missing: 0 };
|
|
77
|
+
for (const row of rows) counts[row.status] += 1;
|
|
78
|
+
return counts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 解析并校验一份清单(对象或 JSON 文本)。
|
|
83
|
+
* 非法结构直接抛错;单条非法 / 重复静默丢弃,避免一个坏行毁掉整份清单。
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeLoadout(input) {
|
|
86
|
+
let raw = input;
|
|
87
|
+
if (typeof input === "string") {
|
|
88
|
+
if (input.length > LOADOUT_MAX_BYTES) throw new Error("装备清单文件过大(上限 256KB)");
|
|
89
|
+
try {
|
|
90
|
+
raw = JSON.parse(input);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error("不是有效的 JSON");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("装备清单必须是一个对象");
|
|
96
|
+
if (raw.schema !== LOADOUT_SCHEMA) throw new Error("不是 dsh-armory 装备清单(schema 不匹配)");
|
|
97
|
+
if (!Array.isArray(raw.items)) throw new Error("装备清单缺少 items 数组");
|
|
98
|
+
|
|
99
|
+
const items = [];
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
for (const row of raw.items) {
|
|
102
|
+
const item = normalizeItem(row);
|
|
103
|
+
if (!item) continue;
|
|
104
|
+
const key = keyOf(item);
|
|
105
|
+
if (seen.has(key)) continue;
|
|
106
|
+
seen.add(key);
|
|
107
|
+
items.push(item);
|
|
108
|
+
if (items.length >= LOADOUT_MAX_ITEMS) break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const loadout = {
|
|
112
|
+
schema: LOADOUT_SCHEMA,
|
|
113
|
+
name: text(raw.name, 120) || LOADOUT_DEFAULT_NAME,
|
|
114
|
+
generatedAt: text(raw.generatedAt, 60),
|
|
115
|
+
items,
|
|
116
|
+
counts: countItems(items),
|
|
117
|
+
};
|
|
118
|
+
if (raw.app && typeof raw.app === "object") {
|
|
119
|
+
const name = text(raw.app.name, 80);
|
|
120
|
+
const version = text(raw.app.version, 40);
|
|
121
|
+
if (name) loadout.app = { name, ...(version ? { version } : {}) };
|
|
122
|
+
}
|
|
123
|
+
if (raw.truncated === true) loadout.truncated = true;
|
|
124
|
+
return loadout;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 由本机收集到的条目构建一份可导出的清单。 */
|
|
128
|
+
export function buildLoadout({ name, items, generatedAt, appVersion } = {}) {
|
|
129
|
+
const sourceItems = Array.isArray(items) ? items : [];
|
|
130
|
+
const loadout = normalizeLoadout({
|
|
131
|
+
schema: LOADOUT_SCHEMA,
|
|
132
|
+
name: text(name, 120) || LOADOUT_DEFAULT_NAME,
|
|
133
|
+
generatedAt: text(generatedAt, 60) || new Date().toISOString(),
|
|
134
|
+
app: { name: "dsh-armory", version: text(appVersion, 40) },
|
|
135
|
+
items: sourceItems,
|
|
136
|
+
});
|
|
137
|
+
if (sourceItems.length > LOADOUT_MAX_ITEMS) loadout.truncated = true;
|
|
138
|
+
return loadout;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 与清单做本机差异对比。
|
|
143
|
+
* 只按 kind + name 匹配;双方都有版本且不同才算「可更新」,避免把缺版本的条目误判。
|
|
144
|
+
*/
|
|
145
|
+
export function diffLoadout(remoteInput, localItems) {
|
|
146
|
+
const remote = normalizeLoadout(remoteInput);
|
|
147
|
+
const local = new Map();
|
|
148
|
+
for (const row of Array.isArray(localItems) ? localItems : []) {
|
|
149
|
+
const item = normalizeItem(row);
|
|
150
|
+
if (item && !local.has(keyOf(item))) local.set(keyOf(item), item);
|
|
151
|
+
}
|
|
152
|
+
const rows = remote.items.map((item) => {
|
|
153
|
+
const hit = local.get(keyOf(item));
|
|
154
|
+
const status = !hit ? "missing" : item.version && hit.version && item.version !== hit.version ? "update" : "installed";
|
|
155
|
+
return { ...item, status, localVersion: hit?.version || "" };
|
|
156
|
+
});
|
|
157
|
+
return {
|
|
158
|
+
loadout: {
|
|
159
|
+
schema: remote.schema,
|
|
160
|
+
name: remote.name,
|
|
161
|
+
generatedAt: remote.generatedAt,
|
|
162
|
+
counts: remote.counts,
|
|
163
|
+
},
|
|
164
|
+
rows,
|
|
165
|
+
counts: countStatuses(rows),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 稳定、人类可读的导出文本。 */
|
|
170
|
+
export function stringifyLoadout(input) {
|
|
171
|
+
return JSON.stringify(normalizeLoadout(input), null, 2);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 下载文件名:保留中文,只清理 Windows / URL 不接受的字符。 */
|
|
175
|
+
export function loadoutFileName(name) {
|
|
176
|
+
const base = text(name, 80).replace(/[\\/:*?"<>|\s]+/g, "-").replace(/^-+|-+$/g, "");
|
|
177
|
+
return `${base || "dsh-loadout"}.json`;
|
|
178
|
+
}
|
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.
|
|
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
|
+
}
|
package/lib/suites-registry.js
CHANGED
|
@@ -111,12 +111,18 @@ function dirnameOf(path) {
|
|
|
111
111
|
return cut > 0 ? path.slice(0, cut) : path;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/**
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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:手改坏的条目在读侧就被拉回合法形状。 */
|