dsh-commandcode-quota 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-commandcode-quota",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Command Code balance and quota panel for DeepSeek Harness: a status row under the composer showing balance and the 5-hour, weekly and monthly windows.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,6 +19,7 @@
19
19
  "src/",
20
20
  "locale/",
21
21
  "test/",
22
+ "scripts/",
22
23
  "cordis.patch.yml",
23
24
  "README.md",
24
25
  "README.zh.md",
@@ -35,7 +36,8 @@
35
36
  "./package.json": "./package.json"
36
37
  },
37
38
  "scripts": {
38
- "test": "node test/host.test.cjs && node test/client.test.cjs"
39
+ "test": "node test/host.test.cjs && node test/client.test.cjs",
40
+ "version": "node scripts/sync-version.cjs && git add src/index.js"
39
41
  },
40
42
  "repository": {
41
43
  "type": "git",
@@ -49,7 +51,7 @@
49
51
  "access": "public"
50
52
  },
51
53
  "peerDependencies": {
52
- "@deepseek-ai/dsh-credentials": "*"
54
+ "@deepseek-ai/dsh-credentials": ">=0.1.0-0 <0.2.0-0 || >=0.2.0-0 <0.3.0-0 || >=0.3.0-0 <0.4.0-0"
53
55
  },
54
56
  "peerDependenciesMeta": {
55
57
  "@deepseek-ai/dsh-credentials": {
@@ -57,6 +59,9 @@
57
59
  }
58
60
  },
59
61
  "dsh": {
62
+ "engines": {
63
+ "dsh": ">=0.2.0-rc.2"
64
+ },
60
65
  "bundle": {
61
66
  "patch": "./cordis.patch.yml"
62
67
  },
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // 把 package.json 的版本同步进 src/index.js 的 user-agent 行。
3
+ //
4
+ // 由 npm 的 "version" 生命周期脚本调用。该钩子的执行时机是:
5
+ // package.json 的 version 已更新,但改动尚未 commit —— 所以这里改完文件后
6
+ // 再 git add,就能和版本号进入同一个 commit。
7
+ //
8
+ // 手动执行也可以:node scripts/sync-version.cjs
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+
12
+ const root = path.resolve(__dirname, "..");
13
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
14
+ const target = path.join(root, "src", "index.js");
15
+ const source = fs.readFileSync(target, "utf8");
16
+
17
+ const USER_AGENT = /("user-agent":\s*")[^"]*(")/;
18
+ const wanted = `${manifest.name}/${manifest.version}`;
19
+
20
+ if (!USER_AGENT.test(source)) {
21
+ console.error(`[sync-version] src/index.js 里找不到 user-agent 行,无法同步到 ${wanted}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ const next = source.replace(USER_AGENT, `$1${wanted}$2`);
26
+
27
+ if (next === source) {
28
+ console.log(`[sync-version] user-agent 已是 ${wanted},无需修改`);
29
+ process.exit(0);
30
+ }
31
+
32
+ fs.writeFileSync(target, next);
33
+ console.log(`[sync-version] user-agent 已更新为 ${wanted}`);
package/src/client.js CHANGED
@@ -17,6 +17,7 @@ window.__ModuleLoader__.load({
17
17
  const { useCallback, useEffect, useState, useRef } = React;
18
18
 
19
19
  const FETCH_URL = "/__dsh-commandcode-quota/dashboard";
20
+ const CREDENTIALS_URL = "/__dsh-commandcode-quota/credentials";
20
21
  // 每 3 分钟后台自动刷新一次余额(面板开/关都刷新, 打开时看到的就是最新数据)
21
22
  const REFRESH_MS = 3 * 60 * 1000;
22
23
 
@@ -306,6 +307,63 @@ window.__ModuleLoader__.load({
306
307
  `剩余 ${fmtCurrency(remaining)} · 重置 ${fmtDuration(resetAt)}${fmtResetDay(resetAt)}`));
307
308
  }
308
309
 
310
+ // ---- 凭据条目选择 ------------------------------------------------------------
311
+ //
312
+ // 这个选择器在整个面板里【恒定渲染】—— 加载中、报错、甚至完全没数据时都在。
313
+ // 因为「一个 key 都没配」正是最需要它的时刻:用户在这里选一个已有条目即可,
314
+ // 不必去手改 profile patch。
315
+
316
+ function CredentialPicker({ state, onPick }) {
317
+ const safe = state || {};
318
+ const raw = Array.isArray(safe.entries) ? safe.entries : [];
319
+ // entries 是 [{ ref, provider }];同时容忍纯字符串(宿主端读不到供应商时)
320
+ const entries = raw
321
+ .map((e) => (typeof e === "string" ? { ref: e, provider: null } : e))
322
+ .filter((e) => e && typeof e.ref === "string" && e.ref.length > 0);
323
+ const current = safe.apiKeyEnv ?? null;
324
+ // 当前值可能不在候选里(配置指向了尚未添加的条目),补一项,避免下拉显示空白
325
+ const options = current && !entries.some((e) => e.ref === current)
326
+ ? [{ ref: current, provider: null }, ...entries]
327
+ : entries;
328
+ // 能对上模型供应商时显示「供应商(条目名)」,否则只显示条目名
329
+ const labelOf = (e) => (e.provider ? `${e.provider}(${e.ref})` : e.ref);
330
+
331
+ return React.createElement("div", {
332
+ style: {
333
+ display: "flex",
334
+ alignItems: "center",
335
+ gap: 8,
336
+ padding: "9px 16px",
337
+ borderBottom: `1px solid ${T.borderL}`,
338
+ fontSize: 11.5,
339
+ color: T.label3,
340
+ },
341
+ },
342
+ React.createElement("span", { style: { whiteSpace: "nowrap" } }, "凭据条目"),
343
+ React.createElement("select", {
344
+ value: current ?? "",
345
+ disabled: Boolean(safe.saving),
346
+ title: "选择用哪条凭据查询额度",
347
+ onChange: (event) => onPick(event.target.value),
348
+ style: {
349
+ flex: 1,
350
+ minWidth: 0,
351
+ padding: "4px 6px",
352
+ fontSize: 11.5,
353
+ fontFamily: "inherit",
354
+ color: T.label2,
355
+ background: T.bg2,
356
+ border: `1px solid ${T.border}`,
357
+ borderRadius: 8,
358
+ cursor: safe.saving ? "default" : "pointer",
359
+ },
360
+ },
361
+ React.createElement("option", { value: "" }, "(未设置)"),
362
+ options.map((e) => React.createElement("option", { key: e.ref, value: e.ref }, labelOf(e)))),
363
+ safe.saving ? React.createElement("span", null, "…") : null,
364
+ safe.error ? React.createElement("span", { style: { color: T.error } }, safe.error) : null);
365
+ }
366
+
309
367
  // ---- 面板内容 ----------------------------------------------------------------
310
368
 
311
369
  function DashboardBody({ data, loading, error }) {
@@ -496,6 +554,8 @@ window.__ModuleLoader__.load({
496
554
  const [loading, setLoading] = useState(false);
497
555
  const [error, setError] = useState(null);
498
556
  const timerRef = useRef(null);
557
+ // 凭据条目:与 data / error 完全解耦 —— 这样面板报「没配 key」时依然能切换
558
+ const [creds, setCreds] = useState({ entries: [], apiKeyEnv: null, saving: false, error: null });
499
559
 
500
560
  const refresh = useCallback(async () => {
501
561
  const fullUrl = FETCH_URL;
@@ -526,12 +586,46 @@ window.__ModuleLoader__.load({
526
586
  }
527
587
  }, []);
528
588
 
589
+ const loadCredentials = useCallback(async () => {
590
+ try {
591
+ const res = await fetch(CREDENTIALS_URL, { cache: "no-store" });
592
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
593
+ const body = await res.json();
594
+ setCreds((prev) => ({
595
+ ...prev,
596
+ entries: Array.isArray(body.entries) ? body.entries : [],
597
+ apiKeyEnv: body.apiKeyEnv ?? null,
598
+ error: null,
599
+ }));
600
+ } catch (reason) {
601
+ setCreds((prev) => ({ ...prev, error: String(reason) }));
602
+ }
603
+ }, []);
604
+
605
+ const pickCredential = useCallback(async (name) => {
606
+ setCreds((prev) => ({ ...prev, saving: true, error: null }));
607
+ try {
608
+ const res = await fetch(CREDENTIALS_URL, {
609
+ method: "POST",
610
+ headers: { "content-type": "application/json" },
611
+ body: JSON.stringify({ apiKeyEnv: name || null }),
612
+ });
613
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
614
+ const body = await res.json();
615
+ setCreds((prev) => ({ ...prev, apiKeyEnv: body.apiKeyEnv ?? null, saving: false }));
616
+ await refresh(); // 换完立刻按新条目重新查一次
617
+ } catch (reason) {
618
+ setCreds((prev) => ({ ...prev, saving: false, error: String(reason) }));
619
+ }
620
+ }, [refresh]);
621
+
529
622
  useEffect(() => {
623
+ loadCredentials();
530
624
  refresh();
531
625
  // 每 3 分钟自动查询一次余额(面板打开时也刷新)
532
626
  timerRef.current = setInterval(() => refresh(), REFRESH_MS);
533
627
  return () => { if (timerRef.current) clearInterval(timerRef.current); };
534
- }, [refresh]);
628
+ }, [loadCredentials, refresh]);
535
629
 
536
630
  function pctOf(win) {
537
631
  if (!win || typeof win.used !== "number" || typeof win.cap !== "number" || win.cap <= 0) return null;
@@ -642,6 +736,7 @@ window.__ModuleLoader__.load({
642
736
  onMouseEnter: (e) => Object.assign(e.currentTarget.style, iconBtnHover),
643
737
  onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = T.label3; },
644
738
  }, React.createElement(IconClose)))),
739
+ React.createElement(CredentialPicker, { state: creds, onPick: pickCredential }),
645
740
  React.createElement(DashboardBody, { data, loading, error })) : null);
646
741
  }
647
742
 
package/src/index.js CHANGED
@@ -4,8 +4,10 @@
4
4
  * 通过 Command Code 官方 API 读取余额与限额数据, 在 DSH Web 界面提供
5
5
  * 一个与 OpenCode 余额查询类似的单账号仪表盘。
6
6
  *
7
- * GET /__dsh-commandcode-quota/dashboard → 余额 + 限额 + 用量
8
- * GET /__dsh-commandcode-quota/dashboard?scope=quick → 余额优先,其余字段用缓存回填
7
+ * GET /__dsh-commandcode-quota/dashboard → 余额 + 限额 + 用量
8
+ * GET /__dsh-commandcode-quota/dashboard?scope=quick → 余额优先,其余字段用缓存回填
9
+ * GET /__dsh-commandcode-quota/credentials → 候选凭据条目名 + 当前选择
10
+ * POST /__dsh-commandcode-quota/credentials → 保存选择的凭据条目名
9
11
  *
10
12
  * 数据源 (均为 Authorization: Bearer <key> 的 GET 请求):
11
13
  * GET {base}/alpha/billing/credits 余额 & 窗口限额
@@ -23,6 +25,9 @@
23
25
  * ESM module format (cordis bundle rule): named exports apply/inject/name.
24
26
  */
25
27
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
28
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { dirname, join } from "node:path";
26
31
 
27
32
  const name = "dsh-commandcode-quota";
28
33
  const inject = ["credentials"];
@@ -67,7 +72,7 @@ async function ccFetch(baseURL, path, apiKey, timeoutMs) {
67
72
  headers: {
68
73
  authorization: `Bearer ${apiKey}`,
69
74
  accept: "application/json",
70
- "user-agent": "dsh-commandcode-quota/0.2.0"
75
+ "user-agent": "dsh-commandcode-quota/0.2.2"
71
76
  },
72
77
  signal: controller.signal
73
78
  });
@@ -249,14 +254,159 @@ async function resolveAccountOptions(ctx, account) {
249
254
  };
250
255
  }
251
256
 
257
+ // --- 凭据条目选择(面板里可切换)----------------------------------------------
258
+ //
259
+ // 两个约束决定了这里的写法:
260
+ // 1. ctx.credentials 只有 resolve/describe/set/unset,没有「列出条目」的能力,
261
+ // 所以候选列表只能自己从 .credentials.yaml 里取 —— 且【只取键名,绝不读值】。
262
+ // 2. 选择要持久化才能跨重启生效;写进 $DSH_HOME 下的一个小 JSON,优先级高于
263
+ // profile patch 里的 apiKeyEnv(面板里的选择是最新意图)。
264
+
265
+ /** 合法凭据条目名:POSIX shell 标识符,与 dsh-credentials 的 REF_PATTERN 一致。 */
266
+ const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
267
+
268
+ function dshHome() {
269
+ const fromEnv = process.env.DSH_HOME;
270
+ return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : join(homedir(), ".dsh");
271
+ }
272
+
273
+ function choiceFile() {
274
+ return join(dshHome(), "dsh-commandcode-quota.json");
275
+ }
276
+
277
+ /** 面板里保存过的选择;没有或损坏时返回全 null(回落到 patch 配置)。 */
278
+ function readChoice() {
279
+ try {
280
+ const parsed = JSON.parse(readFileSync(choiceFile(), "utf8"));
281
+ const pick = (v) => (typeof v === "string" && REF_PATTERN.test(v) ? v : null);
282
+ return { apiKeyEnv: pick(parsed?.apiKeyEnv), fallbackEnv: pick(parsed?.fallbackEnv) };
283
+ } catch {
284
+ return { apiKeyEnv: null, fallbackEnv: null };
285
+ }
286
+ }
287
+
288
+ function writeChoice(choice) {
289
+ const file = choiceFile();
290
+ mkdirSync(dirname(file), { recursive: true });
291
+ writeFileSync(file, JSON.stringify(choice, null, 2) + "\n", "utf8");
292
+ }
293
+
294
+ /**
295
+ * 列出 .credentials.yaml 里 refs: 段下的条目名。
296
+ * 只解析键、跳过一切值 —— 返回的是名字,不是密钥。
297
+ */
298
+ function readCredentialEntries() {
299
+ let text;
300
+ try {
301
+ text = readFileSync(join(dshHome(), ".credentials.yaml"), "utf8");
302
+ } catch {
303
+ return [];
304
+ }
305
+ const names = [];
306
+ let inRefs = false;
307
+ let indent = -1;
308
+ for (const line of text.split(/\r?\n/)) {
309
+ if (/^refs:\s*$/.test(line)) { inRefs = true; continue; }
310
+ if (!inRefs) continue;
311
+ if (line.trim().length === 0) continue;
312
+ const m = line.match(/^(\s+)([A-Za-z_][A-Za-z0-9_]*):/);
313
+ if (!m) {
314
+ if (/^\S/.test(line)) break; // 回到顶层键,refs 段结束
315
+ continue;
316
+ }
317
+ if (indent < 0) indent = m[1].length;
318
+ if (m[1].length === indent) names.push(m[2]);
319
+ }
320
+ return names;
321
+ }
322
+
323
+ /** 读取请求体(上限 8KB,够放两个条目名)。 */
324
+ function readBody(req, limit = 8192) {
325
+ return new Promise((resolve, reject) => {
326
+ let body = "";
327
+ req.on("data", (chunk) => {
328
+ body += chunk;
329
+ if (body.length > limit) {
330
+ reject(new Error("请求体过大"));
331
+ req.destroy?.();
332
+ }
333
+ });
334
+ req.on("end", () => resolve(body));
335
+ req.on("error", reject);
336
+ });
337
+ }
338
+
252
339
  // --- plugin ------------------------------------------------------------------
253
340
 
341
+ /**
342
+ * 挑出要展示的凭据条目。
343
+ *
344
+ * 口径:对不上模型供应商的条目说明没在用它,不必占位置。
345
+ * 但如果一个都对不上(凭据库还没和任何 provider 关联),就退回全部 ——
346
+ * 否则下拉是空的,反而没法选。
347
+ */
348
+ function pickEntries(refs, labels) {
349
+ const all = refs.map((ref) => ({ ref, provider: labels[ref] ?? null }));
350
+ const matched = all.filter((entry) => entry.provider);
351
+ return matched.length > 0 ? matched : all;
352
+ }
353
+
354
+ /**
355
+ * 凭据条目名 → 模型供应商显示名。
356
+ *
357
+ * 这层对应本来就存在于模型配置里(provider.apiKeyEnv 指向条目名),所以读
358
+ * llm-pi-ai 的 providers 就能把 "COMMANDCODE2_API_KEY" 翻译成用户在模型设置页
359
+ * 看到的 "commandcode2"。读不到就返回空表,调用方回落到只显示条目名。
360
+ *
361
+ * 用 ctx.get("settings") 而不是 inject —— 读不到时降级即可,
362
+ * 不必因为缺少这个服务就让整个插件不激活。
363
+ */
364
+ function readProviderLabels(ctx) {
365
+ // 只能用 ctx.get:cordis 里访问未注入的服务属性(ctx.settings)会直接抛异常,
366
+ // 而这里的原则是「读不到就降级」,不该让整个端点跟着失败。
367
+ if (typeof ctx.get !== "function") return {};
368
+ let service;
369
+ try {
370
+ service = ctx.get("settings");
371
+ } catch {
372
+ return {};
373
+ }
374
+ if (typeof service?.describe !== "function") return {};
375
+ let rows;
376
+ try {
377
+ rows = service.describe();
378
+ } catch {
379
+ return {};
380
+ }
381
+ const row = (Array.isArray(rows) ? rows : []).find((r) => r?.ns === "llm-pi-ai");
382
+ const providers = row?.user?.providers;
383
+ if (!providers || typeof providers !== "object") return {};
384
+ const labels = {};
385
+ for (const [id, cfg] of Object.entries(providers)) {
386
+ const ref = cfg?.apiKeyEnv;
387
+ if (typeof ref !== "string" || ref.length === 0) continue;
388
+ const label = typeof cfg?.displayName === "string" && cfg.displayName.length > 0 ? cfg.displayName : id;
389
+ if (!(ref in labels)) labels[ref] = label;
390
+ }
391
+ return labels;
392
+ }
393
+
254
394
  function apply(ctx, config) {
255
- const account = normalizeAccount(config);
395
+ const base = normalizeAccount(config);
256
396
  const timeoutMs = Number.isFinite(config?.timeoutMs) && config.timeoutMs > 0
257
397
  ? config.timeoutMs
258
398
  : DEFAULT_TIMEOUT_MS;
259
399
 
400
+ // 每次请求重新算:面板里刚改过选择就立刻生效,不必重载插件。
401
+ function currentAccount() {
402
+ const saved = readChoice();
403
+ return {
404
+ ...base,
405
+ apiKeyEnv: saved.apiKeyEnv ?? base.apiKeyEnv,
406
+ fallbackEnv: saved.fallbackEnv ?? base.fallbackEnv
407
+ };
408
+ }
409
+
260
410
  function registerHttp(host, targetCtx) {
261
411
  targetCtx.effect(() => host.register({
262
412
  kind: "exact",
@@ -271,13 +421,13 @@ function apply(ctx, config) {
271
421
  // scope=quick: 只查响应快的 credits, 用量/订阅用缓存回填 (客户端先渲染再补全)
272
422
  const scope = readQuery(req, "scope");
273
423
 
274
- const options = await resolveAccountOptions(ctx, account);
424
+ const options = await resolveAccountOptions(ctx, currentAccount());
275
425
  const meta = { ok: true };
276
426
  if (options.apiKey.length === 0) {
277
427
  sendJson(res, 200, {
278
428
  ...meta,
279
429
  ok: false,
280
- error: `未配置 API key (尝试过: ${options.triedEnvs.join(", ") || "无"}); 请在 ~/.dsh/.credentials.yaml 中配置`,
430
+ error: `未配置 API key (尝试过: ${options.triedEnvs.join(", ") || "无"})。用面板上方的「凭据条目」选一个已有条目,或在 DSH 设置 → 凭据里添加`,
281
431
  keyConfigured: false
282
432
  });
283
433
  return;
@@ -293,6 +443,49 @@ function apply(ctx, config) {
293
443
  }
294
444
  }
295
445
  }));
446
+
447
+ // 凭据条目:GET 列出候选 + 当前选择;POST 保存选择。
448
+ // 无论如何都可用 —— 面板没配 key 时正是最需要它的时候。
449
+ targetCtx.effect(() => host.register({
450
+ kind: "exact",
451
+ path: "/__dsh-commandcode-quota/credentials",
452
+ handler: async (req, res) => {
453
+ if (req.method === "GET") {
454
+ const account = currentAccount();
455
+ const labels = readProviderLabels(ctx);
456
+ sendJson(res, 200, {
457
+ ok: true,
458
+ // 只列能对上模型供应商的条目(口径见 pickEntries)
459
+ entries: pickEntries(readCredentialEntries(), labels),
460
+ apiKeyEnv: account.apiKeyEnv,
461
+ fallbackEnv: account.fallbackEnv,
462
+ chosen: readChoice()
463
+ });
464
+ return;
465
+ }
466
+ if (req.method === "POST") {
467
+ try {
468
+ const parsed = JSON.parse((await readBody(req)) || "{}");
469
+ const next = { apiKeyEnv: null, fallbackEnv: null };
470
+ for (const key of ["apiKeyEnv", "fallbackEnv"]) {
471
+ const value = parsed?.[key];
472
+ if (value === null || value === undefined || value === "") continue;
473
+ if (typeof value !== "string" || !REF_PATTERN.test(value)) {
474
+ sendJson(res, 400, { ok: false, error: `凭据条目名不合法: ${JSON.stringify(value)}` });
475
+ return;
476
+ }
477
+ next[key] = value;
478
+ }
479
+ writeChoice(next);
480
+ sendJson(res, 200, { ok: true, ...next });
481
+ } catch (error) {
482
+ sendJson(res, 400, { ok: false, error: `保存失败: ${String(error)}` });
483
+ }
484
+ return;
485
+ }
486
+ sendJson(res, 405, { error: "method not allowed" });
487
+ }
488
+ }));
296
489
  }
297
490
 
298
491
  const ws = ctx.get("webServer");
@@ -13,9 +13,9 @@ const fixture = {
13
13
  // 月度窗口的重置时间取自订阅周期末,是 ISO 字符串而非毫秒数
14
14
  subscription: { status: "active", currentPeriodEnd: new Date(Date.now() + 20 * 86400000).toISOString() },
15
15
  };
16
- function mount({ open = true, data = fixture, failed = false } = {}) {
16
+ function mount({ open = true, data = fixture, failed = false, creds = null } = {}) {
17
17
  let registration, component, slot, stateIndex = 0;
18
- const values = [open, data, false, null];
18
+ const values = [open, data, false, null, creds || { entries: [], apiKeyEnv: null, saving: false, error: null }];
19
19
  const updates = [], callbacks = [], effects = [], requests = [], timers = [];
20
20
  const React = {
21
21
  Fragment: "fragment",
@@ -62,7 +62,7 @@ async function main() {
62
62
  const view = mount();
63
63
  const buttons = view.nodes.filter(node => node.type === "button");
64
64
  assert.equal(buttons.length, 3, "只保留状态栏、刷新和关闭按钮,不应存在账号切换按钮");
65
- assert.equal(view.stateIndex, 4, "不再维护选中账号状态");
65
+ assert.equal(view.stateIndex, 5, "状态:面板开合 / 数据 / 加载 / 错误 / 凭据条目");
66
66
  assert.equal(view.callbacks[0].deps.length, 0);
67
67
  for (const label of ["5h 10%", "周 20%", "月 30%", "$70.00", "5 小时窗口", "每周窗口", "每月窗口"]) {
68
68
  assert.ok(view.texts.includes(label), `保留额度展示:${label}`);
@@ -76,9 +76,67 @@ async function main() {
76
76
  `月度应显示天级倒计时与到期日,实际:${resetTexts.join(" | ")}`,
77
77
  );
78
78
  await buttons.find(node => node.props.title === "刷新").props.onClick();
79
- assert.deepEqual(view.requests.map(req => req.url), ["/__dsh-commandcode-quota/dashboard?scope=quick", "/__dsh-commandcode-quota/dashboard"]);
79
+ assert.deepEqual(view.requests.filter((r) => r.url.includes("/dashboard")).map((r) => r.url), [
80
+ "/__dsh-commandcode-quota/dashboard?scope=quick",
81
+ "/__dsh-commandcode-quota/dashboard",
82
+ ]);
80
83
  assert.ok(view.requests.every(req => req.options.cache === "no-store"));
81
84
  assert.ok(!view.updates.some(update => update.index > 3));
85
+
86
+ // 这个 mock 的 useEffect 只记录不执行,所以在这里手动触发挂载逻辑:
87
+ // 挂载时必须顺带拉一次凭据条目,下拉才会有候选
88
+ view.effects[0]();
89
+ assert.ok(
90
+ view.requests.some((r) => r.url === "/__dsh-commandcode-quota/credentials"),
91
+ "挂载时应拉取凭据条目列表",
92
+ );
93
+
94
+ // 凭据条目下拉存在的意义就是「没配 key 时也能选」,所以每种面板状态都必须能看到它
95
+ const pickerState = {
96
+ entries: [
97
+ { ref: "BAILIAN_API_KEY", provider: "bailian" },
98
+ { ref: "COMMANDCODE2_API_KEY", provider: "commandcode2" },
99
+ { ref: "CC_ACCOUNT2_API_KEY", provider: null },
100
+ ],
101
+ apiKeyEnv: "COMMANDCODE2_API_KEY",
102
+ saving: false,
103
+ error: null,
104
+ };
105
+ for (const [label, options] of [
106
+ ["正常态", {}],
107
+ ["报错态", { failed: true }],
108
+ ["无数据态", { data: null }],
109
+ ]) {
110
+ const view2 = mount({ ...options, creds: pickerState });
111
+ const selects = view2.nodes.filter((n) => n.type === "select");
112
+ assert.equal(selects.length, 1, `${label}下应能看到凭据条目下拉`);
113
+ const opts = view2.nodes.filter((n) => n.type === "option");
114
+ assert.deepEqual(
115
+ opts.map((o) => o.props.value),
116
+ ["", "BAILIAN_API_KEY", "COMMANDCODE2_API_KEY", "CC_ACCOUNT2_API_KEY"],
117
+ `${label}下的候选项不正确`,
118
+ );
119
+ // 能对上模型供应商的显示「供应商(条目名)」,对不上的只显示条目名
120
+ assert.deepEqual(opts.map((o) => o.children[0]), [
121
+ "(未设置)",
122
+ "bailian(BAILIAN_API_KEY)",
123
+ "commandcode2(COMMANDCODE2_API_KEY)",
124
+ "CC_ACCOUNT2_API_KEY",
125
+ ], `${label}下的显示文案不正确`);
126
+ assert.equal(selects[0].props.value, "COMMANDCODE2_API_KEY", `${label}下应选中当前条目`);
127
+ }
128
+ // 宿主端读不到供应商时会退化成纯字符串数组,也必须照常工作
129
+ const plain = mount({ creds: { entries: ["BAILIAN_API_KEY"], apiKeyEnv: null, saving: false, error: null } });
130
+ assert.deepEqual(
131
+ plain.nodes.filter((n) => n.type === "option").map((o) => o.children[0]),
132
+ ["(未设置)", "BAILIAN_API_KEY"],
133
+ );
134
+ // 配置指向一个尚未添加的条目时也要出现在候选里,否则下拉会显示成空白
135
+ const orphan = mount({ creds: { entries: [{ ref: "OTHER_KEY", provider: null }], apiKeyEnv: "MISSING_KEY", saving: false, error: null } });
136
+ assert.deepEqual(
137
+ orphan.nodes.filter((n) => n.type === "option").map((o) => o.props.value),
138
+ ["", "MISSING_KEY", "OTHER_KEY"],
139
+ );
82
140
  const timerView = mount({ open: false, data: null });
83
141
  assert.equal(timerView.nodes.filter(node => node.type === "button").length, 1);
84
142
  const cleanup = timerView.effects[0]();
@@ -3,9 +3,23 @@ const fs = require("node:fs");
3
3
  const vm = require("node:vm");
4
4
  const assert = require("node:assert/strict");
5
5
  const { createHash } = require("node:crypto");
6
- const { join } = require("node:path");
6
+ const { join, dirname } = require("node:path");
7
+ const { tmpdir } = require("node:os");
7
8
  const plugin = join(__dirname, "..");
8
9
  const source = fs.readFileSync(join(plugin, "src/index.js"), "utf8");
10
+
11
+ // 完全隔离的 DSH_HOME:保存凭据选择会写配置,绝不能落到真实的 ~/.dsh。
12
+ const fakeHome = fs.mkdtempSync(join(tmpdir(), "dsh-quota-test-"));
13
+ fs.writeFileSync(join(fakeHome, ".credentials.yaml"), [
14
+ "version: 1",
15
+ "refs:",
16
+ " PRIMARY_TEST_KEY: fixture-primary-value",
17
+ " FALLBACK_TEST_KEY: fixture-fallback-value",
18
+ "records: {}",
19
+ "",
20
+ ].join("\n"));
21
+ process.on("exit", () => { try { fs.rmSync(fakeHome, { recursive: true, force: true }); } catch { /* 清理失败不影响测试结论 */ } });
22
+
9
23
  const responses = {
10
24
  "/alpha/billing/credits": { credits: { monthlyCredits: 70 }, windowLimits: { fiveHour: { used: 10, cap: 100 } } },
11
25
  "/alpha/usage/summary": { totalMonthlyCredits: 30, totalTokens: 100, totalCost: 5, successRate: 98 },
@@ -17,6 +31,13 @@ function load() {
17
31
  let reply = async (url) => ({ ok: true, status: 200, json: async () => responses[new URL(url).pathname] });
18
32
  const context = vm.createContext({
19
33
  Buffer, URL, AbortController, setTimeout, clearTimeout, createHash,
34
+ // 源码的 import 会被剥掉,所以这里用到的 node 内置必须显式注入
35
+ readFileSync: fs.readFileSync,
36
+ writeFileSync: fs.writeFileSync,
37
+ mkdirSync: fs.mkdirSync,
38
+ join, dirname,
39
+ homedir: () => fakeHome,
40
+ process: { env: { DSH_HOME: fakeHome } },
20
41
  credentialRef: (ref) => ref,
21
42
  fetch: async (url, options) => { requests.push({ url, options }); return reply(url, options); },
22
43
  });
@@ -24,13 +45,27 @@ function load() {
24
45
  "\nglobalThis.host = { apply, fetchDashboard, inject, name };", context);
25
46
  return { host: context.host, requests, respond(fn) { reply = fn; } };
26
47
  }
27
- function mount(runtime, config = {}, keys = {}, lazy = false) {
28
- let route;
48
+ const DASHBOARD = "/__dsh-commandcode-quota/dashboard";
49
+ const CREDS = "/__dsh-commandcode-quota/credentials";
50
+
51
+ function mount(runtime, config = {}, keys = {}, lazy = false, settings = undefined) {
52
+ const routes = new Map();
29
53
  const refs = [];
30
- const web = { register(row) { route = row; return () => {}; } };
54
+ const web = { register(row) { routes.set(row.path, row); return () => {}; } };
31
55
  const credentials = { async resolve(ref) { refs.push(ref); return { value: keys[ref] ?? "" }; } };
32
56
  const ctx = {
33
- get(name) { return name === "credentials" ? credentials : name === "webServer" && !lazy ? web : undefined; },
57
+ get(name) {
58
+ if (name === "credentials") return credentials;
59
+ if (name === "settings") return settings;
60
+ if (name === "webServer") return lazy ? undefined : web;
61
+ return undefined;
62
+ },
63
+ // 真实 cordis 里访问未注入的服务属性会【抛异常】而不是返回 undefined。
64
+ // 这条 getter 守住它:代码必须走 ctx.get 并降级,
65
+ // 否则凭据端点会整个 400(曾经真的这么挂过一次)。
66
+ get settings() {
67
+ throw new Error("service settings is not injected");
68
+ },
34
69
  effect(fn) { return fn(); },
35
70
  inject(names, fn) { assert.deepEqual(Array.from(names), ["webServer"]); fn({ webServer: web, effect: ctx.effect }); },
36
71
  logger: { info() {} },
@@ -38,19 +73,41 @@ function mount(runtime, config = {}, keys = {}, lazy = false) {
38
73
  runtime.host.apply(ctx, config);
39
74
  assert.equal(runtime.host.name, "dsh-commandcode-quota");
40
75
  assert.deepEqual(Array.from(runtime.host.inject), ["credentials"]);
41
- assert.equal(route.kind, "exact");
42
- assert.equal(route.path, "/__dsh-commandcode-quota/dashboard");
76
+
77
+ const dashboard = routes.get(DASHBOARD);
78
+ assert.ok(dashboard, "必须注册 dashboard 端点");
79
+ assert.equal(dashboard.kind, "exact");
80
+ const creds = routes.get(CREDS);
81
+ assert.ok(creds, "必须注册 credentials 端点");
82
+ assert.equal(creds.kind, "exact");
83
+
84
+ async function invoke(handler, method, url, payload) {
85
+ let status, headers, raw;
86
+ const listeners = {};
87
+ const req = {
88
+ method,
89
+ url,
90
+ on(event, fn) { (listeners[event] = listeners[event] || []).push(fn); return req; },
91
+ destroy() {},
92
+ };
93
+ const pending = handler(req, {
94
+ writeHead(code, values) { status = code; headers = values; },
95
+ end(body) { raw = body; },
96
+ });
97
+ if (payload !== undefined) {
98
+ for (const fn of listeners.data || []) fn(JSON.stringify(payload));
99
+ for (const fn of listeners.end || []) fn();
100
+ }
101
+ await pending;
102
+ assert.equal(headers["cache-control"], "no-store");
103
+ assert.equal(headers["content-length"], Buffer.byteLength(raw));
104
+ return { status, body: JSON.parse(raw), raw };
105
+ }
106
+
43
107
  return {
44
108
  refs,
45
- async request(url = route.path, method = "GET") {
46
- let status, headers, raw;
47
- await route.handler({ method, url }, {
48
- writeHead(code, values) { status = code; headers = values; },
49
- end(body) { raw = body; },
50
- });
51
- assert.equal(headers["cache-control"], "no-store");
52
- assert.equal(headers["content-length"], Buffer.byteLength(raw));
53
- return { status, body: JSON.parse(raw), raw };
109
+ request(url = DASHBOARD, method = "GET", payload) {
110
+ return invoke(url.includes("/credentials") ? creds.handler : dashboard.handler, method, url, payload);
54
111
  },
55
112
  };
56
113
  }
@@ -73,7 +130,7 @@ async function main() {
73
130
  assert.ok(runtime.requests.every(req => req.options.headers.authorization === "Bearer fixture-primary"));
74
131
  assert.deepEqual(app.refs, ["PRIMARY_TEST_KEY"]);
75
132
  runtime.requests.length = 0;
76
- const quick = await app.request("/__dsh-commandcode-quota/dashboard?scope=quick");
133
+ const quick = await app.request(DASHBOARD + "?scope=quick");
77
134
  assert.equal(runtime.requests.length, 1);
78
135
  assert.equal(new URL(runtime.requests[0].url).pathname, "/alpha/billing/credits");
79
136
  assert.equal(quick.body.usage.totalMonthlyCredits, 30);
@@ -92,7 +149,7 @@ async function main() {
92
149
  assert.equal(runtime.requests.length, 0);
93
150
  const fallbackRuntime = load();
94
151
  const fallback = mount(fallbackRuntime, config, { FALLBACK_TEST_KEY: "fixture-fallback" }, true);
95
- assert.equal((await fallback.request("/__dsh-commandcode-quota/dashboard?scope=quick")).body.keyConfigured, true);
152
+ assert.equal((await fallback.request(DASHBOARD + "?scope=quick")).body.keyConfigured, true);
96
153
  assert.deepEqual(fallback.refs, ["PRIMARY_TEST_KEY", "FALLBACK_TEST_KEY"]);
97
154
  assert.equal(fallbackRuntime.requests[0].options.headers.authorization, "Bearer fixture-fallback");
98
155
  const emptyRuntime = load();
@@ -100,6 +157,12 @@ async function main() {
100
157
  assert.equal(missing.body.keyConfigured, false);
101
158
  assert.equal(missing.body.ok, false);
102
159
  assert.equal(emptyRuntime.requests.length, 0);
160
+ assert.ok(missing.body.error.includes("凭据条目"), "缺 key 的提示应指向面板里的选择器");
161
+ // 缺 key 时,凭据端点必须照常可用 —— 这正是用户最需要它的时候
162
+ const missingApp = mount(load(), config);
163
+ const listedWhenEmpty = await missingApp.request(CREDS);
164
+ assert.equal(listedWhenEmpty.status, 200, "没配 key 时凭据端点也必须可用");
165
+ assert.equal(listedWhenEmpty.body.apiKeyEnv, "PRIMARY_TEST_KEY");
103
166
  const defaultRuntime = load();
104
167
  assert.equal((await mount(defaultRuntime, {}, { COMMANDCODE_API_KEY: "fixture-default" }).request()).body.ok, true);
105
168
  let attempts = 0;
@@ -108,14 +171,75 @@ async function main() {
108
171
  if (++attempts === 1) throw new TypeError("模拟连接失败");
109
172
  return { status: 200, json: async () => responses[new URL(url).pathname] };
110
173
  });
111
- assert.equal((await mount(retryRuntime, config, { PRIMARY_TEST_KEY: "fixture-primary" }).request("/__dsh-commandcode-quota/dashboard?scope=quick")).body.ok, true);
174
+ assert.equal((await mount(retryRuntime, config, { PRIMARY_TEST_KEY: "fixture-primary" }).request(DASHBOARD + "?scope=quick")).body.ok, true);
112
175
  assert.equal(attempts, 2);
113
176
  const timeoutRuntime = load();
114
177
  timeoutRuntime.respond((url, options) => new Promise((resolve, reject) => {
115
178
  options.signal.addEventListener("abort", () => reject(new Error("模拟超时")), { once: true });
116
179
  }));
117
- assert.equal((await mount(timeoutRuntime, { ...config, timeoutMs: 5 }, { PRIMARY_TEST_KEY: "fixture-primary" }).request("/__dsh-commandcode-quota/dashboard?scope=quick")).body.ok, false);
180
+ assert.equal((await mount(timeoutRuntime, { ...config, timeoutMs: 5 }, { PRIMARY_TEST_KEY: "fixture-primary" }).request(DASHBOARD + "?scope=quick")).body.ok, false);
118
181
  assert.equal(timeoutRuntime.requests.length, 1, "主动超时不重试");
182
+
183
+ // 凭据条目端点:GET 只回条目名 + 当前选择,POST 保存选择。放在最后跑,因为保存会改变后续读取。
184
+ const credsApp = mount(load(), config, { PRIMARY_TEST_KEY: "fixture-primary" });
185
+ const listed = await credsApp.request(CREDS);
186
+ assert.equal(listed.status, 200);
187
+ assert.deepEqual(listed.body.entries, [
188
+ { ref: "PRIMARY_TEST_KEY", provider: null },
189
+ { ref: "FALLBACK_TEST_KEY", provider: null },
190
+ ], "一个都对不上供应商时回退显示全部,免得多拉是空的");
191
+ assert.equal(listed.body.apiKeyEnv, "PRIMARY_TEST_KEY");
192
+ for (const secret of ["fixture-primary-value", "fixture-fallback-value"]) {
193
+ assert.ok(!listed.raw.includes(secret), `凭据端点绝不能回值:${secret}`);
194
+ }
195
+ const saved = await credsApp.request(CREDS, "POST", { apiKeyEnv: "FALLBACK_TEST_KEY" });
196
+ assert.equal(saved.status, 200);
197
+ assert.equal(saved.body.apiKeyEnv, "FALLBACK_TEST_KEY");
198
+ assert.equal(
199
+ (await credsApp.request(CREDS, "POST", { apiKeyEnv: "not a valid ref" })).status,
200
+ 400,
201
+ "非法条目名必须被拒",
202
+ );
203
+ assert.equal((await credsApp.request(CREDS, "DELETE")).status, 405);
204
+ // 保存过的选择要覆盖 profile patch 里的 apiKeyEnv
205
+ const chosenRuntime = load();
206
+ const chosen = mount(chosenRuntime, config, { FALLBACK_TEST_KEY: "fixture-fallback" });
207
+ assert.equal((await chosen.request(DASHBOARD + "?scope=quick")).body.keyConfigured, true);
208
+ assert.deepEqual(chosen.refs, ["FALLBACK_TEST_KEY"], "保存的选择应优先于 patch 配置");
209
+
210
+ // 供应商映射:provider.apiKeyEnv 指向条目名时,下拉显示供应商名而不是裸条目名
211
+ const settingsStub = {
212
+ describe: () => ([{
213
+ ns: "llm-pi-ai",
214
+ user: {
215
+ providers: {
216
+ commandcode2: { apiKeyEnv: "PRIMARY_TEST_KEY" },
217
+ cerebras: { displayName: "bailian", apiKeyEnv: "FALLBACK_TEST_KEY" },
218
+ noKey: { baseURL: "https://example.invalid" },
219
+ },
220
+ },
221
+ }]),
222
+ };
223
+ const labeled = await mount(load(), config, { PRIMARY_TEST_KEY: "x" }, false, settingsStub).request(CREDS);
224
+ assert.deepEqual(labeled.body.entries, [
225
+ { ref: "PRIMARY_TEST_KEY", provider: "commandcode2" },
226
+ { ref: "FALLBACK_TEST_KEY", provider: "bailian" },
227
+ ], "应把条目名翻译成模型设置里显示的供应商名");
228
+ // describe() 抛错时必须降级,而不是让整个端点失败
229
+ const broken = await mount(load(), config, {}, false, { describe() { throw new Error("boom"); } }).request(CREDS);
230
+ assert.equal(broken.status, 200, "settings 读不到时应降级而不是报错");
231
+ assert.deepEqual(broken.body.entries.map((e) => e.provider), [null, null]);
232
+ // 只有部分条目对得上时,只列出对得上的 —— 对不上的说明用户没在用它
233
+ const partial = await mount(load(), config, {}, false, {
234
+ describe: () => ([{
235
+ ns: "llm-pi-ai",
236
+ user: { providers: { cerebras: { displayName: "bailian", apiKeyEnv: "FALLBACK_TEST_KEY" } } },
237
+ }]),
238
+ }).request(CREDS);
239
+ assert.deepEqual(partial.body.entries, [
240
+ { ref: "FALLBACK_TEST_KEY", provider: "bailian" },
241
+ ], "只应列出对得上模型供应商的条目");
242
+
119
243
  // 包自检:声明、注册 id 与 bundle 补丁层三者必须一致。
120
244
  const manifest = JSON.parse(fs.readFileSync(join(plugin, "package.json"), "utf8"));
121
245
  assert.equal(manifest.name, "dsh-commandcode-quota", "package.json 的 name 必须等于宿主端导出 name");
@@ -128,7 +252,16 @@ async function main() {
128
252
  // 发版时最容易漏的一处:user-agent 里的版本号要与 package.json 同步。
129
253
  const uaVersion = source.match(/"user-agent":\s*"[^"]*\/([^"]+)"/)?.[1];
130
254
  assert.equal(uaVersion, manifest.version, "user-agent 版本必须与 package.json 的 version 一致");
255
+ // 官方包的 peer 范围必须带显式预发布分支。node-semver 只让「相同 major.minor.patch
256
+ // 元组、且自身带预发布标签」的比较符放行预发布版本,所以 '*' 和 '>=0.0.0-0' 都匹配
257
+ // 不到任何 0.x.0-rc.y —— 而那正是 harness 官方包的版本形态。
258
+ const peerRange = manifest.peerDependencies["@deepseek-ai/dsh-credentials"];
259
+ assert.ok(
260
+ typeof peerRange === "string" && /-0/.test(peerRange),
261
+ `官方包 peer 范围必须含显式预发布分支(当前: ${peerRange})`,
262
+ );
131
263
  console.log("通过:单账号响应、主/回退凭据、完整/快速查询、缓存、401、405、重试和超时。");
264
+ console.log("通过:凭据端点只回条目名、翻译供应商名、保存后覆盖 patch 配置、非法名被拒、无 key 时依然可用。");
132
265
  console.log("通过:宿主端导出、路由、包名与 bundle 补丁层一致。");
133
266
  }
134
267
  main().catch(error => { console.error(error.message); process.exitCode = 1; });