dsh-armory 2.3.1 → 2.4.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/lib/index.js CHANGED
@@ -14,6 +14,8 @@ import { deriveMcpPhase, formatMcpLog, sanitizeMcpLog } from "./mcp-observabilit
14
14
  import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInventory } from "./mcp-registry.js";
15
15
  // Skill 看板的卸载落盘点:来源白名单 + 根目录包含性 + 符号链接/根目录校验(lib/skill-remove.js)。
16
16
  import { removeSkillFromDisk } from "./skill-remove.js";
17
+ // Skill 看板的安装时间 + 市场原名投影(名字自建映射,lib/skill-dir-times.js)。
18
+ import { skillInstallInfo } from "./skill-dir-times.js";
17
19
  import { WHATS_NEW } from "./whats-new.js";
18
20
  import { attachWhatsNewEnglish } from "./whats-new-en.js";
19
21
  import { probeMcpServersAuthorization, wbConnectorCatalog, wbConnectorCachedCount, wbConnectorIcon, wbConnectorPreview, wbSkillCatalog, wbSkillDetail, wbSkillInstall, wbSkillInstalled, wbSkillUninstall } from "./wb-market.js";
@@ -38,6 +40,8 @@ import {
38
40
  cancelAuthorization as oauthCancel,
39
41
  clearTokenRecord as oauthClearToken,
40
42
  ensureAccessToken as oauthEnsureToken,
43
+ isSerializedGarbageCredential as oauthIsGarbageCredential,
44
+ isUsableBearerToken as oauthIsUsableToken,
41
45
  saveManualToken as oauthSaveManualToken,
42
46
  submitCallback as oauthSubmitCallback,
43
47
  } from "./mcp-oauth.js";
@@ -121,6 +125,24 @@ function withBearerHeader(rawServer, token) {
121
125
  return { ...rawServer, headers };
122
126
  }
123
127
 
128
+ /**
129
+ * 摘掉一个已经坏掉的 authorization 头(保留其它头、保留原键名大小写)。
130
+ * 只给「确认是序列化残骸」的配置用:历史版本把 `Bearer [object Object]` 写进了
131
+ * 用户的 patch,那条头不删掉,服务器就从「你没带凭据」(401) 变成「你凭据是坏的」(400),
132
+ * 装前探测因此再也认不出这是鉴权问题,用户既装不上也看不到授权入口。
133
+ */
134
+ function stripBearerHeader(rawServer) {
135
+ const container = rawServer && typeof rawServer.transport === "object" ? rawServer.transport : rawServer;
136
+ const headers = container?.headers;
137
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) return rawServer;
138
+ const next = {};
139
+ for (const [key, value] of Object.entries(headers)) {
140
+ if (String(key).toLowerCase() !== "authorization") next[key] = value;
141
+ }
142
+ const patched = { ...container, headers: next };
143
+ return rawServer && typeof rawServer.transport === "object" ? { ...rawServer, transport: patched } : patched;
144
+ }
145
+
124
146
  /**
125
147
  * 安装前把已落盘的 access_token 注进对应 server 的 headers。
126
148
  * 读不到 / 过期且刷不动就原样返回,交给 probe 去判定「需要授权」——
@@ -131,17 +153,27 @@ async function injectStoredOAuthTokens(mcpServers) {
131
153
  const out = {};
132
154
  for (const [serverName, rawServer] of Object.entries(mcpServers)) {
133
155
  const spec = nestedServerSpec(rawServer);
134
- if (!isOAuthCandidate(spec) || spec.headers.authorization) {
156
+ // 配置里已有的 authorization 一律优先:可能是用户手填的真令牌,也可能是 R7
157
+ // 的 ${...} 占位符(那种要原样保留、由 missingSlots 回报面板补填)。
158
+ const declared = spec.headers.authorization;
159
+ const poisoned = Boolean(declared) && oauthIsGarbageCredential(declared);
160
+ if (!isOAuthCandidate(spec) || (declared && !poisoned)) {
135
161
  out[serverName] = rawServer;
136
162
  continue;
137
163
  }
138
164
  let token = "";
139
165
  try {
140
- token = String((await oauthEnsureToken(spec.url)) || "");
166
+ // ensureAccessToken 回的是 {ok, accessToken, refreshed, error} 对象,不是字符串。
167
+ // 这里曾经直接对那个对象取字符串:得到 "[object Object]",真值、非空,
168
+ // 于是每个 HTTP 连接器都被注进 `authorization: Bearer [object Object]`
169
+ // —— 装上必挂,还把服务器的 401 伪装成 400(见 test/auth-gate-regression.mjs 第 7 节)。
170
+ const result = await oauthEnsureToken(spec.url);
171
+ if (result?.ok && oauthIsUsableToken(result.accessToken)) token = String(result.accessToken).replace(/^Bearer\s+/i, "").trim();
141
172
  } catch {
142
173
  token = "";
143
174
  }
144
- out[serverName] = token ? withBearerHeader(rawServer, token) : rawServer;
175
+ if (token) out[serverName] = withBearerHeader(rawServer, token);
176
+ else out[serverName] = poisoned ? stripBearerHeader(rawServer) : rawServer;
145
177
  }
146
178
  return out;
147
179
  }
@@ -155,7 +187,12 @@ async function resolveOAuthTarget(connectorId, serverName) {
155
187
  if (!rawServer) throw new Error(`连接器 ${connectorId} 里没有 server「${name || "(空)"}」`);
156
188
  const spec = nestedServerSpec(rawServer);
157
189
  if (!spec.url) throw new Error(`server「${name}」没有可授权的 URL(stdio 型不需要 OAuth 授权)`);
158
- return { serverName: name, url: spec.url, headers: spec.headers, transport: spec.transport };
190
+ // 坏掉的 authorization 不能跟着去探测:带着 `Bearer [object Object]` 打过去,服务器
191
+ // 回的是 400「token parse failed」而不是 401「empty access token」,授权发现
192
+ // 那条路就在第一步被判定成「非鉴权错误」而中止。
193
+ const headers = { ...spec.headers };
194
+ if (headers.authorization && oauthIsGarbageCredential(headers.authorization)) delete headers.authorization;
195
+ return { serverName: name, url: spec.url, headers, transport: spec.transport };
159
196
  }
160
197
 
161
198
  /** 宿主 shell 服务的几种可能落点,逐个试;拿不到就返回 undefined 走系统兜底。 */
@@ -1780,6 +1817,12 @@ let McpManagerGateway = (() => {
1780
1817
  if (result === undefined) throw new Error("宿主没有挂载 skill 注册表(@deepseek-ai/dsh-skill),无法卸载");
1781
1818
  return { note: `已卸载 ${name}(${result.kind === "symlink" ? "符号链接" : "目录"}:${result.path})` };
1782
1819
  }
1820
+ // 安装时间 + 市场原名(按 skill 名字索引):宿主自扫标准技能根目录、读 SKILL.md
1821
+ // 头部 name 建立映射——不依赖 listSkills 行里的 path(注册表可能整列不给)。
1822
+ // 永不抛错——时间与原名是辅助信息,单个目录读失败只是没有该条目,不该拖垮看板。
1823
+ async skillInstallInfo(payload) {
1824
+ return skillInstallInfo(payload || {});
1825
+ }
1783
1826
  // ── 自定义套件(Skill 看板)─────────────────────────────────────────
1784
1827
  // 数据层在 lib/suites-registry.js(~/.dsh/armory/suites.json);这里只做 RPC 转发。
1785
1828
  // listSkillSuites —— 永不抛错:文件缺失 / JSON 损坏 / 结构不对都回落空集合,
@@ -1884,6 +1927,10 @@ let McpManagerGateway = (() => {
1884
1927
  error: started?.error || "授权流程启动失败",
1885
1928
  mode: started?.mode || "",
1886
1929
  redirectUri: started?.redirectUri || "",
1930
+ // 授权页这条路走不通(服务器不提供 OAuth 发现、动态注册被拒…)时,
1931
+ // 手填令牌仍然是可用通道:把它交给面板,别只留一行红字。
1932
+ manualTokenOnly: started?.manualTokenOnly === true,
1933
+ serverUrl: started?.serverUrl || target.url,
1887
1934
  };
1888
1935
  }
1889
1936
  // 默认由宿主直接把授权页推给系统浏览器;open:false 留给面板自己渲染链接。
package/lib/mcp-oauth.js CHANGED
@@ -29,7 +29,7 @@ import { homedir } from "node:os";
29
29
  import { join } from "node:path";
30
30
 
31
31
  /** 插件版本:DCR 的 clientInfo 与 UA 共用一处,避免两边各自漂移。 */
32
- const PLUGIN_VERSION = "2.3.1";
32
+ const PLUGIN_VERSION = "2.3.2";
33
33
  const USER_AGENT = `dsh-armory/${PLUGIN_VERSION} (+mcp-oauth)`;
34
34
  const NET_TIMEOUT_MS = 15_000;
35
35
  /** 授权流程整体存活时间:超过就判过期,避免会话表无限增长。 */
@@ -59,6 +59,69 @@ function sha256hex(text) {
59
59
  return createHash("sha256").update(String(text)).digest("hex");
60
60
  }
61
61
 
62
+ // ── 凭据形状 ────────────────────────────────────────────────────────────
63
+ // 这一层存在的理由是一条真实事故:`ensureAccessToken()` 返回的是对象
64
+ // `{ok, accessToken, ...}`,调用方写成 `String(await ensureAccessToken(url))` 就得到
65
+ // `"[object Object]"` —— 真值、非空、于是被当成正经令牌注进 `authorization` 头并落盘。
66
+ // 服务器收到后回的不是 401「empty access token」而是 400「token parse failed」,
67
+ // 装前探测只认 401/403,于是「需要授权」被彻底漏判:配置写进去了、授权页永远不弹、
68
+ // 连接永远失败。所以「什么才算一个能用的令牌」必须只写一次、放在这里。
69
+
70
+ /** Bearer 令牌长度上限:正常签发物不会这么长,超长基本是把整坨响应粘回来了。 */
71
+ const BEARER_TOKEN_MAX = 8192;
72
+
73
+ /** 去掉可选的 `Bearer ` 前缀与首尾空白(光杆一个 "Bearer" 等于没有令牌)。 */
74
+ function stripBearerPrefix(value) {
75
+ return String(value ?? "").replace(/^\s*Bearer\b[ \t]*/i, "").trim();
76
+ }
77
+
78
+ /**
79
+ * 一眼假:序列化残骸(`[object Object]` / `undefined` / `NaN`)、空值、光杆 `Bearer`。
80
+ * 未替换的 `${TOKEN}` 占位符**不算**残骸——那是 R7 令牌引导的合法中间态,
81
+ * 由 `missingSlots` 单独回报面板,不能被这里悄悄抹掉。
82
+ */
83
+ export function isSerializedGarbageCredential(value) {
84
+ if (value === null || value === undefined) return true;
85
+ if (typeof value !== "string") return true;
86
+ const text = value.trim();
87
+ if (!text) return true;
88
+ if (/^\[object\s+\w+\]$/i.test(text) || /\[object\s+\w+\]/i.test(text)) return true;
89
+ if (/^(?:undefined|null|NaN)$/i.test(stripBearerPrefix(text))) return true;
90
+ return !stripBearerPrefix(text);
91
+ }
92
+
93
+ /** 是不是一个可以原样发给服务器的 access token。 */
94
+ export function isUsableBearerToken(value) {
95
+ if (isSerializedGarbageCredential(value)) return false;
96
+ const token = stripBearerPrefix(value);
97
+ if (token.length > BEARER_TOKEN_MAX) return false;
98
+ if (/\$\{[^}]*\}/.test(token)) return false;
99
+ // 控制符/换行:HTTP 头里出现就是注入面(fetch 也会拒),一律视为不可用。
100
+ return !/[\u0000-\u001f\u007f\u0080-\u009f]/.test(token);
101
+ }
102
+
103
+ // ── 「这是鉴权失败吗」判定 ───────────────────────────────────────────────
104
+
105
+ /**
106
+ * 各家网关的鉴权失败措辞(JSON-RPC error.message 里常见)。刻意只列鉴权语义,
107
+ * 不收 "not found" / "invalid params" 这类——把服务器故障判成「要授权」会把用户
108
+ * 引到一条根本走不通的授权路上。
109
+ */
110
+ const AUTH_BODY_RE =
111
+ /(empty access token|access token (?:empty|missing|invalid)|token parse|invalid[ _-]?token|missing[ _-]?token|no (?:access )?token|token (?:expired|invalid)|unauthorized|unauthenticated|(?:authentication|authorization) (?:failed|error|required)|(?:需要|请)(?:登录|授权|鉴权))/i;
112
+
113
+ /**
114
+ * 一次 MCP 握手失败是不是「缺凭据」。
115
+ * 401/403 无条件算;其它 4xx 只有在响应体明确说鉴权时才算(腾讯 ima 就是 400 +
116
+ * `msg:token parse failed`,且不带 `WWW-Authenticate`);2xx/3xx/5xx 一律不算。
117
+ */
118
+ export function mcpAuthFailure(status, bodyText = "") {
119
+ const code = Number(status) || 0;
120
+ if (code === 401 || code === 403) return true;
121
+ if (code < 400 || code >= 500) return false;
122
+ return AUTH_BODY_RE.test(String(bodyText || ""));
123
+ }
124
+
62
125
  /** PKCE 授权码流程的 verifier/challenge(S256)。 */
63
126
  export function createPkce() {
64
127
  const verifier = b64url(randomBytes(48));
@@ -114,8 +177,8 @@ async function fetchJson(url, init = {}) {
114
177
  // ── 探测 ────────────────────────────────────────────────────────────────
115
178
 
116
179
  /**
117
- * 打一次 MCP initialize,拿回状态码与 `WWW-Authenticate`。
118
- * 只用于确认「这颗 server 是不是 401 且声明了 OAuth」,不做其它判断。
180
+ * 打一次 MCP initialize,拿回状态码、`WWW-Authenticate` 与响应体摘要。
181
+ * 只用于确认「这颗 server 是不是要凭据」,不做其它判断。
119
182
  */
120
183
  export async function probeServer(serverUrl, headers = {}) {
121
184
  const body = JSON.stringify({
@@ -142,16 +205,18 @@ export async function probeServer(serverUrl, headers = {}) {
142
205
  },
143
206
  body
144
207
  });
145
- // 读掉 body,避免 socket 悬挂
146
- await response.text().catch(() => "");
208
+ // 读掉 body,避免 socket 悬挂;正文还要留给鉴权判定(不少网关 400 也带鉴权语义)。
209
+ const text = await response.text().catch(() => "");
147
210
  return {
148
211
  ok: true,
149
212
  status: response.status,
150
213
  wwwAuthenticate: response.headers.get("www-authenticate") || "",
214
+ body: trimText(text, 400),
215
+ authRequired: mcpAuthFailure(response.status, text),
151
216
  error: ""
152
217
  };
153
218
  } catch (error) {
154
- return { ok: false, status: 0, wwwAuthenticate: "", error: String(error?.message || error) };
219
+ return { ok: false, status: 0, wwwAuthenticate: "", body: "", authRequired: false, error: String(error?.message || error) };
155
220
  }
156
221
  }
157
222
 
@@ -439,7 +504,8 @@ export async function clearTokenRecord(serverUrl) {
439
504
 
440
505
  /** 记录里是否有一个「还没过期」的 access token(留 60s 余量)。 */
441
506
  export function tokenIsFresh(record) {
442
- if (!record || !str(record.accessToken)) return false;
507
+ // 形状先于时效:`[object Object]` 这种残骸哪怕带着未来的 expiresAt 也不是令牌。
508
+ if (!record || !isUsableBearerToken(record.accessToken)) return false;
443
509
  if (!record.expiresAt) return true; // 服务器没给 expires_in:视为长期有效,等 401 再说
444
510
  // 落盘时写的是 ISO 串(见 exchangeCode),但令牌库是纯 JSON、可能被人手改或由
445
511
  // 旧版本写成数字毫秒,两种都认:数字直接当时间戳,字符串才走 Date.parse。
@@ -478,7 +544,7 @@ async function refreshAccessToken(record) {
478
544
  headers: { "content-type": "application/x-www-form-urlencoded" },
479
545
  body: body.toString()
480
546
  });
481
- if (!result.ok || !str(result.json?.access_token)) {
547
+ if (!result.ok || !isUsableBearerToken(result.json?.access_token)) {
482
548
  return { ok: false, accessToken: "", error: `刷新 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}` };
483
549
  }
484
550
  await writeTokenRecord(record.serverUrl, tokenRecordPatch(result.json));
@@ -512,20 +578,35 @@ function tokenRecordPatch(tokenResponse) {
512
578
  export async function beginAuthorization({ serverName, serverUrl, headers = {}, clientName = "DeepSeek Harness" } = {}) {
513
579
  if (!serverUrl) return fail("", "", "", "缺少 server url");
514
580
  const probe = await probeServer(serverUrl, headers);
515
- if (!probe.ok) return fail("", "", "", `无法连接 ${serverUrl}:${probe.error}`);
516
- if (probe.status !== 401 && probe.status !== 403) {
517
- // 有些服务器首次 initialize 回 200,鉴权在 tools/call 才触发;这里仍允许继续试一次。
518
- if (probe.status >= 400 && probe.status !== 401 && probe.status !== 403) {
519
- return fail("", "", "", `服务器返回 HTTP ${probe.status},不是预期的 401/403,无法确认其 OAuth 要求`);
520
- }
581
+ if (!probe.ok) return fail("", "", "", `无法连接 ${serverUrl}:${probe.error}`, { serverUrl });
582
+ if (probe.status >= 400 && !probe.authRequired) {
583
+ // 明确的非鉴权失败(404 路径错、500 服务器挂了…):继续往下走只会把用户
584
+ // 引到一条根本不存在的授权路上,直接说清楚更省时间。
585
+ return fail("", "", "", `服务器返回 HTTP ${probe.status},不是预期的鉴权错误:${probe.body || "(无响应体)"}`, { serverUrl });
521
586
  }
587
+ // 到这里要么服务器点名要凭据(401/403/4xx 鉴权体),要么匿名握手就过了
588
+ // (有些实现把鉴权推迟到 tools/call)——两种都继续试一次发现流程。
522
589
 
523
590
  const resource = await resolveProtectedResource({ serverUrl, wwwAuthenticate: probe.wwwAuthenticate });
524
- if (!resource.ok) return fail("", "", "", resource.error);
591
+ if (!resource.ok) return fail("", "", "", resource.error, { serverUrl, manualTokenOnly: true });
525
592
  const as = await resolveAuthorizationServer(resource.authorizationServers[0]);
526
- if (!as.ok) return fail("", "", "", as.error);
593
+ if (!as.ok) {
594
+ // 现实里很常见:服务器要 Bearer token,但压根不实现 RFC 9728/8414 的 well-known
595
+ // 发现(腾讯 ima 就是这样)。这时没有任何 authorizeUrl 可开,硬报错等于把用户
596
+ // 关在门外——回 manualTokenOnly,让面板把手填令牌那条兜底路摊开给他。
597
+ return fail("", "", "", `${as.error}\n该服务器没有提供标准 OAuth 授权发现,无法自动打开授权页;如果你已有访问令牌,可以直接粘贴安装。`, {
598
+ serverUrl,
599
+ manualTokenOnly: true
600
+ });
601
+ }
527
602
  if (!as.tokenEndpointAuthMethods.includes("none")) {
528
- return fail("", "", "", `该授权服务器要求客户端认证(${as.tokenEndpointAuthMethods.join("/")}),本插件只支持公共客户端(none)`);
603
+ return fail(
604
+ "",
605
+ "",
606
+ "",
607
+ `该授权服务器要求客户端认证(${as.tokenEndpointAuthMethods.join("/")}),本插件只支持公共客户端(none);如果你已有访问令牌,可以直接粘贴安装。`,
608
+ { serverUrl, manualTokenOnly: true }
609
+ );
529
610
  }
530
611
 
531
612
  const scope = as.scopesSupported.length ? as.scopesSupported.join(" ") : "";
@@ -543,13 +624,22 @@ export async function beginAuthorization({ serverName, serverUrl, headers = {},
543
624
  const template = extractExpectedRedirectUri(registration.raw || registration.error);
544
625
  if (!template) {
545
626
  loopback.close();
546
- return fail("", "", "", registration.error);
627
+ return fail("", "", "", `${registration.error}\n动态注册走不通,无法生成授权页;如果你已有访问令牌,可以直接粘贴安装。`, {
628
+ serverUrl,
629
+ manualTokenOnly: true
630
+ });
547
631
  }
548
632
  const expected = substituteRedirectPlaceholder(template, serverName || clientName);
549
633
  const retry = await registerClient(as, { redirectUri: expected, clientName, scope });
550
634
  if (!retry.ok) {
551
635
  loopback.close();
552
- return fail("", "", "", `${registration.error}\n改用服务器指定的回调地址 ${expected} 重试仍失败:${retry.error}`);
636
+ return fail(
637
+ "",
638
+ "",
639
+ "",
640
+ `${registration.error}\n改用服务器指定的回调地址 ${expected} 重试仍失败:${retry.error}\n如果你已有访问令牌,可以直接粘贴安装。`,
641
+ { serverUrl, manualTokenOnly: true }
642
+ );
553
643
  }
554
644
  registration = retry;
555
645
  redirectUri = expected;
@@ -619,8 +709,21 @@ function buildAuthorizeUrl({ authorizationEndpoint, clientId, redirectUri, scope
619
709
  return url.href;
620
710
  }
621
711
 
622
- function fail(sessionId, authorizeUrl, mode, error) {
623
- return { ok: false, sessionId, authorizeUrl, mode, redirectUri: "", hint: "", error };
712
+ function fail(sessionId, authorizeUrl, mode, error, extra = {}) {
713
+ return {
714
+ ok: false,
715
+ sessionId,
716
+ authorizeUrl,
717
+ mode,
718
+ redirectUri: "",
719
+ hint: "",
720
+ // manualTokenOnly:授权页这条路走不通,但「手填访问令牌」仍然可用。面板据此
721
+ // 也要把弹层开出来(而不是只甩一行红字),否则用户看到的是死路。
722
+ manualTokenOnly: false,
723
+ serverUrl: "",
724
+ error,
725
+ ...extra
726
+ };
624
727
  }
625
728
 
626
729
  function pruneSessions() {
@@ -667,7 +770,7 @@ async function exchangeCode(session, code) {
667
770
  headers: { "content-type": "application/x-www-form-urlencoded" },
668
771
  body: body.toString()
669
772
  });
670
- if (!result.ok || !str(result.json?.access_token)) {
773
+ if (!result.ok || !isUsableBearerToken(result.json?.access_token)) {
671
774
  session.status = "failed";
672
775
  session.error = `换取 token 失败(HTTP ${result.status || "网络错误"}):${trimText(result.text)}`;
673
776
  return;
@@ -775,8 +878,12 @@ export async function saveManualToken({ serverUrl, serverName = "", token, token
775
878
  const raw = String(token || "").trim();
776
879
  if (!serverUrl) return { ok: false, error: "缺少 server url" };
777
880
  if (!raw) return { ok: false, error: "token 不能为空" };
778
- const accessToken = raw.replace(/^Bearer\s+/i, "").trim();
881
+ const accessToken = stripBearerPrefix(raw);
779
882
  if (!accessToken) return { ok: false, error: "token 不能为空" };
883
+ // 手填这条路最容易粘进残骸(`[object Object]`、没替换的 ${TOKEN}、带换行的整段 curl)。
884
+ if (!isUsableBearerToken(accessToken)) {
885
+ return { ok: false, error: "这不像是个可用的访问令牌(空值、未替换的 ${...} 占位符或序列化残骸),请重新从服务方复制。" };
886
+ }
780
887
  await writeTokenRecord(serverUrl, {
781
888
  accessToken,
782
889
  tokenType: "Bearer",
@@ -0,0 +1,111 @@
1
+ // Skill 看板的「安装时间 + 市场原名」投影(按 skill 名字索引)。
2
+ //
3
+ // 为什么不按 listSkills 行里的 path 查:宿主注册表的行**不一定**带 path(字段是条件
4
+ // 展开,lib/index.js 只透传注册表给的),实测有的版本整列都缺——按 path 查会整表静默
5
+ // 落空。这里改为宿主自建映射:扫描标准技能根目录,读每个目录 SKILL.md 头部的
6
+ // `name:` 字段得到「看板显示名」,建立 名字 → 目录 → 时间/原名 的映射,按名字返回。
7
+ // 面板的每一行必有 name(看板主键),join 永不落空。
8
+ //
9
+ // installedAt 取值优先级(与 wb-market.wbSkillInstalled 同一口径):
10
+ // ① 安装状态账本(kind=skill,按 installPath 归一对齐)登记的首次安装时间;
11
+ // ② 目录创建时间——重装 = rm 后 rename 换入新目录,创建时间即最近一次落盘;
12
+ // ③ 目录修改时间兜底(个别文件系统不吐 birthtime)。
13
+ // 没有 SKILL.md 的目录不会被注册表发现,直接跳过;同名目录按扫描序首个优先
14
+ // (agents 根在 dsh 根之前,与注册表的来源优先级同向)。
15
+ // 账本命中的条目附带 marketName / marketplace / marketVersion:账本登记的是市场里的
16
+ // 名字,看板显示的是 SKILL.md 的 name 字段——两者可以不同,投影出去供面板对应。
17
+ //
18
+ // 纯只读(readdir / stat / 只读 SKILL.md 头部 4KB);账本读失败只降级到 ②③。
19
+ // 存储根目录可注入(options.rootDir / DSH_ARMORY_HOME),home 可注入(options.home,测试用)。
20
+ import { open, readdir, stat } from "node:fs/promises";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { listInstalled } from "./wb-install-state.js";
24
+
25
+ /** join 键归一:分隔符统一 /、去尾斜杠。账本 installPath 与实际扫描目录是两套来源,
26
+ * Windows 下分隔符与结尾斜杠可能有差异,对齐前先拉齐形态。 */
27
+ const normPath = (value) => String(value || "").trim().replace(/[\\/]+/g, "/").replace(/\/+$/, "");
28
+
29
+ /** 只读文件头部 4KB——frontmatter 永远在最前面,整读大 SKILL.md 不值得。 */
30
+ async function head(path, bytes = 4096) {
31
+ const handle = await open(path, "r");
32
+ try {
33
+ const buffer = Buffer.alloc(bytes);
34
+ const { bytesRead } = await handle.read(buffer, 0, bytes, 0);
35
+ return buffer.toString("utf8", 0, bytesRead);
36
+ } finally {
37
+ await handle.close().catch(() => {});
38
+ }
39
+ }
40
+
41
+ /** SKILL.md 头部 frontmatter 的 name 字段;没有 frontmatter / 没写 name 返回空串。 */
42
+ export function frontmatterName(text) {
43
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ""));
44
+ if (!block) return "";
45
+ const line = /^name:[ \t]*(.+?)[ \t]*$/m.exec(block[1]);
46
+ return line ? line[1].replace(/^["']|["']$/g, "") : "";
47
+ }
48
+
49
+ /**
50
+ * 扫描技能根目录,返回「看板名字 → 安装信息」映射。
51
+ * @param {object} payload 客户端信封:{ cwd? }(项目作用域时带项目根)
52
+ * @returns {Promise<{info: Record<string, {installedAt: number, dir: string,
53
+ * marketName?: string, marketplace?: string, marketVersion?: string}>}>}
54
+ */
55
+ export async function skillInstallInfo(payload = {}, options = {}) {
56
+ const home = options.home || homedir();
57
+ const roots = [
58
+ join(home, ".agents", "skills"),
59
+ join(home, ".dsh", "skills"),
60
+ ];
61
+ const cwd = String(payload?.cwd || "").trim();
62
+ if (cwd) {
63
+ roots.push(join(cwd, ".agents", "skills"));
64
+ roots.push(join(cwd, ".dsh", "skills"));
65
+ }
66
+ const ledger = await listInstalled("skill", options).catch(() => []);
67
+ const byPath = new Map(
68
+ ledger.filter((row) => row && row.installPath).map((row) => [normPath(row.installPath), row]),
69
+ );
70
+ const info = {};
71
+ for (const root of roots) {
72
+ let entries = [];
73
+ try {
74
+ entries = await readdir(root);
75
+ } catch {
76
+ continue; // 根目录不存在(没装过任何市场技能 / 没有 ~/.dsh/skills):跳过
77
+ }
78
+ for (const entry of entries) {
79
+ if (entry.startsWith(".")) continue;
80
+ const dir = join(root, entry);
81
+ let dirStat;
82
+ try {
83
+ dirStat = await stat(dir);
84
+ } catch {
85
+ continue; // 竞态删除等:跳过
86
+ }
87
+ if (!dirStat.isDirectory()) continue;
88
+ let name = "";
89
+ try {
90
+ name = frontmatterName(await head(join(dir, "SKILL.md")));
91
+ } catch {
92
+ continue; // 没有 SKILL.md:注册表发现不了,不进映射
93
+ }
94
+ if (!name) name = entry;
95
+ if (info[name]) continue; // 同名:首个根/首个目录优先
96
+ const hit = byPath.get(normPath(dir));
97
+ const bornAt = Math.round(Number(dirStat.birthtimeMs) || 0);
98
+ const row = {
99
+ installedAt: (hit?.installedAt || 0) || bornAt || Math.round(Number(dirStat.mtimeMs) || 0),
100
+ dir,
101
+ };
102
+ if (hit?.name) {
103
+ row.marketName = hit.name;
104
+ row.marketplace = hit.source?.marketplace || "";
105
+ row.marketVersion = hit.version || "";
106
+ }
107
+ info[name] = row;
108
+ }
109
+ }
110
+ return { info };
111
+ }
package/lib/typert.js CHANGED
@@ -77,6 +77,11 @@ export const TYPERT = {
77
77
  // 卸载已安装 skill:永久删除用户级/项目级 skills 根目录内的条目。三处契约缺一即 404,
78
78
  // 删除规则全在 lib/skill-remove.js(来源白名单 / 根目录包含性 / 符号链接只删链接)。
79
79
  mv('removeSkill', [payloadParam], 'NoteResult'),
80
+ // 安装时间 + 市场原名(Skill 看板卡片徽标 /「最近安装」排序的数据源):宿主自扫
81
+ // 标准根目录按 SKILL.md name 建映射,出 { ok, info: { [name]: { installedAt, ... } } }。
82
+ // 不按 listSkills 行的 path 查——注册表行可能整列缺 path(实测)。形状由
83
+ // lib/skill-dir-times.js 校验,与 wb* 系列同一宽容度(payload $any)。
84
+ mv('skillInstallInfo', [payloadParam], 'SkillInstallInfoResult'),
80
85
  // 自定义套件(Skill 看板的「一组相关 skill 打包引用」):读 = 全量集合,
81
86
  // 存 = 整集合覆盖(UI 一次提交右侧全部套件,不做增量 upsert)。数据层在
82
87
  // lib/suites-registry.js,id 分配 / 成员去重保序 / 脏数据拒绝都在那边,