dsh-armory 2.0.0 → 2.1.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 +7 -1
- package/README.md +9 -2
- package/lib/client.js +556 -31
- package/lib/index.js +7 -2
- package/lib/mcp-oauth.js +1 -1
- package/lib/typert.js +2 -0
- package/lib/wb-awesome-market.js +304 -36
- package/lib/whats-new.js +17 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { probeMcpServersAuthorization, wbConnectorCatalog, wbConnectorCachedCoun
|
|
|
18
18
|
import { readRegistry, upsertMarketplace } from "./wb-registry.js";
|
|
19
19
|
import { wbPluginCatalog, wbPluginDetail, wbPluginUpdateCheck } from "./wb-plugin-market.js";
|
|
20
20
|
// T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)的宿主侧数据层。
|
|
21
|
-
import { wbAwesomeCatalog, wbAwesomeDetail, wbAwesomeUpdateCheck } from "./wb-awesome-market.js";
|
|
21
|
+
import { wbAwesomeCatalog, wbAwesomeDetail, wbAwesomeReadme, wbAwesomeUpdateCheck } from "./wb-awesome-market.js";
|
|
22
22
|
import { readState as readInstallState, record as recordInstallState, forget as forgetInstallState } from "./wb-install-state.js";
|
|
23
23
|
import { applyTokens } from "./wb-auth-rules.js";
|
|
24
24
|
import {
|
|
@@ -989,7 +989,7 @@ async function listMcpServers(ctx) {
|
|
|
989
989
|
let McpManagerGateway = (() => {
|
|
990
990
|
let _classSuper = TypertRemoteService;
|
|
991
991
|
let _instanceExtraInitializers = [];
|
|
992
|
-
let _list_decorators, _status_decorators, _enable_decorators, _disable_decorators, _reconnect_decorators, _tools_decorators, _builtins_decorators, _installBuiltins_decorators, _add_decorators, _update_decorators, _remove_decorators, _reveal_decorators, _previewImport_decorators, _importJson_decorators, _listWorkspaces_decorators, _getWorkspaceView_decorators, _addWorkspaceServer_decorators, _updateWorkspaceServer_decorators, _removeWorkspaceServer_decorators, _setWorkspaceExclude_decorators, _installWorkspaceBuiltins_decorators, _previewWorkspaceImport_decorators, _importWorkspaceJson_decorators, _revealWorkspaceServer_decorators, _projectConnections_decorators, _reconnectWorkspaceServer_decorators, _updateHint_decorators, _whatsNew_decorators, _scanImportSources_decorators, _previewImportSource_decorators, _importSource_decorators, _toolTailor_decorators, _wbConnectorCatalog_decorators, _wbConnectorCachedCount_decorators, _wbConnectorPreview_decorators, _wbConnectorInstall_decorators, _wbOAuthBegin_decorators, _wbOAuthPoll_decorators, _wbOAuthCancel_decorators, _wbOAuthToken_decorators, _wbOAuthOverview_decorators, _wbOAuthOpen_decorators, _wbSkillCatalog_decorators, _wbSkillDetail_decorators, _wbSkillInstall_decorators, _wbSkillUninstall_decorators, _wbSkillInstalled_decorators, _wbMarketRegistry_decorators, _wbMarketRegistryUpsert_decorators, _wbPluginCatalog_decorators, _wbPluginDetail_decorators, _wbInstallState_decorators, _wbInstallStateRecord_decorators, _wbInstallStateForget_decorators, _wbPluginUpdateCheck_decorators, _wbAwesomeCatalog_decorators, _wbAwesomeDetail_decorators, _wbAwesomeUpdateCheck_decorators;
|
|
992
|
+
let _list_decorators, _status_decorators, _enable_decorators, _disable_decorators, _reconnect_decorators, _tools_decorators, _builtins_decorators, _installBuiltins_decorators, _add_decorators, _update_decorators, _remove_decorators, _reveal_decorators, _previewImport_decorators, _importJson_decorators, _listWorkspaces_decorators, _getWorkspaceView_decorators, _addWorkspaceServer_decorators, _updateWorkspaceServer_decorators, _removeWorkspaceServer_decorators, _setWorkspaceExclude_decorators, _installWorkspaceBuiltins_decorators, _previewWorkspaceImport_decorators, _importWorkspaceJson_decorators, _revealWorkspaceServer_decorators, _projectConnections_decorators, _reconnectWorkspaceServer_decorators, _updateHint_decorators, _whatsNew_decorators, _scanImportSources_decorators, _previewImportSource_decorators, _importSource_decorators, _toolTailor_decorators, _wbConnectorCatalog_decorators, _wbConnectorCachedCount_decorators, _wbConnectorPreview_decorators, _wbConnectorInstall_decorators, _wbOAuthBegin_decorators, _wbOAuthPoll_decorators, _wbOAuthCancel_decorators, _wbOAuthToken_decorators, _wbOAuthOverview_decorators, _wbOAuthOpen_decorators, _wbSkillCatalog_decorators, _wbSkillDetail_decorators, _wbSkillInstall_decorators, _wbSkillUninstall_decorators, _wbSkillInstalled_decorators, _wbMarketRegistry_decorators, _wbMarketRegistryUpsert_decorators, _wbPluginCatalog_decorators, _wbPluginDetail_decorators, _wbInstallState_decorators, _wbInstallStateRecord_decorators, _wbInstallStateForget_decorators, _wbPluginUpdateCheck_decorators, _wbAwesomeCatalog_decorators, _wbAwesomeDetail_decorators, _wbAwesomeReadme_decorators, _wbAwesomeUpdateCheck_decorators;
|
|
993
993
|
return class McpManagerGateway extends _classSuper {
|
|
994
994
|
static {
|
|
995
995
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
@@ -1111,6 +1111,8 @@ let McpManagerGateway = (() => {
|
|
|
1111
1111
|
__esDecorate(this, null, _wbAwesomeCatalog_decorators, { kind: "method", name: "wbAwesomeCatalog", static: false, private: false, access: { has: (obj) => "wbAwesomeCatalog" in obj, get: (obj) => obj.wbAwesomeCatalog }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1112
1112
|
_wbAwesomeDetail_decorators = [Remote("wbAwesomeDetail")];
|
|
1113
1113
|
__esDecorate(this, null, _wbAwesomeDetail_decorators, { kind: "method", name: "wbAwesomeDetail", static: false, private: false, access: { has: (obj) => "wbAwesomeDetail" in obj, get: (obj) => obj.wbAwesomeDetail }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1114
|
+
_wbAwesomeReadme_decorators = [Remote("wbAwesomeReadme")];
|
|
1115
|
+
__esDecorate(this, null, _wbAwesomeReadme_decorators, { kind: "method", name: "wbAwesomeReadme", static: false, private: false, access: { has: (obj) => "wbAwesomeReadme" in obj, get: (obj) => obj.wbAwesomeReadme }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1114
1116
|
_wbAwesomeUpdateCheck_decorators = [Remote("wbAwesomeUpdateCheck")];
|
|
1115
1117
|
__esDecorate(this, null, _wbAwesomeUpdateCheck_decorators, { kind: "method", name: "wbAwesomeUpdateCheck", static: false, private: false, access: { has: (obj) => "wbAwesomeUpdateCheck" in obj, get: (obj) => obj.wbAwesomeUpdateCheck }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
1116
1118
|
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
@@ -1840,6 +1842,9 @@ let McpManagerGateway = (() => {
|
|
|
1840
1842
|
async wbAwesomeDetail(payload) {
|
|
1841
1843
|
return wbAwesomeDetail(payload?.name);
|
|
1842
1844
|
}
|
|
1845
|
+
async wbAwesomeReadme(payload) {
|
|
1846
|
+
return wbAwesomeReadme(payload || {});
|
|
1847
|
+
}
|
|
1843
1848
|
async wbAwesomeUpdateCheck() {
|
|
1844
1849
|
return wbAwesomeUpdateCheck();
|
|
1845
1850
|
}
|
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.1.0";
|
|
33
33
|
const USER_AGENT = `dsh-armory/${PLUGIN_VERSION} (+mcp-oauth)`;
|
|
34
34
|
const NET_TIMEOUT_MS = 15_000;
|
|
35
35
|
/** 授权流程整体存活时间:超过就判过期,避免会话表无限增长。 */
|
package/lib/typert.js
CHANGED
|
@@ -109,6 +109,8 @@ export const TYPERT = {
|
|
|
109
109
|
// T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins,数据层 lib/wb-awesome-market.js)。
|
|
110
110
|
mv('wbAwesomeCatalog', [payloadParam], 'AwesomeCatalogResult'),
|
|
111
111
|
mv('wbAwesomeDetail', [payloadParam], 'AwesomeDetailResult'),
|
|
112
|
+
// README 预览:点击卡片按需取一次(npm registry / GitHub raw),落盘缓存 7 天。
|
|
113
|
+
mv('wbAwesomeReadme', [payloadParam], 'AwesomeReadmeResult'),
|
|
112
114
|
mv('wbAwesomeUpdateCheck', [], 'WbUpdateHintResult'),
|
|
113
115
|
// WorkBuddy 专家直连(召唤预设 + 捆绑技能)。payload 全部 $any:数据形状由
|
|
114
116
|
// lib/expert-market.js 校验,与 wb* 系列同一宽容度。
|
package/lib/wb-awesome-market.js
CHANGED
|
@@ -186,7 +186,7 @@ async function fetchChain(etag) {
|
|
|
186
186
|
if (!data || typeof data !== "object" || Array.isArray(data) || !Array.isArray(data.plugins)) {
|
|
187
187
|
throw new Error("响应缺少 plugins 数组");
|
|
188
188
|
}
|
|
189
|
-
return { ok: true, data, etag: response.headers.get("etag") || "" };
|
|
189
|
+
return { ok: true, data, etag: response.headers.get("etag") || "", source: sourceUrl };
|
|
190
190
|
} catch (error) {
|
|
191
191
|
lastError = `${sourceUrl} → ${error.message}`;
|
|
192
192
|
continue;
|
|
@@ -196,13 +196,15 @@ async function fetchChain(etag) {
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/** 瘦身结果 + 元信息原子落盘(slim.json + meta.json),返回 counts。
|
|
199
|
-
* 同时刷新进程内缓存层,后续调用免掉 readFile + JSON.parse。
|
|
200
|
-
|
|
199
|
+
* 同时刷新进程内缓存层,后续调用免掉 readFile + JSON.parse。
|
|
200
|
+
* source 记录「这份缓存是从哪个源拿到的」:探测时优先回同一源比对 ETag,
|
|
201
|
+
* 避免主源挂掉走备用源取到缓存后、探测却只问主源而误报失败。 */
|
|
202
|
+
async function persistSlim(entries, etag, updated, options = {}, source = "") {
|
|
201
203
|
const counts = countsOf(entries);
|
|
202
204
|
const fetchedAt = Date.now();
|
|
203
205
|
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
|
+
await writeAtomic(metaPathOf(options), JSON.stringify({ etag, fetchedAt, updated, counts, source }));
|
|
207
|
+
setMemSlim(options, entries, { etag, fetchedAt, updated, counts, source });
|
|
206
208
|
return counts;
|
|
207
209
|
}
|
|
208
210
|
|
|
@@ -317,7 +319,7 @@ function catalogResult(entries, meta, loadedFrom, extra = {}) {
|
|
|
317
319
|
/** 拉取链成功后的统一走线:瘦身 → 原子落盘 → 组装 network 响应。 */
|
|
318
320
|
async function refreshFromNetwork(chain, options = {}) {
|
|
319
321
|
const entries = slimEntries(chain.data.plugins);
|
|
320
|
-
const counts = await persistSlim(entries, chain.etag, typeof chain.data.updated === "string" ? chain.data.updated : "", options);
|
|
322
|
+
const counts = await persistSlim(entries, chain.etag, typeof chain.data.updated === "string" ? chain.data.updated : "", options, typeof chain.source === "string" ? chain.source : "");
|
|
321
323
|
return {
|
|
322
324
|
ok: true,
|
|
323
325
|
loadedFrom: "network",
|
|
@@ -392,12 +394,43 @@ export async function wbAwesomeDetail(name, options = {}) {
|
|
|
392
394
|
}
|
|
393
395
|
|
|
394
396
|
/**
|
|
395
|
-
* ETag
|
|
396
|
-
*
|
|
397
|
-
|
|
398
|
-
|
|
397
|
+
* 单源 ETag 条件探测:HEAD(被 405/501 拒绝则降级 GET 并立刻 cancel 响应体)。
|
|
398
|
+
* 绝不落盘,只取 headers。返回 { ok: true, remoteEtag } 或 { ok: false, error }。
|
|
399
|
+
*/
|
|
400
|
+
async function probeEtagAt(sourceUrl, localEtag) {
|
|
401
|
+
const headers = { "user-agent": USER_AGENT, "if-none-match": localEtag };
|
|
402
|
+
let response;
|
|
403
|
+
try {
|
|
404
|
+
response = await fetch(sourceUrl, { method: "HEAD", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
405
|
+
if (response.status === 405 || response.status === 501) {
|
|
406
|
+
// HEAD 不被支持 → 降级 GET,立刻 cancel() 丢弃响应体,不读进内存。
|
|
407
|
+
response.body?.cancel?.();
|
|
408
|
+
response = await fetch(sourceUrl, { method: "GET", headers, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
409
|
+
response.body?.cancel?.();
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return { ok: false, error: `${sourceUrl} → ${error.message}` };
|
|
413
|
+
}
|
|
414
|
+
if (response.status === 304) return { ok: true, remoteEtag: localEtag };
|
|
415
|
+
if (response.ok) {
|
|
416
|
+
const remoteEtag = response.headers.get("etag") || "";
|
|
417
|
+
// 探测只信 ETag:响应没有 ETag 就无法判定,宁可报探测失败也不误报「有更新」。
|
|
418
|
+
if (!remoteEtag) return { ok: false, error: `${sourceUrl} → 响应缺少 ETag` };
|
|
419
|
+
return { ok: true, remoteEtag };
|
|
420
|
+
}
|
|
421
|
+
return { ok: false, error: `${sourceUrl} → HTTP ${response.status}` };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* ETag 条件探测(wbPluginUpdateCheck 同款语义):**绝不落盘**,只取 headers。
|
|
426
|
+
* reason 五枚举:no-source / probe-failed / no-cache / up-to-date / update-available。
|
|
399
427
|
* 无缓存(meta 无 etag)→ hasUpdate: false + no-cache(与插件市场不同:awesome 首次
|
|
400
428
|
* 由 catalog 链兜底加载,探测层不引导更新)。
|
|
429
|
+
*
|
|
430
|
+
* 探测与目录拉取必须走同一条源链:过去只问 AWESOME_SOURCES[0],于是「主源不通、
|
|
431
|
+
* 目录靠备用源成功」的机器上目录能正常加载、探测却每次 probe-failed —— 这正是
|
|
432
|
+
* 「DSH 社区目录更新探测失败」的来源。现在按「缓存实际来源 → 全部源」顺序逐个试,
|
|
433
|
+
* 首个拿到 ETag 的源胜出;全部失败才报 probe-failed。
|
|
401
434
|
*/
|
|
402
435
|
export async function wbAwesomeUpdateCheck(options = {}) {
|
|
403
436
|
try {
|
|
@@ -406,34 +439,20 @@ export async function wbAwesomeUpdateCheck(options = {}) {
|
|
|
406
439
|
if (!localEtag) {
|
|
407
440
|
return { ok: true, hasUpdate: false, reason: "no-cache", localEtag: "", remoteEtag: "", checkedAt: Date.now() };
|
|
408
441
|
}
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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" };
|
|
442
|
+
// 先问拿到这份缓存的源(ETag 同源可比),再兜底其余源。
|
|
443
|
+
const cachedSource = typeof meta.source === "string" ? meta.source : "";
|
|
444
|
+
const candidates = [...new Set([cachedSource, ...AWESOME_SOURCES].filter(Boolean))];
|
|
445
|
+
let lastError = "";
|
|
446
|
+
for (const sourceUrl of candidates) {
|
|
447
|
+
const probe = await probeEtagAt(sourceUrl, localEtag);
|
|
448
|
+
if (!probe.ok) {
|
|
449
|
+
lastError = probe.error;
|
|
450
|
+
continue;
|
|
432
451
|
}
|
|
433
|
-
const hasUpdate = remoteEtag !== localEtag;
|
|
434
|
-
return { ok: true, hasUpdate, reason: hasUpdate ? "update-available" : "up-to-date", localEtag, remoteEtag, checkedAt: Date.now() };
|
|
452
|
+
const hasUpdate = probe.remoteEtag !== localEtag;
|
|
453
|
+
return { ok: true, hasUpdate, reason: hasUpdate ? "update-available" : "up-to-date", localEtag, remoteEtag: probe.remoteEtag, checkedAt: Date.now() };
|
|
435
454
|
}
|
|
436
|
-
return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `awesome
|
|
455
|
+
return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag, remoteEtag: "", checkedAt: Date.now(), error: `awesome 市场更新探测失败:${lastError}` };
|
|
437
456
|
} catch (error) {
|
|
438
457
|
return { ok: false, hasUpdate: false, reason: "probe-failed", localEtag: "", remoteEtag: "", checkedAt: Date.now(), error: `更新探测异常:${error.message}` };
|
|
439
458
|
}
|
|
@@ -451,3 +470,252 @@ export async function wbAwesomeCachedInfo(options = {}) {
|
|
|
451
470
|
updated: typeof meta.updated === "string" ? meta.updated : "",
|
|
452
471
|
};
|
|
453
472
|
}
|
|
473
|
+
|
|
474
|
+
// ── README 预览(按需拉取 + 落盘缓存,纯只读)────────────────────────────────
|
|
475
|
+
//
|
|
476
|
+
// 为什么单独一条 RPC:目录 slim 里不放 README(13MB 全量本身就只留 13 个字段,
|
|
477
|
+
// 塞进 README 会把瘦身缓存撑爆)。README 只在用户点开某张卡片时按需取一次,
|
|
478
|
+
// 取到后按「npm 包名或仓库」落盘缓存,同一插件再点就秒开、也不怕断网。
|
|
479
|
+
|
|
480
|
+
/** 预览是用户点击触发的一次短请求,不能像目录那样等 120s。 */
|
|
481
|
+
const README_TIMEOUT_MS = 20_000;
|
|
482
|
+
/** 正文上限:超长的 CHANGELOG 类 README 截断,别把几 MB 文本塞进浏览器。 */
|
|
483
|
+
const README_MAX_CHARS = 160_000;
|
|
484
|
+
/** 磁盘缓存 TTL:README 变动不频繁,7 天足够,过期再取一次。 */
|
|
485
|
+
const README_CACHE_TTL_MS = 7 * 24 * 3600_000;
|
|
486
|
+
/** GitHub raw 里按顺序尝试的文件名(大小写在不同仓库里真的不一样)。 */
|
|
487
|
+
const README_FILES = ["README.md", "readme.md", "Readme.md", "README.MD", "README.markdown", "README_EN.md"];
|
|
488
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
489
|
+
|
|
490
|
+
function readmeCacheDir(options = {}) {
|
|
491
|
+
return join(awesomeMarketDir(options), "readme");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** 缓存文件名:来源标识压成安全短名(不用 crypto,避免多一个依赖)。 */
|
|
495
|
+
function readmeCachePath(key, options = {}) {
|
|
496
|
+
const safe = String(key || "").toLowerCase().replace(/[^a-z0-9._-]+/g, "_").slice(0, 96) || "item";
|
|
497
|
+
return join(readmeCacheDir(options), `${safe}.json`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* 从各种形态的仓库字段里提取 `owner/repo`;不是 GitHub 或提取不到返回 ""。
|
|
502
|
+
* 吃到过的形态:`owner/repo`、`https://github.com/o/r`、`git+https://github.com/o/r.git`、
|
|
503
|
+
* `git@github.com:o/r.git`、带 `#readme` / `/tree/main` 尾巴的。
|
|
504
|
+
*/
|
|
505
|
+
export function parseGithubRepo(value) {
|
|
506
|
+
if (typeof value !== "string") return "";
|
|
507
|
+
let text = value.trim();
|
|
508
|
+
if (!text) return "";
|
|
509
|
+
const ssh = /^git@github\.com:([^/\s]+)\/([^/\s#]+?)(?:\.git)?$/i.exec(text);
|
|
510
|
+
if (ssh) return `${ssh[1]}/${ssh[2].replace(/\.git$/i, "")}`;
|
|
511
|
+
text = text.replace(/^git\+/i, "");
|
|
512
|
+
const http = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+)/i.exec(text);
|
|
513
|
+
if (http) return `${http[1]}/${http[2].replace(/\.git$/i, "")}`;
|
|
514
|
+
const bare = /^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9._-]+)$/.exec(text);
|
|
515
|
+
if (bare) return `${bare[1]}/${bare[2].replace(/\.git$/i, "")}`;
|
|
516
|
+
return "";
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** 正文规整:去 BOM、统一换行、按字符上限截断。 */
|
|
520
|
+
function normalizeReadme(text) {
|
|
521
|
+
let out = String(text || "").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
522
|
+
if (out.length > README_MAX_CHARS) {
|
|
523
|
+
return { markdown: out.slice(0, README_MAX_CHARS), truncated: true };
|
|
524
|
+
}
|
|
525
|
+
return { markdown: out, truncated: false };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** 单次文本 GET(带超时);非 2xx 也把 body 读掉,避免连接悬挂。 */
|
|
529
|
+
async function fetchReadmeText(url, accept) {
|
|
530
|
+
const controller = new AbortController();
|
|
531
|
+
const timer = setTimeout(() => controller.abort(), README_TIMEOUT_MS);
|
|
532
|
+
try {
|
|
533
|
+
const response = await fetch(url, {
|
|
534
|
+
headers: { "user-agent": USER_AGENT, accept },
|
|
535
|
+
signal: controller.signal,
|
|
536
|
+
redirect: "follow",
|
|
537
|
+
});
|
|
538
|
+
if (!response.ok) {
|
|
539
|
+
try {
|
|
540
|
+
await response.text();
|
|
541
|
+
} catch {
|
|
542
|
+
// 忽略:只为释放连接
|
|
543
|
+
}
|
|
544
|
+
return { ok: false, reachable: true, status: response.status, error: `HTTP ${response.status}` };
|
|
545
|
+
}
|
|
546
|
+
return { ok: true, reachable: true, status: response.status, text: await response.text(), url: response.url || url };
|
|
547
|
+
} catch (error) {
|
|
548
|
+
const reason = error && error.name === "AbortError" ? `超时(${README_TIMEOUT_MS}ms)` : String((error && error.message) || error);
|
|
549
|
+
return { ok: false, reachable: false, status: 0, error: reason };
|
|
550
|
+
} finally {
|
|
551
|
+
clearTimeout(timer);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** npm 侧取 README:先打 `/<pkg>/latest`(小),没有再退全量 packument。 */
|
|
556
|
+
async function readmeFromNpm(npmName) {
|
|
557
|
+
const encoded = String(npmName).replace(/\//g, "%2F");
|
|
558
|
+
const latest = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}/latest`, "application/json");
|
|
559
|
+
if (latest.ok) {
|
|
560
|
+
let doc = null;
|
|
561
|
+
try {
|
|
562
|
+
doc = JSON.parse(latest.text);
|
|
563
|
+
} catch {
|
|
564
|
+
doc = null;
|
|
565
|
+
}
|
|
566
|
+
if (doc && typeof doc.readme === "string" && doc.readme.trim()) {
|
|
567
|
+
return { ok: true, markdown: doc.readme, repo: parseGithubRepo(doc.repository), file: doc.readmeFilename || "README.md" };
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const full = await fetchReadmeText(`${NPM_REGISTRY}/${encoded}`, "application/json");
|
|
571
|
+
if (!full.ok) {
|
|
572
|
+
return { ok: false, error: full.error };
|
|
573
|
+
}
|
|
574
|
+
let packument = null;
|
|
575
|
+
try {
|
|
576
|
+
packument = JSON.parse(full.text);
|
|
577
|
+
} catch {
|
|
578
|
+
return { ok: false, error: "npm 返回的不是合法 JSON" };
|
|
579
|
+
}
|
|
580
|
+
const markdown = packument && typeof packument.readme === "string" ? packument.readme : "";
|
|
581
|
+
if (!markdown.trim()) {
|
|
582
|
+
return { ok: false, error: latest.ok ? "npm 包没有 README" : `${latest.error};npm 包没有 README` };
|
|
583
|
+
}
|
|
584
|
+
return { ok: true, markdown, repo: parseGithubRepo(packument.repository), file: packument.readmeFilename || "README.md" };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* GitHub 侧 README 的候选基底,按「先直连、后被墙可用」排序。
|
|
589
|
+
* raw.githubusercontent.com 在部分网络(含国内常见环境)会直接 DNS 失败,
|
|
590
|
+
* 因此补 jsDelivr 的 GitHub 镜像(@main / @master 两个默认分支都试)。
|
|
591
|
+
* 每个基底各自独立:谁先拿到正文,就用谁的目录当相对图片的解析基准。
|
|
592
|
+
*/
|
|
593
|
+
function readmeGithubBases(repo) {
|
|
594
|
+
return [
|
|
595
|
+
`https://raw.githubusercontent.com/${repo}/HEAD/`,
|
|
596
|
+
`https://cdn.jsdelivr.net/gh/${repo}@main/`,
|
|
597
|
+
`https://cdn.jsdelivr.net/gh/${repo}@master/`,
|
|
598
|
+
`https://fastly.jsdelivr.net/gh/${repo}@main/`,
|
|
599
|
+
`https://fastly.jsdelivr.net/gh/${repo}@master/`,
|
|
600
|
+
];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* GitHub 侧取 README:按 README_FILES 顺序在候选基底上试。
|
|
605
|
+
* 一旦某个基底返回过任何 HTTP 响应,就认定网络可达:此时把该基底的
|
|
606
|
+
* 文件名全试完即可收工(不再换基底,避免对一个没 README 的仓库打 30 次请求)。
|
|
607
|
+
*/
|
|
608
|
+
async function readmeFromGithub(repo) {
|
|
609
|
+
let lastError = "";
|
|
610
|
+
let sawReachable = false;
|
|
611
|
+
for (const base of readmeGithubBases(repo)) {
|
|
612
|
+
for (const file of README_FILES) {
|
|
613
|
+
const attempt = await fetchReadmeText(`${base}${file}`, "text/plain");
|
|
614
|
+
if (attempt.ok && attempt.text.trim()) {
|
|
615
|
+
return { ok: true, markdown: attempt.text, base, file };
|
|
616
|
+
}
|
|
617
|
+
if (attempt.ok) {
|
|
618
|
+
lastError = "文件为空";
|
|
619
|
+
sawReachable = true;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (attempt.reachable) {
|
|
623
|
+
lastError = attempt.error;
|
|
624
|
+
sawReachable = true;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
lastError = attempt.error;
|
|
628
|
+
break; // 该基底整体不可达(DNS/超时),换下一个
|
|
629
|
+
}
|
|
630
|
+
if (sawReachable) break;
|
|
631
|
+
}
|
|
632
|
+
return { ok: false, error: lastError || "未找到 README" };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* 读取某个市场条目的 README,供客户端详情弹窗预览。
|
|
637
|
+
*
|
|
638
|
+
* 入参:`{ name, npm, repo, refresh }`(`npm` 可传字符串或 `{name}`)。
|
|
639
|
+
* 返回:`{ ok, reason: "cache" | "network" | "no-source" | "readme-unavailable",
|
|
640
|
+
* markdown, truncated, source: "npm" | "github", base, repo, npm, fetchedAt }`。
|
|
641
|
+
*
|
|
642
|
+
* `base` 是相对图片/链接的解析基准(GitHub raw 目录),客户端据此把 README 里的
|
|
643
|
+
* 相对路径 `assets/a.png` 还原成可加载的绝对 URL —— 不还原的话插图全是坏图。
|
|
644
|
+
*/
|
|
645
|
+
export async function wbAwesomeReadme(payload = {}) {
|
|
646
|
+
const name = typeof payload.name === "string" ? payload.name.trim() : "";
|
|
647
|
+
const npmName =
|
|
648
|
+
typeof payload.npm === "string"
|
|
649
|
+
? payload.npm.trim()
|
|
650
|
+
: payload.npm && typeof payload.npm === "object" && typeof payload.npm.name === "string"
|
|
651
|
+
? payload.npm.name.trim()
|
|
652
|
+
: "";
|
|
653
|
+
let repo = parseGithubRepo(payload.repo);
|
|
654
|
+
const wantRefresh = payload.refresh === true;
|
|
655
|
+
if (!npmName && !repo) {
|
|
656
|
+
return { ok: false, reason: "no-source", name, npm: "", repo: "", markdown: "", truncated: false, source: "", base: "", fetchedAt: 0, error: "该条目没有 npm 包名或 GitHub 仓库地址,无法读取 README" };
|
|
657
|
+
}
|
|
658
|
+
const cacheKey = npmName || repo;
|
|
659
|
+
const cachePath = readmeCachePath(cacheKey, payload);
|
|
660
|
+
if (!wantRefresh) {
|
|
661
|
+
const cached = await readJsonAt(cachePath);
|
|
662
|
+
if (
|
|
663
|
+
cached &&
|
|
664
|
+
typeof cached.markdown === "string" &&
|
|
665
|
+
cached.markdown &&
|
|
666
|
+
Date.now() - Number(cached.fetchedAt || 0) < README_CACHE_TTL_MS
|
|
667
|
+
) {
|
|
668
|
+
return {
|
|
669
|
+
ok: true,
|
|
670
|
+
reason: "cache",
|
|
671
|
+
name,
|
|
672
|
+
npm: npmName,
|
|
673
|
+
repo: typeof cached.repo === "string" ? cached.repo : repo,
|
|
674
|
+
markdown: cached.markdown,
|
|
675
|
+
truncated: Boolean(cached.truncated),
|
|
676
|
+
source: typeof cached.source === "string" ? cached.source : "",
|
|
677
|
+
base: typeof cached.base === "string" ? cached.base : "",
|
|
678
|
+
fetchedAt: Number(cached.fetchedAt || 0),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
let markdown = "";
|
|
683
|
+
let source = "";
|
|
684
|
+
let base = "";
|
|
685
|
+
const errors = [];
|
|
686
|
+
if (npmName) {
|
|
687
|
+
const fromNpm = await readmeFromNpm(npmName);
|
|
688
|
+
if (fromNpm.ok) {
|
|
689
|
+
markdown = fromNpm.markdown;
|
|
690
|
+
source = "npm";
|
|
691
|
+
repo = repo || parseGithubRepo(fromNpm.repo);
|
|
692
|
+
base = repo ? `https://raw.githubusercontent.com/${repo}/HEAD/` : "";
|
|
693
|
+
} else {
|
|
694
|
+
errors.push(`npm:${fromNpm.error}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (!markdown && repo) {
|
|
698
|
+
const fromGithub = await readmeFromGithub(repo);
|
|
699
|
+
if (fromGithub.ok) {
|
|
700
|
+
markdown = fromGithub.markdown;
|
|
701
|
+
source = "github";
|
|
702
|
+
base = fromGithub.base;
|
|
703
|
+
} else {
|
|
704
|
+
errors.push(`github:${fromGithub.error}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (!markdown.trim()) {
|
|
708
|
+
return { ok: false, reason: "readme-unavailable", name, npm: npmName, repo, markdown: "", truncated: false, source: "", base: "", fetchedAt: 0, error: `读取 README 失败:${errors.join(";") || "没有可用来源"}` };
|
|
709
|
+
}
|
|
710
|
+
const normalized = normalizeReadme(markdown);
|
|
711
|
+
const fetchedAt = Date.now();
|
|
712
|
+
try {
|
|
713
|
+
await writeAtomic(
|
|
714
|
+
cachePath,
|
|
715
|
+
JSON.stringify({ name, npm: npmName, repo, markdown: normalized.markdown, truncated: normalized.truncated, source, base, fetchedAt }),
|
|
716
|
+
);
|
|
717
|
+
} catch {
|
|
718
|
+
// 缓存写不进去不影响这次预览
|
|
719
|
+
}
|
|
720
|
+
return { ok: true, reason: "network", name, npm: npmName, repo, markdown: normalized.markdown, truncated: normalized.truncated, source, base, fetchedAt };
|
|
721
|
+
}
|
package/lib/whats-new.js
CHANGED
|
@@ -370,4 +370,21 @@ export const WHATS_NEW = [
|
|
|
370
370
|
},
|
|
371
371
|
],
|
|
372
372
|
},
|
|
373
|
+
{
|
|
374
|
+
version: '2.1.0',
|
|
375
|
+
items: [
|
|
376
|
+
{
|
|
377
|
+
text: '**社区插件能看 README 了**:点开 DSH 社区里的任意一张卡片,就地把该仓库的 README 拉下来渲染成可读正文(标题、段落、列表、代码块、表格、引用),不用跳浏览器。只在点开时按需取一次(20s 超时 / 16 万字符上限),取到后按「仓库或 npm 包名」落盘缓存 7 天,同一插件再点秒开、断网也能看。图片走只读代理绕开防盗链;渲染器是自写的,**不碰 `dangerouslySetInnerHTML`**,`javascript:` 这类伪协议链接一律降级成纯文本',
|
|
378
|
+
where: '插件 → DSH 社区 → 点开任意卡片',
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
text: '**社区目录不再卡在旧快照上**:首屏先渲染 localStorage 快照(0 网络 0 IPC),但真实目录结果回来后必须覆盖快照。之前快照一渲染,真实结果就被丢掉,目录于是一直停在旧快照上——现在两条路径都落到同一份状态,秒开与新鲜兼得',
|
|
382
|
+
where: '插件 → DSH 社区',
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
text: '**「更新探测失败」修好了**:过去探测只问主源,主源不通、目录靠备用源加载成功的机器上,目录能开、探测却每次都报失败。现在探测与目录走同一条源链——先问这份缓存实际来自哪个源(ETag 同源才可比),再兜底其余源,首个拿到 ETag 的源胜出,全部失败才报 `probe-failed`',
|
|
386
|
+
where: '插件 → DSH 社区 → 检查更新',
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
},
|
|
373
390
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-armory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & CodeBuddy plugin & awesome-dsh-plugins market installs, MCP OAuth authorization, tool-usage tailor, and profile-backed Host Remote management.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|