dsh-llm-codebuddy 1.3.4 → 1.3.6

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.
@@ -0,0 +1,383 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+
5
+ export const CODEBUDDY_SESSION_REF = "CODEBUDDY_LOGIN_SESSION";
6
+ export const CODEBUDDY_SESSIONS_REF = "CODEBUDDY_LOGIN_SESSIONS";
7
+ export const CODEBUDDY_API_KEYS_REF = "CODEBUDDY_API_KEYS";
8
+
9
+ const BASE_URL = "https://copilot.tencent.com/v2/plugin";
10
+ const USER_AGENT = "CLI/unknown CodeBuddy/2.137.1";
11
+ const REQUEST_HEADERS = {
12
+ accept: "application/json",
13
+ "content-type": "application/json",
14
+ "user-agent": USER_AGENT,
15
+ "x-product": "SaaS",
16
+ };
17
+ const NO_ACCOUNT_HEADERS = {
18
+ "X-No-Authorization": "true",
19
+ "X-No-User-Id": "true",
20
+ "X-No-Enterprise-Id": "true",
21
+ "X-No-Department-Info": "true",
22
+ };
23
+ const NO_ID_HEADERS = {
24
+ "X-No-User-Id": "true",
25
+ "X-No-Enterprise-Id": "true",
26
+ "X-No-Department-Info": "true",
27
+ };
28
+
29
+ function calculateExpiresAt(auth, now = Date.now()) {
30
+ const result = { ...auth };
31
+ if (!result.expiresAt && Number.isFinite(result.expiresIn)) result.expiresAt = now + result.expiresIn * 1000;
32
+ if (!result.refreshExpiresAt && Number.isFinite(result.refreshExpiresIn)) result.refreshExpiresAt = now + result.refreshExpiresIn * 1000;
33
+ return result;
34
+ }
35
+
36
+ async function responseBody(response, action) {
37
+ try {
38
+ return await response.json();
39
+ } catch (error) {
40
+ throw new Error(`${action}返回了无法解析的数据`, { cause: error });
41
+ }
42
+ }
43
+
44
+ async function request(path, options, action) {
45
+ let response;
46
+ try {
47
+ response = await fetch(`${BASE_URL}${path}`, options);
48
+ } catch (error) {
49
+ if (options.signal?.aborted) throw new Error(`${action}已取消`, { cause: error });
50
+ throw new Error(`${action}无法连接 CodeBuddy 中国站`, { cause: error });
51
+ }
52
+ const body = await responseBody(response, action);
53
+ if (!response.ok || body?.code !== 0) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`);
54
+ return body.data;
55
+ }
56
+
57
+ function enterpriseHeaders(session) {
58
+ const enterpriseId = session.account?.enterpriseId;
59
+ return {
60
+ ...(enterpriseId ? { "X-Enterprise-Id": enterpriseId, "X-Tenant-Id": enterpriseId } : {}),
61
+ ...(session.auth?.domain ? { "X-Domain": session.auth.domain } : {}),
62
+ };
63
+ }
64
+
65
+ async function poll(path, headers, action, timeoutMs, signal) {
66
+ const deadline = Date.now() + timeoutMs;
67
+ while (Date.now() < deadline) {
68
+ await delay(1000, undefined, { signal });
69
+ let response;
70
+ try {
71
+ response = await fetch(`${BASE_URL}${path}`, { headers: { ...REQUEST_HEADERS, ...headers }, signal });
72
+ } catch (error) {
73
+ if (signal?.aborted) throw new Error(`${action}已取消`, { cause: error });
74
+ continue;
75
+ }
76
+ const body = await responseBody(response, action);
77
+ if (response.ok && body?.code === 0 && body.data) return body.data;
78
+ if (response.status === 401 || response.status === 403) throw new Error(`${action}失败(${body?.message ?? body?.msg ?? response.status})`);
79
+ }
80
+ throw new Error(`${action}超时`);
81
+ }
82
+
83
+ function openBrowser(url) {
84
+ const [command, args] = process.platform === "win32"
85
+ ? ["rundll32.exe", ["url.dll,FileProtocolHandler", url]]
86
+ : process.platform === "darwin"
87
+ ? ["open", [url]]
88
+ : ["xdg-open", [url]];
89
+ return new Promise((resolve, reject) => {
90
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
91
+ child.once("spawn", () => {
92
+ child.unref();
93
+ resolve();
94
+ });
95
+ child.once("error", reject);
96
+ });
97
+ }
98
+
99
+ function textValue(...values) {
100
+ for (const value of values) {
101
+ if (typeof value === "string" && value.trim()) return value.trim();
102
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ function tokenClaims(token) {
108
+ if (typeof token !== "string") return {};
109
+ try {
110
+ const payload = token.split(".")[1];
111
+ if (!payload) return {};
112
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
113
+ return parsed && typeof parsed === "object" ? parsed : {};
114
+ } catch {
115
+ return {};
116
+ }
117
+ }
118
+
119
+ function normalizeApiKeyEntry(entry, now = Date.now()) {
120
+ const ref = textValue(entry?.ref);
121
+ if (!ref || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) throw new Error("CodeBuddy API Key 引用无效");
122
+ const id = textValue(entry?.id) ?? `dsh:${ref}`;
123
+ return {
124
+ id,
125
+ ref,
126
+ label: textValue(entry?.label) ?? "DSH 保存的 API Key",
127
+ createdAt: Number.isFinite(entry?.createdAt) ? entry.createdAt : now,
128
+ updatedAt: Number.isFinite(entry?.updatedAt) ? entry.updatedAt : now,
129
+ };
130
+ }
131
+
132
+ export function createCodeBuddyApiKeyStore(entries = [], activeId) {
133
+ const keys = [];
134
+ for (const entry of entries) {
135
+ const normalized = normalizeApiKeyEntry(entry);
136
+ if (!keys.some((item) => item.id === normalized.id || item.ref === normalized.ref)) keys.push(normalized);
137
+ }
138
+ const selected = activeId === null
139
+ ? null
140
+ : typeof activeId === "string" && keys.some((entry) => entry.id === activeId) ? activeId : keys[0]?.id;
141
+ return { version: 1, activeId: selected, entries: keys };
142
+ }
143
+
144
+ export function upsertCodeBuddyApiKey(store, entry, now = Date.now()) {
145
+ const current = createCodeBuddyApiKeyStore(store?.entries ?? [], store?.activeId);
146
+ const incoming = normalizeApiKeyEntry({ ...entry, updatedAt: now }, now);
147
+ const index = current.entries.findIndex((item) => item.id === incoming.id || item.ref === incoming.ref);
148
+ if (index >= 0) incoming.createdAt = current.entries[index].createdAt;
149
+ const entries = index >= 0
150
+ ? current.entries.map((item, position) => position === index ? incoming : item)
151
+ : [...current.entries, incoming];
152
+ return { version: 1, activeId: incoming.id, entries };
153
+ }
154
+
155
+ export function serializeCodeBuddyApiKeys(store) {
156
+ const normalized = createCodeBuddyApiKeyStore(store?.entries ?? [], store?.activeId);
157
+ return JSON.stringify(normalized);
158
+ }
159
+
160
+ export function parseCodeBuddyApiKeys(value) {
161
+ let parsed;
162
+ try {
163
+ parsed = JSON.parse(value);
164
+ } catch (error) {
165
+ throw new Error("CodeBuddy API Key 列表已损坏,请重新配置", { cause: error });
166
+ }
167
+ if (!Array.isArray(parsed?.entries)) throw new Error("CodeBuddy API Key 列表格式无效,请重新配置");
168
+ return createCodeBuddyApiKeyStore(parsed.entries, parsed.activeId);
169
+ }
170
+
171
+ export function codeBuddyApiKeyEntries(store) {
172
+ return (store?.entries ?? []).map(({ id, ref, label, createdAt, updatedAt }) => ({ id, ref, label, createdAt, updatedAt }));
173
+ }
174
+
175
+ function normalizeAccount(account, auth) {
176
+ // The account endpoint has returned both a flat object and wrapped objects
177
+ // across CodeBuddy versions. Keep all known wrappers in the lookup so a newly
178
+ // added account gets the same display metadata as an imported account.
179
+ const sources = [
180
+ account,
181
+ account?.account,
182
+ account?.user,
183
+ account?.userInfo,
184
+ account?.profile,
185
+ account?.data,
186
+ ].filter((source) => source && typeof source === "object");
187
+ const read = (...keys) => textValue(...sources.flatMap((source) => keys.map((key) => source[key])));
188
+ const claims = tokenClaims(auth?.accessToken);
189
+ const userId = read("userId", "uid", "user_id", "id") ?? textValue(claims.userId, claims.uid, claims.user_id, claims.sub);
190
+ const enterpriseId = read("enterpriseId", "tenantId", "enterprise_id", "tenant_id")
191
+ ?? textValue(claims.enterpriseId, claims.tenantId, claims.enterprise_id, claims.tenant_id);
192
+ const email = read("email", "mail", "emailAddress") ?? textValue(claims.email, claims.mail);
193
+ const uin = read("uin", "phoneNumber", "phone", "mobile", "mobilePhone")
194
+ ?? textValue(claims.uin, claims.phoneNumber, claims.phone_number, claims.mobile);
195
+ const type = read("type", "accountType", "account_type");
196
+ const displayName = read("displayName", "name", "nickname", "username", "accountName")
197
+ ?? uin
198
+ ?? textValue(claims.displayName, claims.name, claims.nickname, claims.username, claims.preferred_username)
199
+ ?? email;
200
+ return {
201
+ ...(userId ? { userId } : {}),
202
+ ...(enterpriseId ? { enterpriseId } : {}),
203
+ ...(email ? { email } : {}),
204
+ ...(uin ? { uin } : {}),
205
+ ...(type ? { type } : {}),
206
+ ...(displayName ? { displayName } : {}),
207
+ };
208
+ }
209
+
210
+ export function codeBuddySessionId(session) {
211
+ const account = normalizeAccount(session?.account, session?.auth);
212
+ if (account.userId) return `user:${account.userId}`;
213
+ if (account.email) return `email:${account.email}`;
214
+ if (account.enterpriseId) return `enterprise:${account.enterpriseId}`;
215
+ const refreshToken = session?.auth?.refreshToken ?? session?.auth?.accessToken;
216
+ if (!refreshToken) throw new Error("CodeBuddy 登录会话缺少账号标识和令牌");
217
+ return `token:${createHash("sha256").update(refreshToken).digest("hex").slice(0, 24)}`;
218
+ }
219
+
220
+ export function codeBuddySessionLabel(session) {
221
+ const account = normalizeAccount(session?.account, session?.auth);
222
+ return account.displayName ?? account.email ?? account.userId ?? account.enterpriseId ?? `账号 ${codeBuddySessionId(session).slice(-8)}`;
223
+ }
224
+
225
+ export function normalizeCodeBuddySessionEntry(session, now = Date.now()) {
226
+ const normalized = {
227
+ auth: calculateExpiresAt(session?.auth),
228
+ account: normalizeAccount(session?.account, session?.auth),
229
+ };
230
+ if (!normalized.auth.accessToken || !normalized.auth.refreshToken) throw new Error("CodeBuddy 登录会话无效");
231
+ const id = typeof session?.id === "string" && session.id.trim() ? session.id : codeBuddySessionId(normalized);
232
+ const createdAt = Number.isFinite(session?.createdAt) ? session.createdAt : now;
233
+ return {
234
+ id,
235
+ label: typeof session?.label === "string" && session.label.trim() ? session.label : codeBuddySessionLabel(normalized),
236
+ createdAt,
237
+ updatedAt: Number.isFinite(session?.updatedAt) ? session.updatedAt : now,
238
+ ...normalized,
239
+ };
240
+ }
241
+
242
+ export function createCodeBuddySessionStore(entries = [], activeId) {
243
+ const sessions = [];
244
+ for (const entry of entries) {
245
+ const normalized = normalizeCodeBuddySessionEntry(entry);
246
+ if (!sessions.some((item) => item.id === normalized.id)) sessions.push(normalized);
247
+ }
248
+ const selected = typeof activeId === "string" && sessions.some((entry) => entry.id === activeId) ? activeId : sessions[0]?.id;
249
+ return { version: 1, activeId: selected, sessions };
250
+ }
251
+
252
+ export function upsertCodeBuddySession(store, session, now = Date.now()) {
253
+ const current = createCodeBuddySessionStore(store?.sessions ?? [], store?.activeId);
254
+ const incoming = normalizeCodeBuddySessionEntry({ ...session, updatedAt: now }, now);
255
+ const index = current.sessions.findIndex((entry) => entry.id === incoming.id);
256
+ if (index >= 0) incoming.createdAt = current.sessions[index].createdAt;
257
+ const sessions = index >= 0
258
+ ? current.sessions.map((entry, position) => position === index ? incoming : entry)
259
+ : [...current.sessions, incoming];
260
+ return { version: 1, activeId: incoming.id, sessions };
261
+ }
262
+
263
+ export function activeCodeBuddySession(store) {
264
+ return store?.sessions?.find((entry) => entry.id === store.activeId) ?? store?.sessions?.[0];
265
+ }
266
+
267
+ export function codeBuddySessionAccounts(store) {
268
+ return (store?.sessions ?? []).map(({ id, label, account, createdAt, updatedAt }) => ({
269
+ id,
270
+ label,
271
+ accountName: label,
272
+ userId: account?.userId ?? null,
273
+ account,
274
+ createdAt,
275
+ updatedAt,
276
+ }));
277
+ }
278
+
279
+ export async function loginCodeBuddy(onAuthUrl, signal) {
280
+ const state = await request("/auth/state?platform=CLI", {
281
+ method: "POST",
282
+ headers: { ...REQUEST_HEADERS, ...NO_ACCOUNT_HEADERS },
283
+ body: "{}",
284
+ signal,
285
+ }, "创建 CodeBuddy 登录会话");
286
+ if (!state?.state || !state?.authUrl) throw new Error("CodeBuddy 登录接口没有返回登录地址");
287
+ try {
288
+ await openBrowser(state.authUrl);
289
+ onAuthUrl?.(state.authUrl, true);
290
+ } catch {
291
+ onAuthUrl?.(state.authUrl, false);
292
+ }
293
+ const auth = calculateExpiresAt(await poll(
294
+ `/auth/token?state=${encodeURIComponent(state.state)}`,
295
+ NO_ACCOUNT_HEADERS,
296
+ "等待 CodeBuddy 登录",
297
+ 10 * 60_000,
298
+ signal,
299
+ ));
300
+ if (!auth.accessToken || !auth.refreshToken) throw new Error("CodeBuddy 登录接口没有返回完整令牌");
301
+ const account = await poll(
302
+ `/login/account?state=${encodeURIComponent(state.state)}`,
303
+ { ...enterpriseHeaders({ auth }), authorization: `Bearer ${auth.accessToken}`, ...NO_ID_HEADERS },
304
+ "获取 CodeBuddy 账号",
305
+ 60_000,
306
+ signal,
307
+ );
308
+ return { auth, account: normalizeAccount(account, auth) };
309
+ }
310
+
311
+ export async function refreshCodeBuddySession(session, signal) {
312
+ if (!session?.auth?.refreshToken) throw new Error("CodeBuddy 登录会话缺少刷新令牌,请重新登录");
313
+ const auth = await request("/auth/token/refresh", {
314
+ method: "POST",
315
+ headers: {
316
+ ...REQUEST_HEADERS,
317
+ ...enterpriseHeaders(session),
318
+ "X-Refresh-Token": session.auth.refreshToken,
319
+ "X-Auth-Refresh-Source": "plugin",
320
+ },
321
+ body: "{}",
322
+ signal,
323
+ }, "刷新 CodeBuddy 登录令牌");
324
+ const fresh = calculateExpiresAt(auth);
325
+ const merged = { ...session.auth, ...fresh, refreshToken: fresh?.refreshToken ?? session.auth.refreshToken };
326
+ if (!merged.accessToken) throw new Error("CodeBuddy 刷新接口没有返回访问令牌");
327
+ return { auth: merged, account: normalizeAccount(session.account, merged) };
328
+ }
329
+
330
+ export function serializeCodeBuddySession(session) {
331
+ if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("CodeBuddy 登录会话无效");
332
+ return JSON.stringify({ auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account, session.auth) });
333
+ }
334
+
335
+ export function serializeCodeBuddySessions(store) {
336
+ const normalized = createCodeBuddySessionStore(store?.sessions ?? [], store?.activeId);
337
+ if (normalized.sessions.length === 0) throw new Error("CodeBuddy 登录账号列表为空");
338
+ return JSON.stringify(normalized);
339
+ }
340
+
341
+ export function parseCodeBuddySession(value) {
342
+ let session;
343
+ try {
344
+ session = JSON.parse(value);
345
+ } catch (error) {
346
+ throw new Error("CodeBuddy 登录凭据已损坏,请重新登录", { cause: error });
347
+ }
348
+ if (!session?.auth?.accessToken || !session?.auth?.refreshToken) throw new Error("CodeBuddy 登录凭据不完整,请重新登录");
349
+ return { auth: calculateExpiresAt(session.auth), account: normalizeAccount(session.account, session.auth) };
350
+ }
351
+
352
+ export function parseCodeBuddySessions(value) {
353
+ let parsed;
354
+ try {
355
+ parsed = JSON.parse(value);
356
+ } catch (error) {
357
+ throw new Error("CodeBuddy 登录账号列表已损坏,请重新登录", { cause: error });
358
+ }
359
+ if (Array.isArray(parsed?.sessions)) return createCodeBuddySessionStore(parsed.sessions, parsed.activeId);
360
+ if (parsed?.auth) {
361
+ const session = parseCodeBuddySession(value);
362
+ return createCodeBuddySessionStore([session], codeBuddySessionId(session));
363
+ }
364
+ throw new Error("CodeBuddy 登录账号列表格式无效,请重新登录");
365
+ }
366
+
367
+ export function sessionNeedsRefresh(session, now = Date.now()) {
368
+ const expiresAt = Number(session?.auth?.expiresAt);
369
+ if (Number.isFinite(expiresAt)) return expiresAt <= now + 2 * 60_000;
370
+ try {
371
+ const payload = JSON.parse(Buffer.from(session.auth.accessToken.split(".")[1], "base64url").toString("utf8"));
372
+ return Number.isFinite(payload.exp) ? payload.exp * 1000 <= now + 2 * 60_000 : true;
373
+ } catch {
374
+ return true;
375
+ }
376
+ }
377
+
378
+ export function sessionCacheDeadline(session, now = Date.now()) {
379
+ const expiresAt = Number(session?.auth?.expiresAt);
380
+ return Number.isFinite(expiresAt)
381
+ ? Math.max(now, Math.min(expiresAt - 2 * 60_000, now + 30 * 60_000))
382
+ : now + 5 * 60_000;
383
+ }