dsh-commandcode-quota 0.2.2 → 0.3.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/src/index.js CHANGED
@@ -24,8 +24,8 @@
24
24
  *
25
25
  * ESM module format (cordis bundle rule): named exports apply/inject/name.
26
26
  */
27
- import { credentialRef } from "@deepseek-ai/dsh-credentials";
28
27
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
+ import { createHash } from "node:crypto";
29
29
  import { homedir } from "node:os";
30
30
  import { dirname, join } from "node:path";
31
31
 
@@ -37,6 +37,14 @@ const DEFAULT_API_KEY_ENV = "COMMANDCODE_API_KEY";
37
37
  // Command Code 的 usage / subscriptions / whoami 实测要 8~20s, 8s 超时必然误杀。
38
38
  const DEFAULT_TIMEOUT_MS = 30000;
39
39
 
40
+ // 落盘缓存文件名(与凭据选择文件同目录)。只存额度相关字段:
41
+ // whoami 带邮箱等个人信息, 没有落盘价值, 不写进去。
42
+ const CACHE_FILE_NAME = "dsh-commandcode-quota-cache.json";
43
+ const PERSISTED_ENDPOINTS = ["credits", "usage", "subscriptions"];
44
+
45
+ // 接口名 → 中文, 用于把失败原因写成一句人能看懂的话。
46
+ const ENDPOINT_LABELS = { credits: "余额", usage: "用量", subscriptions: "订阅", whoami: "账户" };
47
+
40
48
  // --- HTTP helpers ------------------------------------------------------------
41
49
 
42
50
  function sendJson(res, status, obj) {
@@ -72,7 +80,7 @@ async function ccFetch(baseURL, path, apiKey, timeoutMs) {
72
80
  headers: {
73
81
  authorization: `Bearer ${apiKey}`,
74
82
  accept: "application/json",
75
- "user-agent": "dsh-commandcode-quota/0.2.2"
83
+ "user-agent": "dsh-commandcode-quota/0.3.0"
76
84
  },
77
85
  signal: controller.signal
78
86
  });
@@ -143,17 +151,77 @@ const HANDLERS = {
143
151
  */
144
152
  const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
145
153
  const lastGood = new Map();
154
+ // 已从磁盘灌入过缓存的 key 指纹, 避免每次请求都读盘
155
+ let hydratedFor = null;
156
+
157
+ /** 缓存键带上 key 指纹: 换 key 后不会把上一个账号的数据当成兜底值。 */
158
+ function cacheKey(fingerprint, endpoint) {
159
+ return `${fingerprint}|${endpoint}`;
160
+ }
146
161
 
147
- function cacheGet(key) {
148
- const hit = lastGood.get(key);
162
+ function cacheGet(fingerprint, endpoint) {
163
+ const hit = lastGood.get(cacheKey(fingerprint, endpoint));
149
164
  if (!hit) return null;
150
165
  if (Date.now() - hit.at > CACHE_TTL_MS) return null;
151
166
  return hit;
152
167
  }
153
168
 
154
- function cacheSet(key, value) {
169
+ function cacheSet(fingerprint, endpoint, value) {
155
170
  if (value === undefined || value === null) return;
156
- lastGood.set(key, { value, at: Date.now() });
171
+ lastGood.set(cacheKey(fingerprint, endpoint), { value, at: Date.now() });
172
+ }
173
+
174
+ /** key 的短指纹: 落盘只存指纹, 绝不写原始 key。 */
175
+ function keyFingerprint(apiKey) {
176
+ return createHash("sha256").update(String(apiKey)).digest("hex").slice(0, 16);
177
+ }
178
+
179
+ function cacheFile() {
180
+ return join(dshHome(), CACHE_FILE_NAME);
181
+ }
182
+
183
+ /**
184
+ * 冷启动兜底: 把上次进程落盘的额度数据读回内存缓存。
185
+ *
186
+ * 没有这一步时, DSH 一重启内存缓存就空 —— 此时 credits 单接口失败就没有旧值可退,
187
+ * 面板会整块空白。只认同一把 key(指纹相同), 过期的条目直接丢弃。
188
+ */
189
+ function hydrateCache(fingerprint) {
190
+ if (!fingerprint || hydratedFor === fingerprint) return;
191
+ hydratedFor = fingerprint;
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(readFileSync(cacheFile(), "utf8"));
195
+ } catch {
196
+ return;
197
+ }
198
+ if (!parsed || parsed.fingerprint !== fingerprint) return;
199
+ const entries = parsed.entries;
200
+ if (!entries || typeof entries !== "object") return;
201
+ for (const endpoint of PERSISTED_ENDPOINTS) {
202
+ const hit = entries[endpoint];
203
+ if (!hit || typeof hit.at !== "number") continue;
204
+ if (Date.now() - hit.at > CACHE_TTL_MS) continue;
205
+ if (lastGood.has(cacheKey(fingerprint, endpoint))) continue;
206
+ lastGood.set(cacheKey(fingerprint, endpoint), { value: hit.value, at: hit.at });
207
+ }
208
+ }
209
+
210
+ /** 把当前指纹下仍然新鲜的额度快照落盘; 失败静默, 不能影响查询本身。 */
211
+ function persistCache(fingerprint) {
212
+ const entries = {};
213
+ for (const endpoint of PERSISTED_ENDPOINTS) {
214
+ const hit = cacheGet(fingerprint, endpoint);
215
+ if (hit) entries[endpoint] = hit;
216
+ }
217
+ if (Object.keys(entries).length === 0) return;
218
+ try {
219
+ const file = cacheFile();
220
+ mkdirSync(dirname(file), { recursive: true });
221
+ writeFileSync(file, JSON.stringify({ fingerprint, entries }, null, 2) + "\n", "utf8");
222
+ } catch {
223
+ /* 落盘失败不影响查询 */
224
+ }
157
225
  }
158
226
 
159
227
  /**
@@ -167,6 +235,8 @@ async function fetchDashboard(cfg, timeoutMs, onlyKeys) {
167
235
  const errors = [];
168
236
  const stale = [];
169
237
  const out = { ok: true, fetchedAt: Date.now(), errors, stale };
238
+ const fingerprint = keyFingerprint(cfg.apiKey);
239
+ hydrateCache(fingerprint);
170
240
 
171
241
  const list = Array.isArray(onlyKeys) && onlyKeys.length > 0
172
242
  ? ENDPOINTS.filter(([key]) => onlyKeys.includes(key))
@@ -185,14 +255,14 @@ async function fetchDashboard(cfg, timeoutMs, onlyKeys) {
185
255
  const handler = HANDLERS[r.key];
186
256
  if (!r.error && r.status === 200 && r.json) {
187
257
  handler.apply(r.json, out);
188
- cacheSet(r.key, handler.snapshot(out));
258
+ cacheSet(fingerprint, r.key, handler.snapshot(out));
189
259
  continue;
190
260
  }
191
261
  const message = r.error
192
262
  ? `请求失败: ${r.error}`
193
263
  : (r.status === 401 ? "API key 无效或无权限 (HTTP 401)" : `HTTP ${r.status}`);
194
264
  // 401 是凭据问题, 不能拿旧数据掩盖; 其余失败回退上次成功值。
195
- const hit = r.status === 401 ? null : cacheGet(r.key);
265
+ const hit = r.status === 401 ? null : cacheGet(fingerprint, r.key);
196
266
  if (hit) {
197
267
  handler.restore(hit.value, out);
198
268
  stale.push({ endpoint: r.key, message, at: hit.at });
@@ -206,14 +276,24 @@ async function fetchDashboard(cfg, timeoutMs, onlyKeys) {
206
276
  if (skipped.length > 0) {
207
277
  out.cached = [];
208
278
  for (const [key] of skipped) {
209
- const hit = cacheGet(key);
279
+ const hit = cacheGet(fingerprint, key);
210
280
  if (!hit) continue;
211
281
  HANDLERS[key].restore(hit.value, out);
212
282
  out.cached.push(key);
213
283
  }
214
284
  }
215
285
 
216
- if (errors.length >= list.length) out.ok = false;
286
+ // 落盘: 下次进程启动时 hydrateCache 能把这份快照读回来
287
+ persistCache(fingerprint);
288
+
289
+ if (errors.length >= list.length) {
290
+ out.ok = false;
291
+ // 全部失败时必须把具体原因带出去。否则客户端只收到 ok:false、拿不到任何文案,
292
+ // 面板会被渲染成一块空白(2026-10-29 key 失效时就这么空了一整片)。
293
+ out.error = `查询失败: ${errors
294
+ .map((e) => `${ENDPOINT_LABELS[e.endpoint] ?? e.endpoint} ${e.message}`)
295
+ .join(";")}`;
296
+ }
217
297
  return out;
218
298
  }
219
299
 
@@ -265,6 +345,27 @@ async function resolveAccountOptions(ctx, account) {
265
345
  /** 合法凭据条目名:POSIX shell 标识符,与 dsh-credentials 的 REF_PATTERN 一致。 */
266
346
  const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
267
347
 
348
+ /**
349
+ * 把环境变量名校验成凭据引用(等价于 @deepseek-ai/dsh-credentials 的 credentialRef)。
350
+ *
351
+ * 为什么本地实现而不 import 那个包:那是【运行时导入】,而插件的真实路径未必在
352
+ * profile 的 node_modules 里面(本地 junction 安装就是这种情况)。此时
353
+ * "@deepseek-ai/dsh-credentials" 只能靠 profiles/node_modules 下的链接解析,
354
+ * 而 DSH 升级会清空那里的 @deepseek-ai —— 导入立刻抛 ERR_MODULE_NOT_FOUND,
355
+ * 整个宿主端(连带界面面板)都不会上线;市场里的普通用户同样会踩到。
356
+ * 校验规则只有一行正则,本地实现即可让插件零 harness 运行时依赖。
357
+ *
358
+ * @param {unknown} value 候选引用,如 BAILIAN_API_KEY。
359
+ * @returns {string} 通过校验的引用本身。
360
+ * @throws {TypeError} 名字不符合 shell 标识符语法时。
361
+ */
362
+ function credentialRef(value) {
363
+ if (typeof value !== "string" || !REF_PATTERN.test(value)) {
364
+ throw new TypeError(`credential ref "${String(value)}" must match ${String(REF_PATTERN)}`);
365
+ }
366
+ return value;
367
+ }
368
+
268
369
  function dshHome() {
269
370
  const fromEnv = process.env.DSH_HOME;
270
371
  return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : join(homedir(), ".dsh");
@@ -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, creds = null } = {}) {
16
+ function mount({ open = true, data = fixture, failed = false, creds = null, gearOpen = false } = {}) {
17
17
  let registration, component, slot, stateIndex = 0;
18
- const values = [open, data, false, null, creds || { entries: [], apiKeyEnv: null, saving: false, error: null }];
18
+ const values = [open, data, false, null, creds || { entries: [], apiKeyEnv: null, saving: false, error: null }, gearOpen, null];
19
19
  const updates = [], callbacks = [], effects = [], requests = [], timers = [];
20
20
  const React = {
21
21
  Fragment: "fragment",
@@ -61,8 +61,8 @@ function mount({ open = true, data = fixture, failed = false, creds = null } = {
61
61
  async function main() {
62
62
  const view = mount();
63
63
  const buttons = view.nodes.filter(node => node.type === "button");
64
- assert.equal(buttons.length, 3, "只保留状态栏、刷新和关闭按钮,不应存在账号切换按钮");
65
- assert.equal(view.stateIndex, 5, "状态:面板开合 / 数据 / 加载 / 错误 / 凭据条目");
64
+ assert.equal(buttons.length, 4, "状态栏 + 齿轮 + 刷新 + 关闭,不应存在账号切换按钮");
65
+ assert.equal(view.stateIndex, 7, "状态:面板开合 / 数据 / 加载 / 错误 / 凭据条目 / 凭据设置 / 配色");
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}`);
@@ -107,7 +107,7 @@ async function main() {
107
107
  ["报错态", { failed: true }],
108
108
  ["无数据态", { data: null }],
109
109
  ]) {
110
- const view2 = mount({ ...options, creds: pickerState });
110
+ const view2 = mount({ ...options, creds: pickerState, gearOpen: true });
111
111
  const selects = view2.nodes.filter((n) => n.type === "select");
112
112
  assert.equal(selects.length, 1, `${label}下应能看到凭据条目下拉`);
113
113
  const opts = view2.nodes.filter((n) => n.type === "option");
@@ -126,13 +126,13 @@ async function main() {
126
126
  assert.equal(selects[0].props.value, "COMMANDCODE2_API_KEY", `${label}下应选中当前条目`);
127
127
  }
128
128
  // 宿主端读不到供应商时会退化成纯字符串数组,也必须照常工作
129
- const plain = mount({ creds: { entries: ["BAILIAN_API_KEY"], apiKeyEnv: null, saving: false, error: null } });
129
+ const plain = mount({ creds: { entries: ["BAILIAN_API_KEY"], apiKeyEnv: null, saving: false, error: null }, gearOpen: true });
130
130
  assert.deepEqual(
131
131
  plain.nodes.filter((n) => n.type === "option").map((o) => o.children[0]),
132
132
  ["(未设置)", "BAILIAN_API_KEY"],
133
133
  );
134
134
  // 配置指向一个尚未添加的条目时也要出现在候选里,否则下拉会显示成空白
135
- const orphan = mount({ creds: { entries: [{ ref: "OTHER_KEY", provider: null }], apiKeyEnv: "MISSING_KEY", saving: false, error: null } });
135
+ const orphan = mount({ creds: { entries: [{ ref: "OTHER_KEY", provider: null }], apiKeyEnv: "MISSING_KEY", saving: false, error: null }, gearOpen: true });
136
136
  assert.deepEqual(
137
137
  orphan.nodes.filter((n) => n.type === "option").map((o) => o.props.value),
138
138
  ["", "MISSING_KEY", "OTHER_KEY"],
@@ -140,9 +140,12 @@ async function main() {
140
140
  const timerView = mount({ open: false, data: null });
141
141
  assert.equal(timerView.nodes.filter(node => node.type === "button").length, 1);
142
142
  const cleanup = timerView.effects[0]();
143
+ // 分层轮询: 余额 3 分钟一次, 用量/订阅 30 分钟一次(重查询不该跟余额同频)
143
144
  assert.equal(timerView.timers[0].ms, 3 * 60 * 1000);
145
+ assert.equal(timerView.timers[1].ms, 30 * 60 * 1000);
144
146
  cleanup();
145
- assert.equal(timerView.timers[1].cleared, 1);
147
+ assert.equal(timerView.timers[2].cleared, 1);
148
+ assert.equal(timerView.timers[3].cleared, 1);
146
149
  const failure = mount({ failed: true });
147
150
  await failure.callbacks[0].fn();
148
151
  assert.ok(failure.updates.some(update => update.index === 3 && update.value.includes("HTTP 503")));
@@ -38,7 +38,6 @@ function load() {
38
38
  join, dirname,
39
39
  homedir: () => fakeHome,
40
40
  process: { env: { DSH_HOME: fakeHome } },
41
- credentialRef: (ref) => ref,
42
41
  fetch: async (url, options) => { requests.push({ url, options }); return reply(url, options); },
43
42
  });
44
43
  vm.runInContext(source.replace(/^import .*;\r?$/gm, "").replace(/^export .*;\r?$/gm, "") +
@@ -139,11 +138,22 @@ async function main() {
139
138
  const stale = await app.request();
140
139
  assert.equal(stale.body.balance.monthlyCredits, 70);
141
140
  assert.equal(stale.body.stale.length, 4);
141
+ // 落盘缓存: 模拟 DSH 重启(新 context, 内存缓存为空)后 credits 失败,
142
+ // 应该能从磁盘快照回退余额, 而不是让面板空白。
143
+ const restarted = load();
144
+ restarted.respond(async () => ({ status: 503, json: async () => ({}) }));
145
+ const restored = await mount(restarted, config, { PRIMARY_TEST_KEY: "fixture-primary" })
146
+ .request(DASHBOARD + "?scope=quick");
147
+ assert.equal(restored.body.balance.monthlyCredits, 70, "重启后应能从落盘缓存回退余额");
148
+ assert.equal(restored.body.stale.length, 1);
142
149
  runtime.respond(async () => ({ status: 401, json: async () => ({}) }));
143
150
  const unauthorized = await app.request();
144
151
  assert.equal(unauthorized.body.ok, false);
145
152
  assert.equal(unauthorized.body.balance, undefined);
146
153
  assert.equal(unauthorized.body.errors.length, 4);
154
+ // 全失败必须带出可读原因, 否则客户端只拿到 ok:false, 面板会渲染成空白
155
+ assert.ok(unauthorized.body.error.includes("查询失败"), "全失败时应带出可读原因");
156
+ assert.ok(unauthorized.body.error.includes("余额"), "失败原因应标明是哪个接口");
147
157
  runtime.requests.length = 0;
148
158
  assert.equal((await app.request(undefined, "POST")).status, 405);
149
159
  assert.equal(runtime.requests.length, 0);
@@ -173,6 +183,9 @@ async function main() {
173
183
  });
174
184
  assert.equal((await mount(retryRuntime, config, { PRIMARY_TEST_KEY: "fixture-primary" }).request(DASHBOARD + "?scope=quick")).body.ok, true);
175
185
  assert.equal(attempts, 2);
186
+ // 超时用例要求「冷启动且无任何可用缓存」: 先清掉落盘快照,
187
+ // 否则 hydrateCache 会让 credits 兜底成功, 就测不到 ok:false 了。
188
+ fs.rmSync(join(fakeHome, "dsh-commandcode-quota-cache.json"), { force: true });
176
189
  const timeoutRuntime = load();
177
190
  timeoutRuntime.respond((url, options) => new Promise((resolve, reject) => {
178
191
  options.signal.addEventListener("abort", () => reject(new Error("模拟超时")), { once: true });
@@ -260,6 +273,12 @@ async function main() {
260
273
  typeof peerRange === "string" && /-0/.test(peerRange),
261
274
  `官方包 peer 范围必须含显式预发布分支(当前: ${peerRange})`,
262
275
  );
276
+ // 宿主端不得再有 @deepseek-ai 的【运行时导入】:插件真实路径可能在 profile 之外,
277
+ // 该包只能靠 profiles/node_modules 下的链接解析,而 DSH 升级会清掉那些链接,
278
+ // 导入即抛 ERR_MODULE_NOT_FOUND,整个插件(含界面)都不上线(2026-10-17 真挂过一次)。
279
+ // peer 声明只用于兼容性判定(读 package.json),不参与运行时解析,因此保留。
280
+ const runtimeHarnessImports = source.match(/^import[^\n]*from\s+"@deepseek-ai\/[^"]+";?$/gm) ?? [];
281
+ assert.deepEqual(runtimeHarnessImports, [], "宿主端不得 import harness 包(会在 profile 之外解析失败)");
263
282
  console.log("通过:单账号响应、主/回退凭据、完整/快速查询、缓存、401、405、重试和超时。");
264
283
  console.log("通过:凭据端点只回条目名、翻译供应商名、保存后覆盖 patch 配置、非法名被拒、无 key 时依然可用。");
265
284
  console.log("通过:宿主端导出、路由、包名与 bundle 补丁层一致。");