creditdaddy 1.2.0 → 1.3.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/LICENSE +21 -21
- package/README.md +40 -10
- package/bin/creditdaddy.js +131 -2
- package/package.json +29 -29
- package/src/accounts.js +18 -7
- package/src/authDevice.js +3 -0
- package/src/bgdaemon.js +332 -0
- package/src/catpawClient.js +103 -103
- package/src/catpawLocal.js +229 -229
- package/src/checkin.js +10 -4
- package/src/constants.js +4 -2
- package/src/daemon.js +137 -13
- package/src/localDetect.js +147 -147
- package/src/logger.js +104 -6
- package/src/minimaxAuth.js +127 -0
- package/src/minimaxClient.js +339 -0
- package/src/minimaxGateway.js +410 -0
- package/src/minimaxLocal.js +259 -0
- package/src/mirasimClient.js +189 -189
- package/src/panel.html +459 -36
- package/src/providers.js +26 -0
- package/src/qoderApp.js +3 -9
- package/src/qoderClient.js +237 -237
- package/src/qoderUmid.js +198 -198
- package/src/store.js +65 -7
- package/src/tenrouter.js +8 -0
- package/src/tenrouterAccounts.js +38 -3
- package/src/traeClient.js +6 -1
- package/src/traeGateway.js +679 -0
- package/src/transfer.js +24 -1
- package/src/usageSync.js +505 -505
- package/src/winProc.js +36 -0
- package/src/workbuddyLocal.js +133 -133
- package/src/zcodeAuth.js +131 -131
- package/src/zcodeAutoClaim.js +97 -97
- package/src/zcodeClient.js +93 -18
- package/src/zcodeGateway.js +104 -46
- package/src/zcodePlanShape.json +3 -3
- package/src/zcrypto.js +160 -160
package/src/winProc.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows 进程探测的公共实现。
|
|
3
|
+
*
|
|
4
|
+
* `tasklist.exe` 的输出跟随控制台代码页:中文系统上是 GBK,按 latin1/utf8 解码会得到乱码,
|
|
5
|
+
* 于是「没有运行的任务」那行匹配不上 → 探测函数把「进程不存在」误判成「正在运行」。
|
|
6
|
+
* (曾经真的踩过:qoderApp 用 latin1,中文 Windows 上 qoderRunning() 恒为 true,
|
|
7
|
+
* 导致切换永远报「Qoder 正在运行」,强制切换还会空等 11 秒。)
|
|
8
|
+
* 统一走这里,避免每个产品各写一份、各错一种。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
/** tasklist 的 Buffer 输出 → 字符串(GBK 优先,解码器不可用时退回 utf8) */
|
|
14
|
+
export function decodeTasklist(buf) {
|
|
15
|
+
try { return new TextDecoder('gbk').decode(buf); } catch { return buf.toString('utf8'); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** tasklist 在「无匹配」时输出的那行(各语言版本) */
|
|
19
|
+
const NO_TASKS = /No Tasks|没有运行的任务|没有找到|没有任务/i;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 任一 exe 是否在运行。
|
|
23
|
+
* @param {string|string[]} exes
|
|
24
|
+
* @returns {boolean} 查不到(命令不存在 / 超时)一律按「没在运行」处理,不阻塞调用方
|
|
25
|
+
*/
|
|
26
|
+
export function anyProcRunning(exes) {
|
|
27
|
+
if (process.platform !== 'win32') return false;
|
|
28
|
+
for (const exe of [].concat(exes)) {
|
|
29
|
+
try {
|
|
30
|
+
const out = decodeTasklist(execFileSync('tasklist.exe', ['/FI', `IMAGENAME eq ${exe}`, '/NH'],
|
|
31
|
+
{ windowsHide: true, encoding: 'buffer', timeout: 8000 }));
|
|
32
|
+
if (!NO_TASKS.test(out)) return true;
|
|
33
|
+
} catch { /* 查不到就换下一名 */ }
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
package/src/workbuddyLocal.js
CHANGED
|
@@ -1,133 +1,133 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本机 WorkBuddy 客户端凭据 — 读取已登录账号、切换当前账号。
|
|
3
|
-
*
|
|
4
|
-
* WorkBuddy(与 CodeBuddy CLI / 插件共用)把会话存成明文 JSON:
|
|
5
|
-
* Windows: %LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\<认证ID>.info
|
|
6
|
-
* macOS: ~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/
|
|
7
|
-
* Linux: ~/.local/share/CodeBuddyExtension/Data/Public/auth/
|
|
8
|
-
* 当前会话是 workbuddy-desktop.info(国际版客户端可能用其他认证 ID),带时间戳的同名文件是客户端留下的历史会话。
|
|
9
|
-
* 文件结构:{ account: { uid, nickname, phoneNumber, enterpriseId… }, auth: { accessToken, refreshToken, expiresAt, domain… }, accounts: [...] }
|
|
10
|
-
*
|
|
11
|
-
* 切换账号:原子写入会话文件 + 删除同目录的 "<文件>.logged-out" 登出标记;
|
|
12
|
-
* WorkBuddy 监听该文件,外部改动会被直接应用(客户端日志 "External change applied to session")。
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import fs from 'node:fs';
|
|
16
|
-
import os from 'node:os';
|
|
17
|
-
import path from 'node:path';
|
|
18
|
-
import crypto from 'node:crypto';
|
|
19
|
-
import { inspectToken } from './workbuddyClient.js';
|
|
20
|
-
|
|
21
|
-
export function workbuddyAuthDir() {
|
|
22
|
-
const home = os.homedir();
|
|
23
|
-
const tail = ['CodeBuddyExtension', 'Data', 'Public', 'auth'];
|
|
24
|
-
if (process.platform === 'win32') {
|
|
25
|
-
return path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), ...tail);
|
|
26
|
-
}
|
|
27
|
-
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', ...tail);
|
|
28
|
-
return path.join(process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), ...tail);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const CURRENT_RE = /^[a-z0-9-]+\.info$/i; // workbuddy-desktop.info
|
|
32
|
-
const HISTORY_RE = /^[a-z0-9-]+\.\d{4}-.*\.info$/i; // workbuddy-desktop.<时间戳>.<pid>.<uuid>.info
|
|
33
|
-
|
|
34
|
-
/** 会话 JSON → 账号记录(供 addAccount),不含网络请求 */
|
|
35
|
-
export function sessionToAccount(session, source) {
|
|
36
|
-
const token = session?.auth?.accessToken;
|
|
37
|
-
if (typeof token !== 'string' || !token) return null;
|
|
38
|
-
const info = inspectToken(token);
|
|
39
|
-
if (!info.provider) return null;
|
|
40
|
-
const acc = session.account || {};
|
|
41
|
-
const auth = session.auth || {};
|
|
42
|
-
const iso = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : null);
|
|
43
|
-
// 除 token 外的会话字段原样保留,切换账号时用来还原会话文件
|
|
44
|
-
const { accessToken: _a, refreshToken: _r, ...authRest } = auth;
|
|
45
|
-
return {
|
|
46
|
-
provider: info.provider,
|
|
47
|
-
token,
|
|
48
|
-
refreshToken: auth.refreshToken || null,
|
|
49
|
-
expiresAt: iso(auth.expiresAt) || info.expiresAt,
|
|
50
|
-
uid: acc.uid || info.uid,
|
|
51
|
-
name: acc.nickname || acc.name || null,
|
|
52
|
-
email: acc.email || null,
|
|
53
|
-
source,
|
|
54
|
-
meta: {
|
|
55
|
-
domain: auth.domain || info.host,
|
|
56
|
-
enterpriseId: acc.enterpriseId || null,
|
|
57
|
-
phone: acc.phoneNumber || null,
|
|
58
|
-
refreshExpiresAt: iso(auth.refreshExpiresAt),
|
|
59
|
-
session: {
|
|
60
|
-
account: acc, auth: authRest, accounts: Array.isArray(session.accounts) ? session.accounts : [],
|
|
61
|
-
...(Array.isArray(session.allAccounts) ? { allAccounts: session.allAccounts } : {}),
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* 读取本机 WorkBuddy 会话:当前会话 + 历史会话(按 uid 去重,保留最新)。
|
|
69
|
-
* @returns {{ dir, accounts: Array<{...账号记录, current: boolean, file, fileTime}>, errors }}
|
|
70
|
-
*/
|
|
71
|
-
export function readWorkbuddySessions() {
|
|
72
|
-
const dir = workbuddyAuthDir();
|
|
73
|
-
const out = { dir, accounts: [], errors: [] };
|
|
74
|
-
let names = [];
|
|
75
|
-
try { names = fs.readdirSync(dir); } catch { return out; }
|
|
76
|
-
const byUid = new Map();
|
|
77
|
-
for (const name of names) {
|
|
78
|
-
if (!CURRENT_RE.test(name) && !HISTORY_RE.test(name)) continue;
|
|
79
|
-
const file = path.join(dir, name);
|
|
80
|
-
try {
|
|
81
|
-
const current = !HISTORY_RE.test(name);
|
|
82
|
-
if (current && fs.existsSync(`${file}.logged-out`)) continue; // 已登出
|
|
83
|
-
const rec = sessionToAccount(JSON.parse(fs.readFileSync(file, 'utf8')), current ? 'WorkBuddy 当前登录' : 'WorkBuddy 历史会话');
|
|
84
|
-
if (!rec) continue;
|
|
85
|
-
const fileTime = fs.statSync(file).mtimeMs;
|
|
86
|
-
const item = { ...rec, current, file, fileTime };
|
|
87
|
-
const prev = byUid.get(rec.uid);
|
|
88
|
-
if (!prev || (current && !prev.current) || (current === prev.current && fileTime > prev.fileTime)) byUid.set(rec.uid, item);
|
|
89
|
-
} catch (e) {
|
|
90
|
-
out.errors.push({ file, error: e.message });
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
out.accounts = [...byUid.values()].sort((a, b) => b.current - a.current || b.fileTime - a.fileTime);
|
|
94
|
-
return out;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** 当前 WorkBuddy 客户端登录的 uid(未登录返回 null) */
|
|
98
|
-
export function currentWorkbuddyUid() {
|
|
99
|
-
return readWorkbuddySessions().accounts.find((a) => a.current)?.uid || null;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* 把账号写成 WorkBuddy 当前会话(切换账号)。需要账号带有导入时保存的会话信息。
|
|
104
|
-
* @returns {{ file, previousUid }}
|
|
105
|
-
*/
|
|
106
|
-
export function writeWorkbuddySession(account) {
|
|
107
|
-
const s = account.meta?.session;
|
|
108
|
-
if (!s?.account) throw new Error('该账号缺少 WorkBuddy 会话信息,请从本机重新导入后再切换');
|
|
109
|
-
const dir = workbuddyAuthDir();
|
|
110
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
111
|
-
const file = path.join(dir, 'workbuddy-desktop.info');
|
|
112
|
-
let previousUid = null;
|
|
113
|
-
try { previousUid = JSON.parse(fs.readFileSync(file, 'utf8'))?.account?.uid || null; } catch {}
|
|
114
|
-
|
|
115
|
-
const ms = (iso) => (iso ? new Date(iso).getTime() : undefined);
|
|
116
|
-
const session = {
|
|
117
|
-
account: s.account,
|
|
118
|
-
auth: {
|
|
119
|
-
...s.auth,
|
|
120
|
-
accessToken: account.token,
|
|
121
|
-
refreshToken: account.refreshToken || s.auth?.refreshToken,
|
|
122
|
-
expiresAt: ms(account.expiresAt) ?? s.auth?.expiresAt,
|
|
123
|
-
lastRefreshTime: Date.now(),
|
|
124
|
-
},
|
|
125
|
-
accounts: s.accounts || [],
|
|
126
|
-
...(Array.isArray(s.allAccounts) ? { allAccounts: s.allAccounts } : {}),
|
|
127
|
-
};
|
|
128
|
-
const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
129
|
-
fs.writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
130
|
-
fs.renameSync(tmp, file);
|
|
131
|
-
try { fs.unlinkSync(`${file}.logged-out`); } catch {}
|
|
132
|
-
return { file, previousUid };
|
|
133
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 本机 WorkBuddy 客户端凭据 — 读取已登录账号、切换当前账号。
|
|
3
|
+
*
|
|
4
|
+
* WorkBuddy(与 CodeBuddy CLI / 插件共用)把会话存成明文 JSON:
|
|
5
|
+
* Windows: %LOCALAPPDATA%\CodeBuddyExtension\Data\Public\auth\<认证ID>.info
|
|
6
|
+
* macOS: ~/Library/Application Support/CodeBuddyExtension/Data/Public/auth/
|
|
7
|
+
* Linux: ~/.local/share/CodeBuddyExtension/Data/Public/auth/
|
|
8
|
+
* 当前会话是 workbuddy-desktop.info(国际版客户端可能用其他认证 ID),带时间戳的同名文件是客户端留下的历史会话。
|
|
9
|
+
* 文件结构:{ account: { uid, nickname, phoneNumber, enterpriseId… }, auth: { accessToken, refreshToken, expiresAt, domain… }, accounts: [...] }
|
|
10
|
+
*
|
|
11
|
+
* 切换账号:原子写入会话文件 + 删除同目录的 "<文件>.logged-out" 登出标记;
|
|
12
|
+
* WorkBuddy 监听该文件,外部改动会被直接应用(客户端日志 "External change applied to session")。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { inspectToken } from './workbuddyClient.js';
|
|
20
|
+
|
|
21
|
+
export function workbuddyAuthDir() {
|
|
22
|
+
const home = os.homedir();
|
|
23
|
+
const tail = ['CodeBuddyExtension', 'Data', 'Public', 'auth'];
|
|
24
|
+
if (process.platform === 'win32') {
|
|
25
|
+
return path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), ...tail);
|
|
26
|
+
}
|
|
27
|
+
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', ...tail);
|
|
28
|
+
return path.join(process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), ...tail);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CURRENT_RE = /^[a-z0-9-]+\.info$/i; // workbuddy-desktop.info
|
|
32
|
+
const HISTORY_RE = /^[a-z0-9-]+\.\d{4}-.*\.info$/i; // workbuddy-desktop.<时间戳>.<pid>.<uuid>.info
|
|
33
|
+
|
|
34
|
+
/** 会话 JSON → 账号记录(供 addAccount),不含网络请求 */
|
|
35
|
+
export function sessionToAccount(session, source) {
|
|
36
|
+
const token = session?.auth?.accessToken;
|
|
37
|
+
if (typeof token !== 'string' || !token) return null;
|
|
38
|
+
const info = inspectToken(token);
|
|
39
|
+
if (!info.provider) return null;
|
|
40
|
+
const acc = session.account || {};
|
|
41
|
+
const auth = session.auth || {};
|
|
42
|
+
const iso = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : null);
|
|
43
|
+
// 除 token 外的会话字段原样保留,切换账号时用来还原会话文件
|
|
44
|
+
const { accessToken: _a, refreshToken: _r, ...authRest } = auth;
|
|
45
|
+
return {
|
|
46
|
+
provider: info.provider,
|
|
47
|
+
token,
|
|
48
|
+
refreshToken: auth.refreshToken || null,
|
|
49
|
+
expiresAt: iso(auth.expiresAt) || info.expiresAt,
|
|
50
|
+
uid: acc.uid || info.uid,
|
|
51
|
+
name: acc.nickname || acc.name || null,
|
|
52
|
+
email: acc.email || null,
|
|
53
|
+
source,
|
|
54
|
+
meta: {
|
|
55
|
+
domain: auth.domain || info.host,
|
|
56
|
+
enterpriseId: acc.enterpriseId || null,
|
|
57
|
+
phone: acc.phoneNumber || null,
|
|
58
|
+
refreshExpiresAt: iso(auth.refreshExpiresAt),
|
|
59
|
+
session: {
|
|
60
|
+
account: acc, auth: authRest, accounts: Array.isArray(session.accounts) ? session.accounts : [],
|
|
61
|
+
...(Array.isArray(session.allAccounts) ? { allAccounts: session.allAccounts } : {}),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 读取本机 WorkBuddy 会话:当前会话 + 历史会话(按 uid 去重,保留最新)。
|
|
69
|
+
* @returns {{ dir, accounts: Array<{...账号记录, current: boolean, file, fileTime}>, errors }}
|
|
70
|
+
*/
|
|
71
|
+
export function readWorkbuddySessions() {
|
|
72
|
+
const dir = workbuddyAuthDir();
|
|
73
|
+
const out = { dir, accounts: [], errors: [] };
|
|
74
|
+
let names = [];
|
|
75
|
+
try { names = fs.readdirSync(dir); } catch { return out; }
|
|
76
|
+
const byUid = new Map();
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
if (!CURRENT_RE.test(name) && !HISTORY_RE.test(name)) continue;
|
|
79
|
+
const file = path.join(dir, name);
|
|
80
|
+
try {
|
|
81
|
+
const current = !HISTORY_RE.test(name);
|
|
82
|
+
if (current && fs.existsSync(`${file}.logged-out`)) continue; // 已登出
|
|
83
|
+
const rec = sessionToAccount(JSON.parse(fs.readFileSync(file, 'utf8')), current ? 'WorkBuddy 当前登录' : 'WorkBuddy 历史会话');
|
|
84
|
+
if (!rec) continue;
|
|
85
|
+
const fileTime = fs.statSync(file).mtimeMs;
|
|
86
|
+
const item = { ...rec, current, file, fileTime };
|
|
87
|
+
const prev = byUid.get(rec.uid);
|
|
88
|
+
if (!prev || (current && !prev.current) || (current === prev.current && fileTime > prev.fileTime)) byUid.set(rec.uid, item);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
out.errors.push({ file, error: e.message });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
out.accounts = [...byUid.values()].sort((a, b) => b.current - a.current || b.fileTime - a.fileTime);
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 当前 WorkBuddy 客户端登录的 uid(未登录返回 null) */
|
|
98
|
+
export function currentWorkbuddyUid() {
|
|
99
|
+
return readWorkbuddySessions().accounts.find((a) => a.current)?.uid || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 把账号写成 WorkBuddy 当前会话(切换账号)。需要账号带有导入时保存的会话信息。
|
|
104
|
+
* @returns {{ file, previousUid }}
|
|
105
|
+
*/
|
|
106
|
+
export function writeWorkbuddySession(account) {
|
|
107
|
+
const s = account.meta?.session;
|
|
108
|
+
if (!s?.account) throw new Error('该账号缺少 WorkBuddy 会话信息,请从本机重新导入后再切换');
|
|
109
|
+
const dir = workbuddyAuthDir();
|
|
110
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
111
|
+
const file = path.join(dir, 'workbuddy-desktop.info');
|
|
112
|
+
let previousUid = null;
|
|
113
|
+
try { previousUid = JSON.parse(fs.readFileSync(file, 'utf8'))?.account?.uid || null; } catch {}
|
|
114
|
+
|
|
115
|
+
const ms = (iso) => (iso ? new Date(iso).getTime() : undefined);
|
|
116
|
+
const session = {
|
|
117
|
+
account: s.account,
|
|
118
|
+
auth: {
|
|
119
|
+
...s.auth,
|
|
120
|
+
accessToken: account.token,
|
|
121
|
+
refreshToken: account.refreshToken || s.auth?.refreshToken,
|
|
122
|
+
expiresAt: ms(account.expiresAt) ?? s.auth?.expiresAt,
|
|
123
|
+
lastRefreshTime: Date.now(),
|
|
124
|
+
},
|
|
125
|
+
accounts: s.accounts || [],
|
|
126
|
+
...(Array.isArray(s.allAccounts) ? { allAccounts: s.allAccounts } : {}),
|
|
127
|
+
};
|
|
128
|
+
const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
129
|
+
fs.writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
130
|
+
fs.renameSync(tmp, file);
|
|
131
|
+
try { fs.unlinkSync(`${file}.logged-out`); } catch {}
|
|
132
|
+
return { file, previousUid };
|
|
133
|
+
}
|
package/src/zcodeAuth.js
CHANGED
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ZCode 浏览器登录 — 与 ZCode 客户端的「CLI 轮询登录」同一协议(无需 zcode:// 回调,任何浏览器都能用):
|
|
3
|
-
*
|
|
4
|
-
* 1. POST https://zcode.z.ai/api/v1/oauth/cli/init Authorization: Bearer <本地随机 poll token>
|
|
5
|
-
* {provider: 'bigmodel' | 'zai'} → {flow_id, authorize_url, expires_at, poll_interval_sec}
|
|
6
|
-
* authorize_url 的回调指向 zcode.z.ai 自己的 /oauth/cli/callback,由服务端完成换 token
|
|
7
|
-
* 2. 用户在浏览器(桌面版为隐私窗口)打开 authorize_url 登录授权
|
|
8
|
-
* 3. GET /api/v1/oauth/cli/poll/{flow_id}(同一 poll token)→ status pending / ready / failed
|
|
9
|
-
* ready 时带 token(zcode JWT)、user、bigmodel{access_token, refresh_token} 或 zai{access_token}
|
|
10
|
-
* 4. Z.ai 的 access_token 还要换一次业务 token:POST https://api.z.ai/api/auth/z/login {token}
|
|
11
|
-
*
|
|
12
|
-
* 拿到的凭据按客户端 credentials.json 的格式(enc:v1 加密)组装,存进账号 meta,
|
|
13
|
-
* 之后与「本机导入」的账号完全一样:可查额度、可一键切换到 ZCode 客户端。
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import crypto from 'node:crypto';
|
|
17
|
-
import * as zc from './zcrypto.js';
|
|
18
|
-
import { zcodePaths } from './zcodeLocal.js';
|
|
19
|
-
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
20
|
-
|
|
21
|
-
const API_BASE = 'https://zcode.z.ai';
|
|
22
|
-
const ZAI_BUSINESS_LOGIN_URL = 'https://api.z.ai/api/auth/z/login';
|
|
23
|
-
|
|
24
|
-
async function fetchJson(url, init = {}) {
|
|
25
|
-
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
26
|
-
const text = await res.text();
|
|
27
|
-
let body = null;
|
|
28
|
-
try { body = text ? JSON.parse(text) : null; } catch {}
|
|
29
|
-
return { res, body };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
33
|
-
|
|
34
|
-
/** kind: 'zcode-bigmodel'(智谱 BigModel)| 'zcode-zai'(Z.ai 国际) */
|
|
35
|
-
export async function startZcodeLogin(kind) {
|
|
36
|
-
const provider = kind === 'zcode-zai' ? 'zai' : 'bigmodel';
|
|
37
|
-
const pollToken = crypto.randomBytes(32).toString('hex');
|
|
38
|
-
const { res, body } = await fetchJson(API_BASE + '/api/v1/oauth/cli/init', {
|
|
39
|
-
method: 'POST',
|
|
40
|
-
headers: { Authorization: 'Bearer ' + pollToken, 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
41
|
-
body: JSON.stringify({ provider }),
|
|
42
|
-
});
|
|
43
|
-
const d = body?.data;
|
|
44
|
-
if (!res.ok || body?.code !== 0 || !str(d?.flow_id) || !str(d?.authorize_url)) {
|
|
45
|
-
throw new Error('ZCode 登录初始化失败:' + (body?.msg || 'HTTP ' + res.status));
|
|
46
|
-
}
|
|
47
|
-
if (!d.authorize_url.startsWith('https://')) throw new Error('ZCode 登录初始化返回了非 https 授权地址');
|
|
48
|
-
const expiresInMs = Number.isFinite(d.expires_at) ? d.expires_at * 1000 - Date.now() : 5 * 60e3;
|
|
49
|
-
return {
|
|
50
|
-
url: d.authorize_url,
|
|
51
|
-
expiresInMs: Math.max(60e3, Math.min(expiresInMs, 15 * 60e3)),
|
|
52
|
-
intervalMs: Math.max(1000, (Number(d.poll_interval_sec) || 2) * 1000),
|
|
53
|
-
data: { provider, flowId: d.flow_id, pollToken },
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** 单次轮询:{status:'pending'} | {status:'ok', input};失败抛错 */
|
|
58
|
-
export async function pollZcodeLogin(data) {
|
|
59
|
-
const { res, body } = await fetchJson(API_BASE + '/api/v1/oauth/cli/poll/' + encodeURIComponent(data.flowId), {
|
|
60
|
-
headers: { Authorization: 'Bearer ' + data.pollToken, Accept: 'application/json' },
|
|
61
|
-
});
|
|
62
|
-
if (res.status >= 500 || res.status === 408 || res.status === 429) return { status: 'pending' };
|
|
63
|
-
if (!res.ok || body?.code !== 0) throw new Error('ZCode 授权失败:' + (body?.msg || 'HTTP ' + res.status));
|
|
64
|
-
const d = body.data || {};
|
|
65
|
-
if (d.status === 'pending') return { status: 'pending' };
|
|
66
|
-
if (d.status === 'failed') throw new Error('ZCode 授权失败(用户取消或授权被拒绝)');
|
|
67
|
-
if (d.status !== 'ready') throw new Error('ZCode 授权返回未知状态:' + d.status);
|
|
68
|
-
|
|
69
|
-
const jwt = str(d.token);
|
|
70
|
-
const user = d.user && typeof d.user === 'object' ? d.user : {};
|
|
71
|
-
const userId = user.user_id !== undefined && user.user_id !== null ? String(user.user_id) : null;
|
|
72
|
-
let accessToken = data.provider === 'zai' ? str(d.zai?.access_token) : str(d.bigmodel?.access_token) || str(d.bigmodel?.accessToken);
|
|
73
|
-
const refreshToken = data.provider === 'bigmodel' ? str(d.bigmodel?.refresh_token) || str(d.bigmodel?.refreshToken) : null;
|
|
74
|
-
if (!jwt || !accessToken || !userId) throw new Error('ZCode 授权返回缺少 token / 用户信息(上游结构可能变化)');
|
|
75
|
-
if (data.provider === 'zai') accessToken = await resolveZaiBusinessToken(accessToken);
|
|
76
|
-
|
|
77
|
-
return { status: 'ok', input: buildAccountInput({ provider: data.provider, jwt, accessToken, refreshToken, user, userId }) };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Z.ai OAuth token → 业务 access token(与客户端 ZaiBusinessTokenResolver 一致) */
|
|
81
|
-
async function resolveZaiBusinessToken(oauthToken) {
|
|
82
|
-
const { res, body } = await fetchJson(ZAI_BUSINESS_LOGIN_URL, {
|
|
83
|
-
method: 'POST',
|
|
84
|
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
85
|
-
body: JSON.stringify({ token: oauthToken }),
|
|
86
|
-
});
|
|
87
|
-
const ok = res.ok && (body?.code === undefined || body.code === 0 || body.code === 200) && body?.success !== false;
|
|
88
|
-
const t = str(body?.data?.access_token) || str(body?.data?.accessToken);
|
|
89
|
-
if (!ok || !t) throw new Error('Z.ai 业务 token 交换失败:' + (body?.msg || 'HTTP ' + res.status));
|
|
90
|
-
return t;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** 按 ZCode credentials.json 的键名组装并用本机密钥加密(与客户端写出的格式一致) */
|
|
94
|
-
export function buildCredentials({ provider, jwt, accessToken, refreshToken, user, userId }, secret) {
|
|
95
|
-
const label = str(user.name) || str(user.email) || userId;
|
|
96
|
-
const userInfo = {
|
|
97
|
-
id: userId, username: label, displayName: label,
|
|
98
|
-
...(str(user.avatar) ? { avatarUrl: user.avatar } : {}),
|
|
99
|
-
rawProfile: user,
|
|
100
|
-
};
|
|
101
|
-
const enc = (v) => zc.encryptWithSecret(v, secret);
|
|
102
|
-
const creds = {
|
|
103
|
-
'oauth:active_provider': enc(provider),
|
|
104
|
-
[`oauth:${provider}:access_token`]: enc(accessToken),
|
|
105
|
-
[`oauth:${provider}:user_info`]: enc(JSON.stringify(userInfo)),
|
|
106
|
-
zcodejwttoken: enc(jwt),
|
|
107
|
-
};
|
|
108
|
-
if (refreshToken) creds[`oauth:${provider}:refresh_token`] = enc(refreshToken);
|
|
109
|
-
return creds;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function buildAccountInput(parts) {
|
|
113
|
-
const secret = zc.defaultSecret(zcodePaths().home);
|
|
114
|
-
const credentials = buildCredentials(parts, secret);
|
|
115
|
-
const id = zc.identityWithSecret(credentials, secret);
|
|
116
|
-
return {
|
|
117
|
-
provider: 'zcode',
|
|
118
|
-
token: `zcode-creds:${parts.userId}`, // 与本机导入同一标记,同一用户自动去重 / 续期
|
|
119
|
-
uid: parts.userId,
|
|
120
|
-
name: zc.identityLabel(id),
|
|
121
|
-
email: str(parts.user.email),
|
|
122
|
-
source: 'browser',
|
|
123
|
-
// 不带 config / deviceMid:同一账号再次登录时保留已保存的客户端配置与虚拟设备 ID
|
|
124
|
-
meta: {
|
|
125
|
-
credentials,
|
|
126
|
-
canonicalHash: zc.canonicalHash(credentials),
|
|
127
|
-
capturedAt: new Date().toISOString(),
|
|
128
|
-
loginProvider: parts.provider,
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* ZCode 浏览器登录 — 与 ZCode 客户端的「CLI 轮询登录」同一协议(无需 zcode:// 回调,任何浏览器都能用):
|
|
3
|
+
*
|
|
4
|
+
* 1. POST https://zcode.z.ai/api/v1/oauth/cli/init Authorization: Bearer <本地随机 poll token>
|
|
5
|
+
* {provider: 'bigmodel' | 'zai'} → {flow_id, authorize_url, expires_at, poll_interval_sec}
|
|
6
|
+
* authorize_url 的回调指向 zcode.z.ai 自己的 /oauth/cli/callback,由服务端完成换 token
|
|
7
|
+
* 2. 用户在浏览器(桌面版为隐私窗口)打开 authorize_url 登录授权
|
|
8
|
+
* 3. GET /api/v1/oauth/cli/poll/{flow_id}(同一 poll token)→ status pending / ready / failed
|
|
9
|
+
* ready 时带 token(zcode JWT)、user、bigmodel{access_token, refresh_token} 或 zai{access_token}
|
|
10
|
+
* 4. Z.ai 的 access_token 还要换一次业务 token:POST https://api.z.ai/api/auth/z/login {token}
|
|
11
|
+
*
|
|
12
|
+
* 拿到的凭据按客户端 credentials.json 的格式(enc:v1 加密)组装,存进账号 meta,
|
|
13
|
+
* 之后与「本机导入」的账号完全一样:可查额度、可一键切换到 ZCode 客户端。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import * as zc from './zcrypto.js';
|
|
18
|
+
import { zcodePaths } from './zcodeLocal.js';
|
|
19
|
+
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
20
|
+
|
|
21
|
+
const API_BASE = 'https://zcode.z.ai';
|
|
22
|
+
const ZAI_BUSINESS_LOGIN_URL = 'https://api.z.ai/api/auth/z/login';
|
|
23
|
+
|
|
24
|
+
async function fetchJson(url, init = {}) {
|
|
25
|
+
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
let body = null;
|
|
28
|
+
try { body = text ? JSON.parse(text) : null; } catch {}
|
|
29
|
+
return { res, body };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
33
|
+
|
|
34
|
+
/** kind: 'zcode-bigmodel'(智谱 BigModel)| 'zcode-zai'(Z.ai 国际) */
|
|
35
|
+
export async function startZcodeLogin(kind) {
|
|
36
|
+
const provider = kind === 'zcode-zai' ? 'zai' : 'bigmodel';
|
|
37
|
+
const pollToken = crypto.randomBytes(32).toString('hex');
|
|
38
|
+
const { res, body } = await fetchJson(API_BASE + '/api/v1/oauth/cli/init', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { Authorization: 'Bearer ' + pollToken, 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
41
|
+
body: JSON.stringify({ provider }),
|
|
42
|
+
});
|
|
43
|
+
const d = body?.data;
|
|
44
|
+
if (!res.ok || body?.code !== 0 || !str(d?.flow_id) || !str(d?.authorize_url)) {
|
|
45
|
+
throw new Error('ZCode 登录初始化失败:' + (body?.msg || 'HTTP ' + res.status));
|
|
46
|
+
}
|
|
47
|
+
if (!d.authorize_url.startsWith('https://')) throw new Error('ZCode 登录初始化返回了非 https 授权地址');
|
|
48
|
+
const expiresInMs = Number.isFinite(d.expires_at) ? d.expires_at * 1000 - Date.now() : 5 * 60e3;
|
|
49
|
+
return {
|
|
50
|
+
url: d.authorize_url,
|
|
51
|
+
expiresInMs: Math.max(60e3, Math.min(expiresInMs, 15 * 60e3)),
|
|
52
|
+
intervalMs: Math.max(1000, (Number(d.poll_interval_sec) || 2) * 1000),
|
|
53
|
+
data: { provider, flowId: d.flow_id, pollToken },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 单次轮询:{status:'pending'} | {status:'ok', input};失败抛错 */
|
|
58
|
+
export async function pollZcodeLogin(data) {
|
|
59
|
+
const { res, body } = await fetchJson(API_BASE + '/api/v1/oauth/cli/poll/' + encodeURIComponent(data.flowId), {
|
|
60
|
+
headers: { Authorization: 'Bearer ' + data.pollToken, Accept: 'application/json' },
|
|
61
|
+
});
|
|
62
|
+
if (res.status >= 500 || res.status === 408 || res.status === 429) return { status: 'pending' };
|
|
63
|
+
if (!res.ok || body?.code !== 0) throw new Error('ZCode 授权失败:' + (body?.msg || 'HTTP ' + res.status));
|
|
64
|
+
const d = body.data || {};
|
|
65
|
+
if (d.status === 'pending') return { status: 'pending' };
|
|
66
|
+
if (d.status === 'failed') throw new Error('ZCode 授权失败(用户取消或授权被拒绝)');
|
|
67
|
+
if (d.status !== 'ready') throw new Error('ZCode 授权返回未知状态:' + d.status);
|
|
68
|
+
|
|
69
|
+
const jwt = str(d.token);
|
|
70
|
+
const user = d.user && typeof d.user === 'object' ? d.user : {};
|
|
71
|
+
const userId = user.user_id !== undefined && user.user_id !== null ? String(user.user_id) : null;
|
|
72
|
+
let accessToken = data.provider === 'zai' ? str(d.zai?.access_token) : str(d.bigmodel?.access_token) || str(d.bigmodel?.accessToken);
|
|
73
|
+
const refreshToken = data.provider === 'bigmodel' ? str(d.bigmodel?.refresh_token) || str(d.bigmodel?.refreshToken) : null;
|
|
74
|
+
if (!jwt || !accessToken || !userId) throw new Error('ZCode 授权返回缺少 token / 用户信息(上游结构可能变化)');
|
|
75
|
+
if (data.provider === 'zai') accessToken = await resolveZaiBusinessToken(accessToken);
|
|
76
|
+
|
|
77
|
+
return { status: 'ok', input: buildAccountInput({ provider: data.provider, jwt, accessToken, refreshToken, user, userId }) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Z.ai OAuth token → 业务 access token(与客户端 ZaiBusinessTokenResolver 一致) */
|
|
81
|
+
async function resolveZaiBusinessToken(oauthToken) {
|
|
82
|
+
const { res, body } = await fetchJson(ZAI_BUSINESS_LOGIN_URL, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
85
|
+
body: JSON.stringify({ token: oauthToken }),
|
|
86
|
+
});
|
|
87
|
+
const ok = res.ok && (body?.code === undefined || body.code === 0 || body.code === 200) && body?.success !== false;
|
|
88
|
+
const t = str(body?.data?.access_token) || str(body?.data?.accessToken);
|
|
89
|
+
if (!ok || !t) throw new Error('Z.ai 业务 token 交换失败:' + (body?.msg || 'HTTP ' + res.status));
|
|
90
|
+
return t;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 按 ZCode credentials.json 的键名组装并用本机密钥加密(与客户端写出的格式一致) */
|
|
94
|
+
export function buildCredentials({ provider, jwt, accessToken, refreshToken, user, userId }, secret) {
|
|
95
|
+
const label = str(user.name) || str(user.email) || userId;
|
|
96
|
+
const userInfo = {
|
|
97
|
+
id: userId, username: label, displayName: label,
|
|
98
|
+
...(str(user.avatar) ? { avatarUrl: user.avatar } : {}),
|
|
99
|
+
rawProfile: user,
|
|
100
|
+
};
|
|
101
|
+
const enc = (v) => zc.encryptWithSecret(v, secret);
|
|
102
|
+
const creds = {
|
|
103
|
+
'oauth:active_provider': enc(provider),
|
|
104
|
+
[`oauth:${provider}:access_token`]: enc(accessToken),
|
|
105
|
+
[`oauth:${provider}:user_info`]: enc(JSON.stringify(userInfo)),
|
|
106
|
+
zcodejwttoken: enc(jwt),
|
|
107
|
+
};
|
|
108
|
+
if (refreshToken) creds[`oauth:${provider}:refresh_token`] = enc(refreshToken);
|
|
109
|
+
return creds;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildAccountInput(parts) {
|
|
113
|
+
const secret = zc.defaultSecret(zcodePaths().home);
|
|
114
|
+
const credentials = buildCredentials(parts, secret);
|
|
115
|
+
const id = zc.identityWithSecret(credentials, secret);
|
|
116
|
+
return {
|
|
117
|
+
provider: 'zcode',
|
|
118
|
+
token: `zcode-creds:${parts.userId}`, // 与本机导入同一标记,同一用户自动去重 / 续期
|
|
119
|
+
uid: parts.userId,
|
|
120
|
+
name: zc.identityLabel(id),
|
|
121
|
+
email: str(parts.user.email),
|
|
122
|
+
source: 'browser',
|
|
123
|
+
// 不带 config / deviceMid:同一账号再次登录时保留已保存的客户端配置与虚拟设备 ID
|
|
124
|
+
meta: {
|
|
125
|
+
credentials,
|
|
126
|
+
canonicalHash: zc.canonicalHash(credentials),
|
|
127
|
+
capturedAt: new Date().toISOString(),
|
|
128
|
+
loginProvider: parts.provider,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|