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.
- package/README.en.md +26 -8
- package/README.md +39 -16
- package/docs/chunk-split-feasibility.md +153 -0
- package/docs/host-proposal-mcp-teardown.md +184 -0
- package/docs/host-proposal-skill-toggle.md +166 -0
- package/docs/mcp-market-design.md +62 -0
- package/docs/prd-awesome-dsh-plugins.md +105 -0
- package/docs/prd-workbuddy-parity.md +128 -0
- package/docs/system-design-awesome.md +269 -0
- package/docs/system_design.md +530 -0
- package/docs/workbuddy-market-gap-analysis.md +197 -0
- package/lib/client.js +4507 -271
- package/lib/expert-market.js +13 -2
- package/lib/index.js +395 -9
- package/lib/mcp-catalog.js +104 -0
- package/lib/mcp-config.js +10 -1
- package/lib/mcp-import-sources.js +154 -14
- package/lib/mcp-oauth.js +806 -0
- package/lib/tool-ledger.js +119 -0
- package/lib/typert.js +26 -0
- package/lib/wb-auth-rules.js +128 -0
- package/lib/wb-awesome-market.js +453 -0
- package/lib/wb-install-state.js +151 -0
- package/lib/wb-market.js +232 -8
- package/lib/wb-plugin-market.js +291 -0
- package/lib/wb-registry.js +224 -0
- package/lib/whats-new.js +82 -0
- package/lib/workspace-runtime.js +133 -4
- package/package.json +10 -9
package/lib/wb-market.js
CHANGED
|
@@ -23,8 +23,21 @@ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/p
|
|
|
23
23
|
import { homedir } from "node:os";
|
|
24
24
|
import { join, resolve, sep } from "node:path";
|
|
25
25
|
import { ZipReader } from "./wb-zip.js";
|
|
26
|
+
// T02/R1:auth_injection_rules 只解析、只展示(X9 边界)——DSH 不做任何代注入,
|
|
27
|
+
// 这里只借用 T01 纯函数库的解析与按连接器命中能力。
|
|
28
|
+
import { parseInjectionRules, rulesFor, scanTokenSlots } from "./wb-auth-rules.js";
|
|
29
|
+
// T03/R4:连接器源不再硬编码单 URL——从多市场注册表取 sourceFor("connectors"),
|
|
30
|
+
// 读不到/损坏时回落内置默认(wb-registry.builtinMarketplaces 保证)。
|
|
31
|
+
import { sourceFor } from "./wb-registry.js";
|
|
26
32
|
|
|
27
33
|
const CONNECTOR_ZIP_URL = "https://static.workbuddy.cn/connectors-config-v2/connectors-config.zip";
|
|
34
|
+
|
|
35
|
+
/** R4:连接器市场源 URL。注册表第一条 connectors 源优先,缺失/读失败回落内置默认。 */
|
|
36
|
+
export async function connectorSourceUrl(options = {}) {
|
|
37
|
+
const row = await sourceFor("connectors", options);
|
|
38
|
+
const url = row && row.source && typeof row.source.url === "string" ? row.source.url : "";
|
|
39
|
+
return url || CONNECTOR_ZIP_URL;
|
|
40
|
+
}
|
|
28
41
|
const SKILLHUB_ORIGIN = "https://api.skillhub.cn";
|
|
29
42
|
const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)";
|
|
30
43
|
// 单文件/单响应上限:connectors.json ≈ 550KB、SKILL.md 最大几十 KB,8MB 已是十倍冗余。
|
|
@@ -67,8 +80,11 @@ async function loadConnectorZip() {
|
|
|
67
80
|
if (cachedZip) return cachedZip;
|
|
68
81
|
await mkdir(cacheDir(), { recursive: true });
|
|
69
82
|
const meta = (await readJson(metaPath())) || {};
|
|
83
|
+
// R4:注册表取源(缺失回落内置默认),ZIP 路径/缓存键不变——
|
|
84
|
+
// 注册表只是改了「从哪下」,没有改「下到哪」。
|
|
85
|
+
const sourceUrl = await connectorSourceUrl();
|
|
70
86
|
const download = async (etag) => {
|
|
71
|
-
const response = await fetch(
|
|
87
|
+
const response = await fetch(sourceUrl, {
|
|
72
88
|
headers: { "user-agent": USER_AGENT, ...(etag ? { "if-none-match": etag } : {}) },
|
|
73
89
|
signal: AbortSignal.timeout(120_000),
|
|
74
90
|
});
|
|
@@ -115,21 +131,115 @@ async function loadConnectorZip() {
|
|
|
115
131
|
function isWorkbuddyOnlyCommand(command) {
|
|
116
132
|
return typeof command === "string" && (command.includes("connectors-marketplace") || command.includes(".workbuddy"));
|
|
117
133
|
}
|
|
118
|
-
|
|
134
|
+
|
|
135
|
+
// ── T02 投影函数(R1/R2/R3)───────────────────────────────────────────
|
|
136
|
+
// 三个都是纯函数:输入 connectors.json 的原始形状,输出面板行需要的展示字段。
|
|
137
|
+
// 放在这里而不是 client.js,是因为它们处理的是「远端数据的形状防御」,与
|
|
138
|
+
// serverInstallability 同层;UI 只消费结果,不重复判形状。
|
|
139
|
+
|
|
140
|
+
/** R1:某条连接器的 token 注入规则标注。只数条数、不给注入物(X9:只展示不注入)。 */
|
|
141
|
+
export function injectionRuleNote(indexJson, connectorId) {
|
|
142
|
+
const rules = rulesFor(parseInjectionRules(indexJson), connectorId);
|
|
143
|
+
if (!rules.length) return { count: 0, note: "" };
|
|
144
|
+
return {
|
|
145
|
+
count: rules.length,
|
|
146
|
+
note: `存在 ${rules.length} 条 token 注入规则(依赖 WB 账号体系,DSH 不代注入)`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* R2:visible_in 透传 + 标签筛选。实测为 key 数组(如 ["workbuddy","cline"]);
|
|
152
|
+
* 畸形(缺失/非数组/元素非字符串)一律归一成 [],调用方不需要再判形状。
|
|
153
|
+
* 空数组 ≠ 「不可见」:上游没标就当作全部可见,筛选函数里不拦截。
|
|
154
|
+
*/
|
|
155
|
+
export function visibleInOf(entry) {
|
|
156
|
+
const raw = entry?.visible_in;
|
|
157
|
+
if (!Array.isArray(raw)) return [];
|
|
158
|
+
return raw.map((tag) => (typeof tag === "string" ? tag.trim() : "")).filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** R2:本地筛选。visibleTags 为空(上游未标)= 不受该维度过滤;非空才要求包含。 */
|
|
162
|
+
export function matchesVisibleIn(rowVisibleTags, selectedTags) {
|
|
163
|
+
if (!Array.isArray(selectedTags) || !selectedTags.length) return true;
|
|
164
|
+
if (!Array.isArray(rowVisibleTags) || !rowVisibleTags.length) return true;
|
|
165
|
+
const owned = new Set(rowVisibleTags.map((tag) => String(tag).toLowerCase()));
|
|
166
|
+
return selectedTags.every((tag) => owned.has(String(tag).toLowerCase()));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* R3:examples 优先的展示文案。规则(设计文档 R3):
|
|
171
|
+
* 有 examples 且非空 → 正文用 examples(按语言 zh/en 选取);
|
|
172
|
+
* 否则回落 description;两者都无 → 空串(UI 自行回落到 name)。
|
|
173
|
+
* 入参都是 connectors.json 条目的原样字段,畸形一律防御性回落。
|
|
174
|
+
*/
|
|
175
|
+
export function displayCopyOf(entry) {
|
|
176
|
+
const pick = (list) => (Array.isArray(list) ? list.filter((item) => typeof item === "string" && item.trim()).filter(Boolean) : []);
|
|
177
|
+
const zhExamples = pick(entry?.examples_zh);
|
|
178
|
+
const enExamples = pick(entry?.examples_en);
|
|
179
|
+
const examples = zhExamples.length ? zhExamples : enExamples;
|
|
180
|
+
if (examples.length) {
|
|
181
|
+
return { body: examples.join("\n"), from: "examples", examples };
|
|
182
|
+
}
|
|
183
|
+
const zh = typeof entry?.description_zh === "string" ? entry.description_zh : "";
|
|
184
|
+
const fallbackZh = typeof entry?.description === "string" ? entry.description : "";
|
|
185
|
+
const en = typeof entry?.description_en === "string" ? entry.description_en : "";
|
|
186
|
+
const body = zh || fallbackZh || en;
|
|
187
|
+
return { body, from: body ? "description" : "", examples: [] };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function serverInstallability(serverName, rawServer, authMode = "") {
|
|
119
191
|
const reasons = [];
|
|
192
|
+
// R8:oauth / oneid-token 类不再拒装,而是标记「需走浏览器授权」,
|
|
193
|
+
// 由 lib/mcp-oauth.js 在安装时驱动 RFC 9728/8414/7591/7636 全链路。
|
|
194
|
+
let needsOAuth = false;
|
|
120
195
|
const nested = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
|
|
121
|
-
|
|
122
|
-
|
|
196
|
+
// mcp.json 里的 stdio 条目本来就只有 command、不写 type(官方示例与各家 README 皆如此)。
|
|
197
|
+
// 早前这里只认 nested.type:command 型条目 transport 落成空串,被文件末尾的
|
|
198
|
+
// 「未知传输类型」判为拒装——凡是标准 stdio 连接器都装不上。缺 type 但有 command
|
|
199
|
+
// 时按 stdio 处理,与下面返回处的 `transport || "stdio"` 兜底保持同一判断。
|
|
200
|
+
const declaredType = String(nested?.type ?? "").toLowerCase();
|
|
201
|
+
const transport = declaredType || (typeof nested?.command === "string" && nested.command.trim() ? "stdio" : "");
|
|
202
|
+
// R7:先扫占位符槽位(env/headers 里的 ${...})。这一扫描必须发生在 transport 的
|
|
203
|
+
// 提前 return 之前——否则 streamable-http 型连接器的 header 占位符会被整段跳过,
|
|
204
|
+
// 而 80+ 条 auth_mode=token 的连接器正是这个传输形态,正是「装不了」的成因之一。
|
|
205
|
+
const tokenSlots = scanTokenSlots({ [serverName]: rawServer });
|
|
206
|
+
const hasReplaceableSlots = tokenSlots.length > 0;
|
|
207
|
+
// 索引 auth_mode 是比 mcp.json 更权威的鉴权声明:mcp.json 可能漏标(如 tec-do 裸 URL),
|
|
208
|
+
// 但索引里标了鉴权的连接器 DSH 无法自动注入凭据,装上必 401,必须在列表层就拦下。
|
|
209
|
+
const mode = String(authMode || "").trim().toLowerCase();
|
|
210
|
+
if (mode === "token") {
|
|
211
|
+
// R7:token 型由「一律拒装」改为「可装但需引导填 token」。
|
|
212
|
+
// 有可替换占位符 → 放行(UI 弹令牌框);无占位符 → 用户无从填起,仍然拒装。
|
|
213
|
+
if (!hasReplaceableSlots) {
|
|
214
|
+
reasons.push("该连接器需要 token 凭据,但 mcp.json 未声明可替换的 ${...} 槽位,DSH 无法代填");
|
|
215
|
+
}
|
|
216
|
+
} else if (mode === "oauth" || mode === "oneid-token") {
|
|
217
|
+
// R8:凭据改由插件侧 OAuth 授权流程获取(浏览器授权 → token 落盘 → 注入
|
|
218
|
+
// authorization header),所以这里放行并打上需授权标记。
|
|
219
|
+
needsOAuth = true;
|
|
220
|
+
} else if (mode === "server-side" || mode === "mcp") {
|
|
221
|
+
reasons.push(`该连接器需要 ${mode} 凭据,DSH 无法自动注入`);
|
|
222
|
+
} else if (mode) {
|
|
223
|
+
// 未知 auth_mode 值保守处理:宁可少装不错装(与本文件「能装的标准从严」同一纪律)。
|
|
224
|
+
reasons.push(`该连接器需要未知类型的鉴权(auth_mode=${mode}),DSH 无法自动注入`);
|
|
225
|
+
}
|
|
226
|
+
const needsTokens = hasReplaceableSlots;
|
|
227
|
+
if (transport === "streamablehttp" || transport === "streamable-http" || transport === "http") {
|
|
228
|
+
return { ok: reasons.length === 0, transport: "streamable-http", reasons, tokenSlots, needsTokens, needsOAuth };
|
|
229
|
+
}
|
|
123
230
|
if (transport === "sse" || transport === "ws") reasons.push(`传输 ${transport} 不受 DSH 支持`);
|
|
124
231
|
if (rawServer?.preAuth) reasons.push("需要 WorkBuddy 的 preAuth 鉴权链路");
|
|
125
232
|
if (typeof nested?.command === "string" && isWorkbuddyOnlyCommand(nested.command)) reasons.push("command 依赖 WorkBuddy 本机连接器目录");
|
|
126
233
|
const envKeys = Object.keys(nested?.env || {});
|
|
127
234
|
const headerKeys = Object.keys(nested?.headers || {});
|
|
128
|
-
|
|
235
|
+
// R7:已有可替换占位符的,交给 needsTokens 引导用户填,不再重复算作拒装理由;
|
|
236
|
+
// 反之(键名像凭据但值里没有 ${...} 槽位)无从填起,仍然拒装。
|
|
237
|
+
if (!hasReplaceableSlots
|
|
238
|
+
&& (envKeys.some((key) => /token|secret|key/i.test(key)) || headerKeys.some((key) => /authorization|token/i.test(key)))) {
|
|
129
239
|
reasons.push("需要 WorkBuddy 的 token 注入,DSH 无法代填");
|
|
130
240
|
}
|
|
131
241
|
if (!reasons.length && transport !== "stdio") reasons.push(`未知传输类型 ${transport || "(缺失)"}`);
|
|
132
|
-
return { ok: reasons.length === 0, transport: transport || "stdio", reasons };
|
|
242
|
+
return { ok: reasons.length === 0, transport: transport || "stdio", reasons, tokenSlots, needsTokens, needsOAuth };
|
|
133
243
|
}
|
|
134
244
|
|
|
135
245
|
/** connectors.json 索引 + 每条的可装性标注。UI 列表唯一数据源。 */
|
|
@@ -155,17 +265,32 @@ export async function wbConnectorCatalog() {
|
|
|
155
265
|
}
|
|
156
266
|
// mcp.json 可能一条目录带多个 mcpServers;逐个判可装性,全部可装才算可装(安装时逐条写入)。
|
|
157
267
|
const servers = mcpJson?.mcpServers && typeof mcpJson.mcpServers === "object" ? Object.entries(mcpJson.mcpServers) : [];
|
|
158
|
-
const checks = servers.map(([serverName, rawServer]) => ({ serverName, rawServer, ...serverInstallability(serverName, rawServer) }));
|
|
268
|
+
const checks = servers.map(([serverName, rawServer]) => ({ serverName, rawServer, ...serverInstallability(serverName, rawServer, entry.auth_mode) }));
|
|
159
269
|
const blocked = checks.filter((check) => !check.ok);
|
|
270
|
+
// R3:examples 优先(zh → en 回落),description 只作兜底正文。
|
|
271
|
+
const copy = displayCopyOf(entry);
|
|
160
272
|
rows.push({
|
|
161
273
|
id,
|
|
162
274
|
name: entry.name || id,
|
|
163
275
|
nameEn: entry.name_en || "",
|
|
164
276
|
description: entry.description_zh || entry.description || entry.description_en || "",
|
|
165
277
|
descriptionEn: entry.description_en || "",
|
|
278
|
+
// R3 投影:UI 优先用 displayBody(examples 语义),from 让 UI 知道在展示什么。
|
|
279
|
+
displayBody: copy.body,
|
|
280
|
+
displayFrom: copy.from,
|
|
281
|
+
examples: copy.examples,
|
|
282
|
+
// R2:visible_in 标签(上游未标 = 空数组 = 不受筛选)。
|
|
283
|
+
visibleIn: visibleInOf(entry),
|
|
284
|
+
// R1:token 注入规则条数标注(只展示;DSH 不代注入)。
|
|
285
|
+
injectionRuleCount: injectionRuleNote(index, id).count,
|
|
286
|
+
// R7:可装性拆分投影——needsTokens 为 true 时 UI 应弹令牌引导框。
|
|
287
|
+
needsTokens: checks.some((check) => check.needsTokens),
|
|
288
|
+
// R8:needsOAuth 为 true 时 UI 应走「浏览器授权」引导(而不是拒装/令牌框)。
|
|
289
|
+
needsOAuth: checks.some((check) => check.needsOAuth),
|
|
290
|
+
tokenSlots: checks.flatMap((check) => check.tokenSlots || []),
|
|
166
291
|
authMode: entry.auth_mode || "",
|
|
292
|
+
version: entry.version || "",
|
|
167
293
|
type: entry.type || "",
|
|
168
|
-
examples: entry.examples_zh || entry.examples_en || [],
|
|
169
294
|
skillCount,
|
|
170
295
|
serverNames: checks.map((check) => check.serverName),
|
|
171
296
|
transport: checks[0]?.transport || "",
|
|
@@ -190,6 +315,105 @@ export async function wbConnectorPreview(connectorId) {
|
|
|
190
315
|
return { connectorId: id, mcpServers: servers };
|
|
191
316
|
}
|
|
192
317
|
|
|
318
|
+
/**
|
|
319
|
+
* 装前 401 探测:对 streamable-http 型 server 发一次 MCP initialize JSON-RPC,
|
|
320
|
+
* 服务器明确回 401/403 就中止安装。索引 auth_mode 会漏标(tec-do 的 mcp.json 是裸 URL、
|
|
321
|
+
* 索引 auth_mode 也为空,装完连接必 401 "Version negotiation failed")——这是漏标鉴权的
|
|
322
|
+
* 最后防线。判定刻意保守:只有 401/403 拦,超时/5xx/网络错误/其他状态一律放行——
|
|
323
|
+
* 探测挂了不能误伤正常连接器,宁可让个别服务器在连接时报错。
|
|
324
|
+
* 返回 { authorized: true }(放行)或 throw(要求授权,中止整个安装)。
|
|
325
|
+
*/
|
|
326
|
+
export async function probeServerAuthorization(serverName, rawServer) {
|
|
327
|
+
const nested = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
|
|
328
|
+
const transport = String(nested?.type ?? "").toLowerCase();
|
|
329
|
+
// 只探测 HTTP 型:stdio 没有可探测的 URL,sse/ws 本来就被准入闸拦下。
|
|
330
|
+
if (transport !== "streamablehttp" && transport !== "streamable-http" && transport !== "http") return { authorized: true, skipped: true };
|
|
331
|
+
const url = String(nested?.url || "").trim();
|
|
332
|
+
if (!url) return { authorized: true, skipped: true };
|
|
333
|
+
const body = JSON.stringify({
|
|
334
|
+
jsonrpc: "2.0",
|
|
335
|
+
id: 1,
|
|
336
|
+
method: "initialize",
|
|
337
|
+
params: {
|
|
338
|
+
protocolVersion: "2025-03-26",
|
|
339
|
+
capabilities: {},
|
|
340
|
+
clientInfo: { name: "dsh-armory", version: "1.0.0" },
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
let response;
|
|
344
|
+
try {
|
|
345
|
+
response = await fetch(url, {
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: {
|
|
348
|
+
"content-type": "application/json",
|
|
349
|
+
accept: "application/json, text/event-stream",
|
|
350
|
+
// 透传 mcp.json 里该 server 已声明的 headers(例如部分连接器带公共 UA/token 模板)。
|
|
351
|
+
...Object.fromEntries(Object.entries(nested?.headers || {}).map(([key, value]) => [String(key).toLowerCase(), String(value)])),
|
|
352
|
+
"user-agent": USER_AGENT,
|
|
353
|
+
},
|
|
354
|
+
body,
|
|
355
|
+
signal: AbortSignal.timeout(8_000),
|
|
356
|
+
});
|
|
357
|
+
} catch {
|
|
358
|
+
// 超时 / DNS 失败 / 连接拒绝:探测不了就放行,安装后由用户实测。
|
|
359
|
+
return { authorized: true, error: "probe-unreachable" };
|
|
360
|
+
}
|
|
361
|
+
if (response.status === 401 || response.status === 403) {
|
|
362
|
+
// R8:不再把 401 当致命错误。改回传结构化「需授权」,由上层触发浏览器授权流程:
|
|
363
|
+
// 解析 WWW-Authenticate 里的 resource_metadata,交给 lib/mcp-oauth.js 继续走
|
|
364
|
+
// RFC 9728(资源元数据)→ RFC 8414(AS 元数据)→ RFC 7591(动态注册)→ RFC 7636(PKCE)。
|
|
365
|
+
const wwwAuthenticate = String(response.headers.get("www-authenticate") || "");
|
|
366
|
+
return {
|
|
367
|
+
authorized: false,
|
|
368
|
+
needsAuth: true,
|
|
369
|
+
status: response.status,
|
|
370
|
+
url,
|
|
371
|
+
serverName,
|
|
372
|
+
wwwAuthenticate,
|
|
373
|
+
resourceMetadata: extractResourceMetadata(wwwAuthenticate),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
// 200/202/4xx 其他/5xx:都视为「没有明确拒绝匿名握手」,放行。
|
|
377
|
+
return { authorized: true, status: response.status };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** 从 WWW-Authenticate 里取 resource_metadata="..."(RFC 9728 的入口指针)。 */
|
|
381
|
+
function extractResourceMetadata(header) {
|
|
382
|
+
const match = String(header || "").match(/resource_metadata\s*=\s*"([^"]+)"/i)
|
|
383
|
+
|| String(header || "").match(/resource_metadata\s*=\s*([^\s,]+)/i);
|
|
384
|
+
return match ? match[1].trim() : "";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 安装前对整份 mcpServers 逐个探测;把「需授权」的 server 聚合成清单回报,绝不中止安装。 */
|
|
388
|
+
export async function probeMcpServersAuthorization(mcpServers) {
|
|
389
|
+
const servers = mcpServers && typeof mcpServers === "object" ? Object.entries(mcpServers) : [];
|
|
390
|
+
const needsAuth = [];
|
|
391
|
+
for (const [serverName, rawServer] of servers) {
|
|
392
|
+
const probe = await probeServerAuthorization(serverName, rawServer);
|
|
393
|
+
if (probe?.needsAuth) needsAuth.push(probe);
|
|
394
|
+
}
|
|
395
|
+
return { ok: true, checked: servers.length, needsAuth };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 只读磁盘缓存的市场条目计数(供顶栏 chip 常驻显示,绝不联网)。
|
|
400
|
+
* 磁盘上有 connectors-config.zip 就直接解索引计数;没有就返回 total:null,
|
|
401
|
+
* 等「市场页真实打开过一次」(那会走完整下载并落缓存)之后再查就有数字了。
|
|
402
|
+
* 单独存在的意义:wbConnectorCatalog 即使热缓存也会做一次 304 网络探测,
|
|
403
|
+
* 冷缓存更是 24MB 下载——为顶栏一个数字触发这两者都不值得。
|
|
404
|
+
*/
|
|
405
|
+
export async function wbConnectorCachedCount() {
|
|
406
|
+
try {
|
|
407
|
+
const local = await readFile(zipPath());
|
|
408
|
+
const zip = new ZipReader(local);
|
|
409
|
+
const index = JSON.parse(zip.extract(".codebuddy-connector/connectors.json").toString("utf8"));
|
|
410
|
+
const connectors = Array.isArray(index.connectors) ? index.connectors : [];
|
|
411
|
+
return { total: connectors.filter((entry) => String(entry.id || entry.source || "").trim()).length };
|
|
412
|
+
} catch {
|
|
413
|
+
return { total: null };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
193
417
|
// ── SkillHub 技能市场 ─────────────────────────────────────────────────
|
|
194
418
|
// 列表:/api/skills?sortBy=downloads|installs|stars|score&page=N&pageSize=60&keyword=..&category=..
|
|
195
419
|
// 详情:/api/v1/skills/:slug?namespace=<handle>(带 latestVersion、publisher、securityReports)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// CodeBuddy 插件市场(T03/R5)—— sourceFor("plugins") 的 ZIP 数据源与目录投影。
|
|
2
|
+
//
|
|
3
|
+
// 数据链路(与连接器市场同一套形状,缓存目录不同):
|
|
4
|
+
// 1. 源:lib/wb-registry.js sourceFor("plugins") —— 内置默认
|
|
5
|
+
// codebuddy-plugins-official(https://download.codebuddy.cn/...zip),
|
|
6
|
+
// 用户注册表条目可覆盖;读不到源时抛错(不发明第二处硬编码)。
|
|
7
|
+
// 2. 缓存:~/.dsh/armory/plugin-market/ 下 plugin.zip + meta.json{etag,fetchedAt},
|
|
8
|
+
// ETag 命中就不重复下载(与 wb-market.js 的连接器缓存同构;目录挂在 armory
|
|
9
|
+
// 根下而不是 ~/.dsh/wb-market/,因为这是 armory 自己引入的市场)。
|
|
10
|
+
// 3. 解析(zip 内嵌套清单,实测形状):
|
|
11
|
+
// .codebuddy-plugin/marketplace.json —— 市场索引(plugins 数组,取插件目录名)
|
|
12
|
+
// external_plugins/<name>/.codebuddy-plugin/plugin.json —— 每个插件的清单,
|
|
13
|
+
// 投影 {name,description,author,repository,homepage,license,version,dependencies,lspServers}。
|
|
14
|
+
//
|
|
15
|
+
// 纪律:安装动作不在这里(客户端直接走官方 pluginManager.installBundle,T04 的 R8
|
|
16
|
+
// 才接 wb-install-state 记账);本文件只做「取数 + 投影」,零副作用除缓存写盘。
|
|
17
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { ZipReader } from "./wb-zip.js";
|
|
21
|
+
import { sourceFor } from "./wb-registry.js";
|
|
22
|
+
|
|
23
|
+
const USER_AGENT = "@2026heshao/dsh-armory (+https://github.com/2026heshao/dsh-armory)";
|
|
24
|
+
const MAX_ZIP_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/** 缓存根目录:~/.dsh/armory/plugin-market(测试可用 options.rootDir 覆盖)。 */
|
|
27
|
+
function pluginMarketDir(options = {}) {
|
|
28
|
+
return join(options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory"), "plugin-market");
|
|
29
|
+
}
|
|
30
|
+
function zipPathOf(options) {
|
|
31
|
+
return join(pluginMarketDir(options), "plugin.zip");
|
|
32
|
+
}
|
|
33
|
+
function metaPathOf(options) {
|
|
34
|
+
return join(pluginMarketDir(options), "meta.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readJsonAt(path) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 模块级缓存:一次进程内只解析一次 zip(与 wb-market cachedZip 同构)。 */
|
|
46
|
+
let cachedPluginZip = null;
|
|
47
|
+
|
|
48
|
+
async function downloadPluginZip(sourceUrl, etag) {
|
|
49
|
+
const response = await fetch(sourceUrl, {
|
|
50
|
+
headers: { "user-agent": USER_AGENT, ...(etag ? { "if-none-match": etag } : {}) },
|
|
51
|
+
signal: AbortSignal.timeout(120_000),
|
|
52
|
+
});
|
|
53
|
+
if (response.status === 304 && etag) return null;
|
|
54
|
+
if (!response.ok) throw new Error(`插件市场下载失败:HTTP ${response.status}`);
|
|
55
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
56
|
+
if (buffer.length > MAX_ZIP_BYTES) throw new Error(`插件市场包超过 ${Math.round(MAX_ZIP_BYTES / 1024 / 1024)}MB 上限,疑似异常响应`);
|
|
57
|
+
return { buffer, etag: response.headers.get("etag") || "" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 下载(或复用磁盘缓存)并解析插件市场 zip。返回 { zip, loadedFrom }。 */
|
|
61
|
+
async function loadPluginZip(options = {}) {
|
|
62
|
+
if (cachedPluginZip) return cachedPluginZip;
|
|
63
|
+
const source = await sourceFor("plugins", options);
|
|
64
|
+
const sourceUrl = source?.source?.url;
|
|
65
|
+
if (typeof sourceUrl !== "string" || !sourceUrl) throw new Error("注册表里没有可用的 plugins 市场源");
|
|
66
|
+
const dir = pluginMarketDir(options);
|
|
67
|
+
await mkdir(dir, { recursive: true });
|
|
68
|
+
const meta = (await readJsonAt(metaPathOf(options))) || {};
|
|
69
|
+
const writeThrough = async (fresh) => {
|
|
70
|
+
await writeFile(zipPathOf(options), fresh.buffer);
|
|
71
|
+
await writeFile(metaPathOf(options), JSON.stringify({ etag: fresh.etag, fetchedAt: Date.now() }));
|
|
72
|
+
};
|
|
73
|
+
let zip = null;
|
|
74
|
+
if (meta.etag) {
|
|
75
|
+
try {
|
|
76
|
+
const local = await readFile(zipPathOf(options));
|
|
77
|
+
zip = new ZipReader(local);
|
|
78
|
+
const fresh = await downloadPluginZip(sourceUrl, meta.etag);
|
|
79
|
+
if (fresh === null) {
|
|
80
|
+
cachedPluginZip = { zip, loadedFrom: "disk" };
|
|
81
|
+
return cachedPluginZip;
|
|
82
|
+
}
|
|
83
|
+
zip = new ZipReader(fresh.buffer);
|
|
84
|
+
await writeThrough(fresh);
|
|
85
|
+
cachedPluginZip = { zip, loadedFrom: "network" };
|
|
86
|
+
return cachedPluginZip;
|
|
87
|
+
} catch {
|
|
88
|
+
zip = null; // 磁盘缓存损坏 → 走完整重下
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const fresh = await downloadPluginZip(sourceUrl, "");
|
|
92
|
+
zip = new ZipReader(fresh.buffer);
|
|
93
|
+
await writeThrough(fresh);
|
|
94
|
+
cachedPluginZip = { zip, loadedFrom: "network" };
|
|
95
|
+
return cachedPluginZip;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── 清单解析(嵌套投影)──────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** 字符串字段防御读取。 */
|
|
101
|
+
function asString(value, fallback = "") {
|
|
102
|
+
return typeof value === "string" ? value.trim() : fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* marketplace.json → 插件目录名列表。实测形状 { plugins: ["name-a", ...] };
|
|
107
|
+
* 兼容 { plugins: [{ name }] } 与 { plugins: { ... } }(对象键即目录名)两种变体。
|
|
108
|
+
* 畸形一律返回 [],不抛错。
|
|
109
|
+
*/
|
|
110
|
+
export function pluginNamesFromMarketplace(indexJson) {
|
|
111
|
+
const raw = indexJson?.plugins;
|
|
112
|
+
if (Array.isArray(raw)) {
|
|
113
|
+
return raw
|
|
114
|
+
.map((item) => (typeof item === "string" ? item : asString(item?.name)))
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
}
|
|
117
|
+
if (raw && typeof raw === "object") return Object.keys(raw).filter(Boolean);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 去掉末尾斜杠并统一为 / 分隔(listByPrefix 是小写前缀匹配,路径形态无关紧要)。 */
|
|
122
|
+
/** zip 内插件清单的标准嵌套路径(导出给测试对形状做锚定)。 */
|
|
123
|
+
export function pluginJsonPath(name) {
|
|
124
|
+
return `external_plugins/${name}/.codebuddy-plugin/plugin.json`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* plugin.json → 面板投影。防御性提取:全部字段缺失不炸;dependencies 缺失 = []
|
|
129
|
+
* (R6:实测清单无该字段,出现时展示、绝不自动安装);lspServers 仅透传展示(X7)。
|
|
130
|
+
*/
|
|
131
|
+
export function projectPlugin(name, pluginJson) {
|
|
132
|
+
const raw = pluginJson && typeof pluginJson === "object" ? pluginJson : {};
|
|
133
|
+
const deps = Array.isArray(raw.dependencies)
|
|
134
|
+
? raw.dependencies
|
|
135
|
+
.map((dep) => (typeof dep === "string"
|
|
136
|
+
? { name: dep.trim(), version: "" }
|
|
137
|
+
: { name: asString(dep?.name), version: asString(dep?.version || dep?.spec || "") }))
|
|
138
|
+
.filter((dep) => dep.name)
|
|
139
|
+
: [];
|
|
140
|
+
return {
|
|
141
|
+
name: asString(raw.name) || asString(name),
|
|
142
|
+
description: asString(raw.description),
|
|
143
|
+
author: asString(raw.author),
|
|
144
|
+
repository: asString(raw.repository),
|
|
145
|
+
homepage: asString(raw.homepage),
|
|
146
|
+
license: asString(raw.license),
|
|
147
|
+
version: asString(raw.version),
|
|
148
|
+
dependencies: deps,
|
|
149
|
+
lspServers: raw.lspServers && typeof raw.lspServers === "object" ? raw.lspServers : null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 从已解析的 ZipReader 枚举全部插件投影。先读市场索引拿目录名,再逐条读
|
|
155
|
+
* external_plugins/<name>/.codebuddy-plugin/plugin.json(缺清单的目录跳过并计数)。
|
|
156
|
+
*/
|
|
157
|
+
export function readPluginCatalog(zip) {
|
|
158
|
+
const marketManifestName = ".codebuddy-plugin/marketplace.json";
|
|
159
|
+
if (!zip.has(marketManifestName)) {
|
|
160
|
+
return { plugins: [], skipped: 0 };
|
|
161
|
+
}
|
|
162
|
+
let indexJson = null;
|
|
163
|
+
try {
|
|
164
|
+
indexJson = JSON.parse(zip.extract(marketManifestName).toString("utf8"));
|
|
165
|
+
} catch {
|
|
166
|
+
return { plugins: [], skipped: 0 };
|
|
167
|
+
}
|
|
168
|
+
const names = pluginNamesFromMarketplace(indexJson);
|
|
169
|
+
const plugins = [];
|
|
170
|
+
let skipped = 0;
|
|
171
|
+
for (const name of names) {
|
|
172
|
+
const manifestPath = pluginJsonPath(name);
|
|
173
|
+
if (!zip.has(manifestPath)) {
|
|
174
|
+
skipped += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const pluginJson = JSON.parse(zip.extract(manifestPath).toString("utf8"));
|
|
179
|
+
plugins.push(projectPlugin(name, pluginJson));
|
|
180
|
+
} catch {
|
|
181
|
+
skipped += 1; // 单个插件清单损坏不拖垮整个目录
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { plugins, skipped };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 目录投影:catalog 返回 {ok, total, loadedFrom, plugins, skipped}。
|
|
189
|
+
* 投影只保留列表需要的字段(详情从 detail 再取全量),保持响应轻。
|
|
190
|
+
*/
|
|
191
|
+
export async function wbPluginCatalog(options = {}) {
|
|
192
|
+
const { zip, loadedFrom } = await loadPluginZip(options);
|
|
193
|
+
const { plugins, skipped } = readPluginCatalog(zip);
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
loadedFrom,
|
|
197
|
+
total: plugins.length,
|
|
198
|
+
skipped,
|
|
199
|
+
plugins: plugins.map((plugin) => ({
|
|
200
|
+
name: plugin.name,
|
|
201
|
+
description: plugin.description,
|
|
202
|
+
author: plugin.author,
|
|
203
|
+
repository: plugin.repository,
|
|
204
|
+
version: plugin.version,
|
|
205
|
+
license: plugin.license,
|
|
206
|
+
dependencies: plugin.dependencies,
|
|
207
|
+
})),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 单插件详情:全量投影(repository/homepage/lspServers 只在详情透出)。
|
|
213
|
+
* name 未给或 zip 里没有 → ok:false(不抛错,客户端按「不可用」渲染)。
|
|
214
|
+
*/
|
|
215
|
+
export async function wbPluginDetail(name, options = {}) {
|
|
216
|
+
const wanted = String(name ?? "").trim();
|
|
217
|
+
if (!wanted) return { ok: false, error: "缺少插件名" };
|
|
218
|
+
const { zip, loadedFrom } = await loadPluginZip(options);
|
|
219
|
+
const manifestPath = pluginJsonPath(wanted);
|
|
220
|
+
if (!zip.has(manifestPath)) return { ok: false, error: `zip 里没有插件 ${wanted}` };
|
|
221
|
+
try {
|
|
222
|
+
const pluginJson = JSON.parse(zip.extract(manifestPath).toString("utf8"));
|
|
223
|
+
return { ok: true, loadedFrom, plugin: projectPlugin(wanted, pluginJson) };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
return { ok: false, error: `插件清单解析失败:${error.message}` };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 供测试与未来 R8(T04)检查缓存是否在场;只读,不联网。 */
|
|
230
|
+
export async function wbPluginCachedInfo(options = {}) {
|
|
231
|
+
const meta = (await readJsonAt(metaPathOf(options))) || {};
|
|
232
|
+
return { hasCache: Boolean(meta.etag), etag: meta.etag || "", fetchedAt: meta.fetchedAt || 0 };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// writeAtomic 同款 staging+rename(本文件缓存写用直写即可,这里导出给测试复用形态)。
|
|
236
|
+
export async function writeCacheStaging(path, data) {
|
|
237
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
238
|
+
await writeFile(`${path}.staging`, data);
|
|
239
|
+
await rm(path, { force: true });
|
|
240
|
+
await rename(`${path}.staging`, path);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* R9(T05):插件市场更新探测 —— 只做条件请求,**绝不落盘**。
|
|
245
|
+
*
|
|
246
|
+
* 判定口径(设计文档):ETag 与上次落盘的 meta.json 不一致即有更新;
|
|
247
|
+
* HTTP 304 = 无更新,HTTP 200 = 有更新。HEAD 不传包体做轻量探测;
|
|
248
|
+
* 服务端对 HEAD 返回 405/501 时降级为 GET,并立即 cancel() 丢弃响应体,
|
|
249
|
+
* 不把 zip 读进内存。纯探测:不写 zip、不写 meta.json、不更新内存缓存。
|
|
250
|
+
*/
|
|
251
|
+
export async function wbPluginUpdateCheck(options = {}) {
|
|
252
|
+
try {
|
|
253
|
+
const source = await sourceFor("plugins", options);
|
|
254
|
+
const sourceUrl = source?.source?.url;
|
|
255
|
+
const meta = (await readJsonAt(metaPathOf(options))) || {};
|
|
256
|
+
const localEtag = meta.etag || "";
|
|
257
|
+
if (typeof sourceUrl !== "string" || !sourceUrl) {
|
|
258
|
+
// 市场源检查必须先于 no-cache 分支:市场没配置时绝不能提示「有新版本」。
|
|
259
|
+
return { ok: false, hasUpdate: false, reason: "no-source", localEtag, remoteEtag: "", checkedAt: Date.now(), error: "注册表里没有可用的 plugins 市场源" };
|
|
260
|
+
}
|
|
261
|
+
if (!localEtag) {
|
|
262
|
+
// 本地没缓存过 → 无法比较,视为有更新,引导用户首次加载(纯探测仍不下载)。
|
|
263
|
+
return { ok: true, hasUpdate: true, reason: "no-cache", localEtag: "", remoteEtag: "", checkedAt: Date.now() };
|
|
264
|
+
}
|
|
265
|
+
const headers = { "user-agent": USER_AGENT, "if-none-match": localEtag };
|
|
266
|
+
let response;
|
|
267
|
+
try {
|
|
268
|
+
response = await fetch(sourceUrl, { method: "HEAD", headers, signal: AbortSignal.timeout(30_000) });
|
|
269
|
+
if (response.status === 405 || response.status === 501) {
|
|
270
|
+
// HEAD 不被支持 → 降级 GET,立刻 cancel() 丢弃响应体,不读进内存。
|
|
271
|
+
response.body?.cancel?.();
|
|
272
|
+
response = await fetch(sourceUrl, { method: "GET", headers, signal: AbortSignal.timeout(30_000) });
|
|
273
|
+
response.body?.cancel?.();
|
|
274
|
+
}
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `更新探测失败:${error.message}` };
|
|
277
|
+
}
|
|
278
|
+
if (response.status === 304) {
|
|
279
|
+
// 304:ETag 命中,服务端不回显 ETag;回显 localEtag 便于客户端展示。
|
|
280
|
+
return { ok: true, hasUpdate: false, reason: "up-to-date", localEtag, remoteEtag: localEtag, checkedAt: Date.now() };
|
|
281
|
+
}
|
|
282
|
+
if (response.ok) {
|
|
283
|
+
const remoteEtag = response.headers.get("etag") || "";
|
|
284
|
+
const hasUpdate = remoteEtag !== localEtag;
|
|
285
|
+
return { ok: true, hasUpdate, reason: hasUpdate ? "update-available" : "up-to-date", localEtag, remoteEtag, checkedAt: Date.now() };
|
|
286
|
+
}
|
|
287
|
+
return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `插件市场更新探测失败:HTTP ${response.status}` };
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag: "", remoteEtag: "", checkedAt: Date.now(), error: `更新探测异常:${error.message}` };
|
|
290
|
+
}
|
|
291
|
+
}
|