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/README.md +13 -3
- package/README.zh.md +13 -3
- package/package.json +1 -1
- package/src/client.js +478 -164
- package/src/index.js +111 -10
- package/test/client.test.cjs +11 -8
- package/test/host.test.cjs +20 -1
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.
|
|
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(
|
|
148
|
-
const hit = lastGood.get(
|
|
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(
|
|
169
|
+
function cacheSet(fingerprint, endpoint, value) {
|
|
155
170
|
if (value === undefined || value === null) return;
|
|
156
|
-
lastGood.set(
|
|
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
|
-
|
|
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");
|
package/test/client.test.cjs
CHANGED
|
@@ -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,
|
|
65
|
-
assert.equal(view.stateIndex,
|
|
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[
|
|
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")));
|
package/test/host.test.cjs
CHANGED
|
@@ -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 补丁层一致。");
|