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/daemon.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* POST /api/accounts/:id/zcode/claim ZCode 领取活动 {planId, captchaParam?, region?}
|
|
19
19
|
* GET /api/zcode/captcha-config ZCode 领取验证码配置(sceneId / prefix / region)
|
|
20
20
|
* POST /api/checkin 全部签到 {provider?, skipIfCheckedToday?}
|
|
21
|
-
* POST /api/auth/device/start 发起浏览器登录 {provider: qoder / qoder-cn / workbuddy / workbuddy-intl / zcode-bigmodel / zcode-zai}
|
|
21
|
+
* POST /api/auth/device/start 发起浏览器登录 {provider: qoder / qoder-cn / workbuddy / workbuddy-intl / zcode-bigmodel / zcode-zai / minimax}
|
|
22
22
|
* POST /api/auth/device/poll 轮询浏览器登录结果 {sessionId}
|
|
23
23
|
* GET /api/local/detect 检测本机 Qoder 客户端 / IDE / CLI
|
|
24
24
|
* POST /api/local/scan 读取本机已登录账号(解密客户端凭据 + 扫描旧版 IDE)
|
|
@@ -48,7 +48,7 @@ import path from 'node:path';
|
|
|
48
48
|
import { fileURLToPath } from 'node:url';
|
|
49
49
|
import { logger, getLogs } from './logger.js';
|
|
50
50
|
import {
|
|
51
|
-
loadAccounts, loadState, withAccounts, publicAccount, dataDir, loadSettings, saveSettings,
|
|
51
|
+
loadAccounts, loadState, withAccounts, publicAccount, dataDir, loadSettings, saveSettings, withSettings,
|
|
52
52
|
} from './store.js';
|
|
53
53
|
import { addAccount, importAccounts, refreshContext } from './accounts.js';
|
|
54
54
|
import { productImpl } from './providers.js';
|
|
@@ -57,6 +57,9 @@ import { liveToAccount as zcodeLiveAccount, switchTo as zcodeSwitchTo, currentZc
|
|
|
57
57
|
import { liveToAccount as mirasimLiveAccount, switchTo as mirasimSwitchTo, currentMirasimUid, detectMirasim, terminateMirasim, mirasimRunning } from './mirasimLocal.js';
|
|
58
58
|
import { liveToAccount as catpawLiveAccount, switchTo as catpawSwitchTo, currentCatpawToken, detectCatpaw, terminateCatpaw } from './catpawLocal.js';
|
|
59
59
|
import { liveToAccount as traeLiveAccount, detectTrae, switchTo as traeSwitchTo, snapshotLive as traeSnapshotLive } from './traeLocal.js';
|
|
60
|
+
import { liveToAccount as minimaxLiveAccount, detectMiniMax, currentMiniMaxUid, currentMiniMaxToken, currentMiniMaxRecordKey } from './minimaxLocal.js';
|
|
61
|
+
import * as minimaxGateway from './minimaxGateway.js';
|
|
62
|
+
import * as traeGateway from './traeGateway.js';
|
|
60
63
|
import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, autoClaimUntil, setAutoClaimEnabled, claimIntervalMin, setClaimIntervalMin, claimWindowMin, setClaimWindowMin } from './zcodeClient.js';
|
|
61
64
|
import { exportAccounts, parseImport, TransferError } from './transfer.js';
|
|
62
65
|
import { syncAccountsTo10r } from './tenrouterAccounts.js';
|
|
@@ -69,7 +72,7 @@ import { startDeviceFlow, pollDeviceFlow, LOGIN_KINDS } from './authDevice.js';
|
|
|
69
72
|
import { detectInstalls, scanLocalTokens, putCandidate, peekCandidate } from './localDetect.js';
|
|
70
73
|
import { PROVIDER_LABEL, APP_VERSION, PROVIDERS, PROJECT_URL } from './constants.js';
|
|
71
74
|
|
|
72
|
-
const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae'];
|
|
75
|
+
const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'minimax'];
|
|
73
76
|
|
|
74
77
|
/** 面板访问密码:面板「设置」写入的 settings.json.panelKey 优先(可设 / 可关);
|
|
75
78
|
* 未设置(或被面板关闭)时回退环境变量 CREDITDADDY_PASSWORD(fnOS / 命令行部署注入),
|
|
@@ -383,6 +386,52 @@ async function handleApi(req, res, url) {
|
|
|
383
386
|
}
|
|
384
387
|
return json(res, 200, await zcodeGateway.gatewayStatus());
|
|
385
388
|
}
|
|
389
|
+
// MiniMax 本地网关控制
|
|
390
|
+
if (p === '/api/minimax-gateway' && method === 'GET') {
|
|
391
|
+
return json(res, 200, await minimaxGateway.gatewayStatus());
|
|
392
|
+
}
|
|
393
|
+
if (p === '/api/minimax-gateway' && method === 'PUT') {
|
|
394
|
+
const body = await readBody(req).catch(() => ({}));
|
|
395
|
+
if (body?.enabled !== undefined) await minimaxGateway.setGatewayEnabled(body.enabled === true);
|
|
396
|
+
if (body?.lan !== undefined || body?.allow !== undefined) {
|
|
397
|
+
const patch = {};
|
|
398
|
+
if (body?.lan !== undefined) patch.minimaxGatewayLan = body.lan === true;
|
|
399
|
+
if (body?.allow !== undefined) {
|
|
400
|
+
if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
|
|
401
|
+
const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
|
|
402
|
+
if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
|
|
403
|
+
patch.minimaxGatewayAllow = cleaned;
|
|
404
|
+
}
|
|
405
|
+
await (await import('./store.js')).saveSettings(patch);
|
|
406
|
+
const lanNow = patch.minimaxGatewayLan ?? (await (await import('./store.js')).loadSettings()).minimaxGatewayLan === true;
|
|
407
|
+
const allowText = patch.minimaxGatewayAllow ? patch.minimaxGatewayAllow.join(', ') : '';
|
|
408
|
+
logger.info('DAEMON', `MiniMax 网关局域网:${lanNow ? '开' : '关(仅本机)'}${allowText ? `,白名单:${allowText}` : ''}(绑定改动重启后生效)`);
|
|
409
|
+
}
|
|
410
|
+
return json(res, 200, await minimaxGateway.gatewayStatus());
|
|
411
|
+
}
|
|
412
|
+
// Trae 本地网关控制
|
|
413
|
+
if (p === '/api/trae-gateway' && method === 'GET') {
|
|
414
|
+
return json(res, 200, await traeGateway.gatewayStatus());
|
|
415
|
+
}
|
|
416
|
+
if (p === '/api/trae-gateway' && method === 'PUT') {
|
|
417
|
+
const body = await readBody(req).catch(() => ({}));
|
|
418
|
+
if (body?.enabled !== undefined) await traeGateway.setGatewayEnabled(body.enabled === true);
|
|
419
|
+
if (body?.lan !== undefined || body?.allow !== undefined) {
|
|
420
|
+
const patch = {};
|
|
421
|
+
if (body?.lan !== undefined) patch.traeGatewayLan = body.lan === true;
|
|
422
|
+
if (body?.allow !== undefined) {
|
|
423
|
+
if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
|
|
424
|
+
const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
|
|
425
|
+
if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
|
|
426
|
+
patch.traeGatewayAllow = cleaned;
|
|
427
|
+
}
|
|
428
|
+
await (await import('./store.js')).saveSettings(patch);
|
|
429
|
+
const lanNow = patch.traeGatewayLan ?? (await (await import('./store.js')).loadSettings()).traeGatewayLan === true;
|
|
430
|
+
const allowText = patch.traeGatewayAllow ? patch.traeGatewayAllow.join(', ') : '';
|
|
431
|
+
logger.info('DAEMON', `Trae 网关局域网:${lanNow ? '开' : '关(仅本机)'}${allowText ? `,白名单:${allowText}` : ''}(绑定改动重启后生效)`);
|
|
432
|
+
}
|
|
433
|
+
return json(res, 200, await traeGateway.gatewayStatus());
|
|
434
|
+
}
|
|
386
435
|
if (p === '/api/zcode/net' && method === 'GET') {
|
|
387
436
|
const u = proxyUrl();
|
|
388
437
|
const masked = u ? (() => { try { const x = new URL(u); x.password = x.password ? '*'.repeat(4) : ''; return x.toString(); } catch { return '***'; } })() : null;
|
|
@@ -598,8 +647,12 @@ async function handleApi(req, res, url) {
|
|
|
598
647
|
});
|
|
599
648
|
if (!bound) {
|
|
600
649
|
// 登录窗口可能先于设备码入库关闭:暂存待绑定会话,addAccount 补全 uid 后自动挂上
|
|
601
|
-
|
|
602
|
-
|
|
650
|
+
await withSettings((settings) => {
|
|
651
|
+
settings.pendingQoderWebSessions = {
|
|
652
|
+
...(settings.pendingQoderWebSessions || {}),
|
|
653
|
+
[probe.user_id]: { kind, cookie, capturedAt: now },
|
|
654
|
+
};
|
|
655
|
+
});
|
|
603
656
|
logger.info('DAEMON', `收到 Qoder 网页会话(uid ${probe.user_id.slice(0, 8)}…),暂无匹配账号,已暂存待绑定`);
|
|
604
657
|
} else {
|
|
605
658
|
logger.info('DAEMON', `已绑定 Qoder 网页会话到 ${bound} 个账号(逐资源包明细可用)`);
|
|
@@ -609,7 +662,7 @@ async function handleApi(req, res, url) {
|
|
|
609
662
|
|
|
610
663
|
// ── 本机检测 / 凭据扫描 ──
|
|
611
664
|
if (p === '/api/local/detect' && method === 'GET') {
|
|
612
|
-
return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), catpaw: detectCatpaw(), trae: detectTrae(), legacy: detectInstalls() });
|
|
665
|
+
return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), catpaw: detectCatpaw(), trae: detectTrae(), minimax: detectMiniMax(), legacy: detectInstalls() });
|
|
613
666
|
}
|
|
614
667
|
if (p === '/api/local/scan' && method === 'POST') {
|
|
615
668
|
const existing = await loadAccounts();
|
|
@@ -669,7 +722,12 @@ async function handleApi(req, res, url) {
|
|
|
669
722
|
const tr = await traeLiveAccount();
|
|
670
723
|
if (tr) addRecord(tr, { source: 'Trae 当前登录', current: true });
|
|
671
724
|
} catch (e) { errors.push({ file: 'TRAE SOLO CN/User/globalStorage/storage.json', error: e.message }); }
|
|
672
|
-
// 7)
|
|
725
|
+
// 7) MiniMax 客户端:读取 ~/.minimax 凭据当前登录
|
|
726
|
+
try {
|
|
727
|
+
const mm = await minimaxLiveAccount();
|
|
728
|
+
if (mm) addRecord(mm, { source: 'MiniMax 当前登录', current: true });
|
|
729
|
+
} catch (e) { errors.push({ file: '~/.minimax/auth/.../auth.json', error: e.message }); }
|
|
730
|
+
// 8) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
|
|
673
731
|
const det = detectInstalls();
|
|
674
732
|
const dirs = det.ideDataDirs.filter(d => d.exists).map(d => d.path);
|
|
675
733
|
if (det.cliDir.exists) dirs.push(det.cliDir.path);
|
|
@@ -760,9 +818,10 @@ async function handleApi(req, res, url) {
|
|
|
760
818
|
} catch (e) { return json(res, 500, { error: e.message }); }
|
|
761
819
|
}
|
|
762
820
|
|
|
763
|
-
//
|
|
821
|
+
// 日志(tag 过滤:?tag=MINIMAX-GW / ZCODE-GW 取网关调用日志,与运行日志分开展示)
|
|
764
822
|
if (p === '/api/logs' && method === 'GET') {
|
|
765
|
-
|
|
823
|
+
const tag = url.searchParams.get('tag') || null;
|
|
824
|
+
return json(res, 200, { logs: getLogs(200, tag) });
|
|
766
825
|
}
|
|
767
826
|
|
|
768
827
|
// 状态
|
|
@@ -786,6 +845,17 @@ async function handleApi(req, res, url) {
|
|
|
786
845
|
}
|
|
787
846
|
// 妙手凭据文件只存 token:当前登录按 token 对齐账号库,避免每轮状态都打网关查 uid
|
|
788
847
|
const cpToken = currentCatpawToken();
|
|
848
|
+
const mmToken = currentMiniMaxToken();
|
|
849
|
+
const mmRecKey = currentMiniMaxRecordKey();
|
|
850
|
+
// 1) token match;2) record-key match (token 轮换时仍稳定);3) uid-cache fallback
|
|
851
|
+
let mmLiveUid = null;
|
|
852
|
+
if (mmToken) {
|
|
853
|
+
mmLiveUid = accounts.find((a) => a.provider === 'minimax' && a.token === mmToken)?.uid;
|
|
854
|
+
}
|
|
855
|
+
if (!mmLiveUid && mmRecKey) {
|
|
856
|
+
mmLiveUid = accounts.find((a) => a.provider === 'minimax' && a.meta?.authRecordKey === mmRecKey)?.uid;
|
|
857
|
+
}
|
|
858
|
+
if (!mmLiveUid) mmLiveUid = currentMiniMaxUid();
|
|
789
859
|
const traeDet = detectTrae();
|
|
790
860
|
return json(res, 200, {
|
|
791
861
|
ok: true,
|
|
@@ -796,6 +866,12 @@ async function handleApi(req, res, url) {
|
|
|
796
866
|
dataDir: dataDir(),
|
|
797
867
|
today: dayKey(),
|
|
798
868
|
todayDone: state?.qoderDailyDone || {},
|
|
869
|
+
todayByProduct: {
|
|
870
|
+
qoder: dayKey(Date.now(), 'qoder'),
|
|
871
|
+
workbuddy: dayKey(Date.now(), 'workbuddy'),
|
|
872
|
+
trae: dayKey(Date.now(), 'trae'),
|
|
873
|
+
minimax: dayKey(Date.now(), 'minimax'),
|
|
874
|
+
},
|
|
799
875
|
scheduler: getSchedulerInfo(),
|
|
800
876
|
riskIdentity: riskIdentityAvailable(),
|
|
801
877
|
riskSource: riskIdentitySource(),
|
|
@@ -813,6 +889,8 @@ async function handleApi(req, res, url) {
|
|
|
813
889
|
catpawClient: (() => { const d = detectCatpaw(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
|
|
814
890
|
traeCurrentUid: traeDet.uid,
|
|
815
891
|
traeClient: { installed: traeDet.clientInstalled, signedIn: traeDet.signedIn, running: traeDet.running },
|
|
892
|
+
minimaxCurrentUid: mmLiveUid,
|
|
893
|
+
minimaxClient: (() => { const d = detectMiniMax(); return { installed: d.installed, signedIn: d.signedIn, running: d.running }; })(),
|
|
816
894
|
keyRequired: Boolean(PANEL_KEY),
|
|
817
895
|
});
|
|
818
896
|
}
|
|
@@ -859,9 +937,37 @@ async function handleApi(req, res, url) {
|
|
|
859
937
|
return json(res, 200, { added: r.added, updated: r.updated, skipped, source: parsed.source });
|
|
860
938
|
}
|
|
861
939
|
|
|
940
|
+
// 重启服务(桌面客户端或 CLI)
|
|
941
|
+
if (p === '/api/system/restart' && method === 'POST') {
|
|
942
|
+
json(res, 200, { ok: true, message: '正在重启服务…' });
|
|
943
|
+
setTimeout(async () => {
|
|
944
|
+
try {
|
|
945
|
+
if (restartHandler) {
|
|
946
|
+
await restartHandler();
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
// Node CLI 环境兜底重启:派生自身并退出当前进程
|
|
950
|
+
const { spawn } = await import('node:child_process');
|
|
951
|
+
spawn(process.execPath, process.argv.slice(1), {
|
|
952
|
+
detached: true,
|
|
953
|
+
stdio: 'inherit',
|
|
954
|
+
env: process.env,
|
|
955
|
+
}).unref();
|
|
956
|
+
process.exit(0);
|
|
957
|
+
} catch (err) {
|
|
958
|
+
logger.error('DAEMON', `自动重启失败: ${err?.message || err}`);
|
|
959
|
+
}
|
|
960
|
+
}, 400);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
862
964
|
return json(res, 404, { error: '未知接口' });
|
|
863
965
|
}
|
|
864
966
|
|
|
967
|
+
let restartHandler = null;
|
|
968
|
+
/** 设置外部重启处理器(桌面版 Electron 注入 app.relaunch / app.exit) */
|
|
969
|
+
export function setRestartHandler(fn) { restartHandler = typeof fn === 'function' ? fn : null; }
|
|
970
|
+
|
|
865
971
|
/** 当前生效的面板访问密码(同进程宿主——桌面壳——调用本机 API 时带上 x-qd-key 头) */
|
|
866
972
|
export function getPanelKey() { return PANEL_KEY; }
|
|
867
973
|
|
|
@@ -879,11 +985,25 @@ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
|
|
|
879
985
|
return json(res, 403, { error: denied });
|
|
880
986
|
}
|
|
881
987
|
// ZCode 免费额度网关(数据面,先于面板路由;仅本机回路,见 zcodeGateway.js)。
|
|
882
|
-
// /v1/messages
|
|
988
|
+
// 规范路径带品牌段 /gateway/zcode/v1/messages,与 minimax/trae 网关命名一致,也是
|
|
989
|
+
// gatewayStatus() 展示的 endpoint。/gateway/v1/messages 与 /v1/messages 为旧别名,
|
|
990
|
+
// 保留向后兼容:/v1/messages 与 zcode-api 端点同形,10router 的 zcode-free 供应商
|
|
883
991
|
// 只需换 host:port 即可在 CreditDaddy 网关与 zcode-api 之间切换。
|
|
884
|
-
if (
|
|
992
|
+
if (
|
|
993
|
+
url.pathname === '/gateway/zcode/v1/messages'
|
|
994
|
+
|| url.pathname === '/gateway/v1/messages'
|
|
995
|
+
|| url.pathname === '/v1/messages'
|
|
996
|
+
) {
|
|
885
997
|
return await zcodeGateway.handleGateway(req, res);
|
|
886
998
|
}
|
|
999
|
+
// MiniMax Code 本地 Anthropic 兼容网关
|
|
1000
|
+
if (url.pathname === '/gateway/minimax/v1/messages') {
|
|
1001
|
+
return await minimaxGateway.handleGateway(req, res);
|
|
1002
|
+
}
|
|
1003
|
+
// Trae SOLO 本地 Anthropic 兼容网关
|
|
1004
|
+
if (url.pathname === '/gateway/trae/v1/messages') {
|
|
1005
|
+
return await traeGateway.handleGateway(req, res);
|
|
1006
|
+
}
|
|
887
1007
|
if (url.pathname.startsWith('/api/')) {
|
|
888
1008
|
return await handleApi(req, res, url);
|
|
889
1009
|
}
|
|
@@ -902,7 +1022,7 @@ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
|
|
|
902
1022
|
}
|
|
903
1023
|
});
|
|
904
1024
|
|
|
905
|
-
return new Promise((resolve) => {
|
|
1025
|
+
return new Promise((resolve, reject) => {
|
|
906
1026
|
let attempts = 0;
|
|
907
1027
|
const bind = (p) => {
|
|
908
1028
|
// 失败重试时必须摘掉上一次的 listening 监听,否则成功后会重复触发
|
|
@@ -914,7 +1034,11 @@ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
|
|
|
914
1034
|
server.close();
|
|
915
1035
|
setTimeout(() => bind(p + 1), 300);
|
|
916
1036
|
} else {
|
|
917
|
-
|
|
1037
|
+
// 兜底失败必须 reject:否则这个 promise 永远挂着,调用方的 startScheduler() 不会跑,
|
|
1038
|
+
// 进程既不监听也不退出,只在日志里留一行,看起来像「启动了但没反应」
|
|
1039
|
+
const msg = err?.message || String(err);
|
|
1040
|
+
logger.error('DAEMON', `监听失败:${msg}`);
|
|
1041
|
+
reject(new Error(`无法监听 ${host}:${p}:${msg}`));
|
|
918
1042
|
}
|
|
919
1043
|
};
|
|
920
1044
|
const onListening = () => {
|
package/src/localDetect.js
CHANGED
|
@@ -1,147 +1,147 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本机 Qoder 检测 — 安装探测 + 凭据扫描。
|
|
3
|
-
*
|
|
4
|
-
* Qoder IDE 是 VS Code 系(Electron),凭据散落在 userData 目录;
|
|
5
|
-
* 另扫 ~/.qoder(CLI)。扫描纯本地进行,候选 token 只保留在内存,
|
|
6
|
-
* 面板按 candidateId 导入,token 不在日志中输出。
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import fs from 'node:fs';
|
|
10
|
-
import fsp from 'node:fs/promises';
|
|
11
|
-
import path from 'node:path';
|
|
12
|
-
import os from 'node:os';
|
|
13
|
-
import crypto from 'node:crypto';
|
|
14
|
-
|
|
15
|
-
// 候选内存表:candidateId → { token, file?, provider?, user?, refreshToken?, expiresAt?, at }(10 分钟过期)
|
|
16
|
-
const candidates = new Map();
|
|
17
|
-
const CAND_TTL_MS = 10 * 60 * 1000;
|
|
18
|
-
|
|
19
|
-
const TOKEN_RE = /\b(?:dt|pt)-[A-Za-z0-9_-]{16,}\b/g;
|
|
20
|
-
|
|
21
|
-
/** 纯函数:提取文本中的 Qoder token(去重)——供测试 */
|
|
22
|
-
export function extractTokens(text) {
|
|
23
|
-
const found = new Set();
|
|
24
|
-
let m;
|
|
25
|
-
TOKEN_RE.lastIndex = 0;
|
|
26
|
-
while ((m = TOKEN_RE.exec(String(text))) !== null) found.add(m[0]);
|
|
27
|
-
return [...found];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function mask(token) {
|
|
31
|
-
return token.slice(0, 5) + '...' + token.slice(-4);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function isWindows() { return process.platform === 'win32'; }
|
|
35
|
-
function isMac() { return process.platform === 'darwin'; }
|
|
36
|
-
|
|
37
|
-
/** 安装探测:IDE 可执行文件 + CLI */
|
|
38
|
-
export function detectInstalls() {
|
|
39
|
-
const home = os.homedir();
|
|
40
|
-
const appdata = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
41
|
-
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
42
|
-
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
|
43
|
-
|
|
44
|
-
const idePaths = isMac()
|
|
45
|
-
? ['/Applications/Qoder.app']
|
|
46
|
-
: isWindows()
|
|
47
|
-
? [
|
|
48
|
-
path.join(localAppData, 'Programs', 'Qoder', 'Qoder.exe'),
|
|
49
|
-
path.join(programFiles, 'Qoder', 'Qoder.exe'),
|
|
50
|
-
]
|
|
51
|
-
: ['/usr/bin/qoder', '/opt/Qoder/qoder', '/usr/share/qoder/qoder'];
|
|
52
|
-
|
|
53
|
-
const ideDataDirs = isMac()
|
|
54
|
-
? [path.join(home, 'Library', 'Application Support', 'Qoder')]
|
|
55
|
-
: isWindows()
|
|
56
|
-
? [path.join(appdata, 'Qoder')]
|
|
57
|
-
: [path.join(home, '.config', 'Qoder')];
|
|
58
|
-
|
|
59
|
-
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
platform: process.platform,
|
|
63
|
-
ide: idePaths.map((p) => ({ path: p, exists: exists(p) })),
|
|
64
|
-
ideDataDirs: ideDataDirs.map((p) => ({ path: p, exists: exists(p) })),
|
|
65
|
-
cliDir: { path: path.join(home, '.qoder'), exists: exists(path.join(home, '.qoder')) },
|
|
66
|
-
installed: idePaths.some((p) => exists(p)),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 扫描时跳过的目录(缓存/噪音)
|
|
71
|
-
const SKIP_DIRS = new Set([
|
|
72
|
-
'node_modules', '.git', 'Cache', 'CachedData', 'CacheStorage', 'Code Cache',
|
|
73
|
-
'GPUCache', 'logs', 'Crashes', 'crashpad', 'blob_storage', 'Service Worker',
|
|
74
|
-
'History', 'DawnGraphiteCache', 'DawnWebGPUCache', 'SharedDictionary',
|
|
75
|
-
'index', 'tmp', 'temp', 'workspaceStorage',
|
|
76
|
-
]);
|
|
77
|
-
|
|
78
|
-
const MAX_FILE_BYTES = 1.5 * 1024 * 1024;
|
|
79
|
-
const MAX_FILES = 4000;
|
|
80
|
-
const MAX_MS = 6000;
|
|
81
|
-
|
|
82
|
-
/** 扫描目录集合,产出候选 token(含少量上下文以辅助辨认) */
|
|
83
|
-
export async function scanLocalTokens(dirs) {
|
|
84
|
-
const deadline = Date.now() + MAX_MS;
|
|
85
|
-
const found = new Map(); // token → { file, count }
|
|
86
|
-
let scanned = 0;
|
|
87
|
-
|
|
88
|
-
async function walk(dir, depth) {
|
|
89
|
-
if (depth > 5 || scanned >= MAX_FILES || Date.now() > deadline) return;
|
|
90
|
-
let entries;
|
|
91
|
-
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
92
|
-
for (const e of entries) {
|
|
93
|
-
if (scanned >= MAX_FILES || Date.now() > deadline) return;
|
|
94
|
-
const p = path.join(dir, e.name);
|
|
95
|
-
if (e.isDirectory()) {
|
|
96
|
-
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) await walk(p, depth + 1);
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
let st;
|
|
100
|
-
try { st = await fsp.stat(p); } catch { continue; }
|
|
101
|
-
if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) continue;
|
|
102
|
-
scanned++;
|
|
103
|
-
let text;
|
|
104
|
-
try { text = await fsp.readFile(p, 'utf8'); } catch { continue; }
|
|
105
|
-
for (const token of extractTokens(text)) {
|
|
106
|
-
const prev = found.get(token);
|
|
107
|
-
if (prev) prev.count++;
|
|
108
|
-
else found.set(token, { file: p, count: 1 });
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
for (const d of dirs) {
|
|
114
|
-
try { if (fs.existsSync(d) && fs.statSync(d).isDirectory()) await walk(d, 0); } catch {}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const list = [];
|
|
118
|
-
for (const [token, meta] of found) {
|
|
119
|
-
const id = putCandidate({ token, file: meta.file });
|
|
120
|
-
list.push({
|
|
121
|
-
id,
|
|
122
|
-
tokenMasked: mask(token),
|
|
123
|
-
kind: token.startsWith('pt-') ? 'PAT' : 'device',
|
|
124
|
-
file: meta.file,
|
|
125
|
-
hits: meta.count,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
list.sort((a, b) => b.hits - a.hits);
|
|
129
|
-
return { scanned, candidates: list.slice(0, 50) };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** 放入候选表(带 TTL),返回 candidateId。c 至少含 token,可带 provider / user / refreshToken / expiresAt */
|
|
133
|
-
export function putCandidate(c) {
|
|
134
|
-
const now = Date.now();
|
|
135
|
-
for (const [k, v] of candidates) if (v.at < now - CAND_TTL_MS) candidates.delete(k);
|
|
136
|
-
const id = crypto.createHash('sha256').update(c.token).digest('hex').slice(0, 16);
|
|
137
|
-
candidates.set(id, { ...c, at: now });
|
|
138
|
-
return id;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** 按 candidateId 读取候选(不会消费掉,导入前可反复查询);过期返回 null */
|
|
142
|
-
export function peekCandidate(id) {
|
|
143
|
-
const c = candidates.get(id);
|
|
144
|
-
if (!c) return null;
|
|
145
|
-
if (c.at < Date.now() - CAND_TTL_MS) { candidates.delete(id); return null; }
|
|
146
|
-
return c;
|
|
147
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 本机 Qoder 检测 — 安装探测 + 凭据扫描。
|
|
3
|
+
*
|
|
4
|
+
* Qoder IDE 是 VS Code 系(Electron),凭据散落在 userData 目录;
|
|
5
|
+
* 另扫 ~/.qoder(CLI)。扫描纯本地进行,候选 token 只保留在内存,
|
|
6
|
+
* 面板按 candidateId 导入,token 不在日志中输出。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import fsp from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
// 候选内存表:candidateId → { token, file?, provider?, user?, refreshToken?, expiresAt?, at }(10 分钟过期)
|
|
16
|
+
const candidates = new Map();
|
|
17
|
+
const CAND_TTL_MS = 10 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
const TOKEN_RE = /\b(?:dt|pt)-[A-Za-z0-9_-]{16,}\b/g;
|
|
20
|
+
|
|
21
|
+
/** 纯函数:提取文本中的 Qoder token(去重)——供测试 */
|
|
22
|
+
export function extractTokens(text) {
|
|
23
|
+
const found = new Set();
|
|
24
|
+
let m;
|
|
25
|
+
TOKEN_RE.lastIndex = 0;
|
|
26
|
+
while ((m = TOKEN_RE.exec(String(text))) !== null) found.add(m[0]);
|
|
27
|
+
return [...found];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mask(token) {
|
|
31
|
+
return token.slice(0, 5) + '...' + token.slice(-4);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isWindows() { return process.platform === 'win32'; }
|
|
35
|
+
function isMac() { return process.platform === 'darwin'; }
|
|
36
|
+
|
|
37
|
+
/** 安装探测:IDE 可执行文件 + CLI */
|
|
38
|
+
export function detectInstalls() {
|
|
39
|
+
const home = os.homedir();
|
|
40
|
+
const appdata = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
41
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
|
|
42
|
+
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
|
|
43
|
+
|
|
44
|
+
const idePaths = isMac()
|
|
45
|
+
? ['/Applications/Qoder.app']
|
|
46
|
+
: isWindows()
|
|
47
|
+
? [
|
|
48
|
+
path.join(localAppData, 'Programs', 'Qoder', 'Qoder.exe'),
|
|
49
|
+
path.join(programFiles, 'Qoder', 'Qoder.exe'),
|
|
50
|
+
]
|
|
51
|
+
: ['/usr/bin/qoder', '/opt/Qoder/qoder', '/usr/share/qoder/qoder'];
|
|
52
|
+
|
|
53
|
+
const ideDataDirs = isMac()
|
|
54
|
+
? [path.join(home, 'Library', 'Application Support', 'Qoder')]
|
|
55
|
+
: isWindows()
|
|
56
|
+
? [path.join(appdata, 'Qoder')]
|
|
57
|
+
: [path.join(home, '.config', 'Qoder')];
|
|
58
|
+
|
|
59
|
+
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
platform: process.platform,
|
|
63
|
+
ide: idePaths.map((p) => ({ path: p, exists: exists(p) })),
|
|
64
|
+
ideDataDirs: ideDataDirs.map((p) => ({ path: p, exists: exists(p) })),
|
|
65
|
+
cliDir: { path: path.join(home, '.qoder'), exists: exists(path.join(home, '.qoder')) },
|
|
66
|
+
installed: idePaths.some((p) => exists(p)),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 扫描时跳过的目录(缓存/噪音)
|
|
71
|
+
const SKIP_DIRS = new Set([
|
|
72
|
+
'node_modules', '.git', 'Cache', 'CachedData', 'CacheStorage', 'Code Cache',
|
|
73
|
+
'GPUCache', 'logs', 'Crashes', 'crashpad', 'blob_storage', 'Service Worker',
|
|
74
|
+
'History', 'DawnGraphiteCache', 'DawnWebGPUCache', 'SharedDictionary',
|
|
75
|
+
'index', 'tmp', 'temp', 'workspaceStorage',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const MAX_FILE_BYTES = 1.5 * 1024 * 1024;
|
|
79
|
+
const MAX_FILES = 4000;
|
|
80
|
+
const MAX_MS = 6000;
|
|
81
|
+
|
|
82
|
+
/** 扫描目录集合,产出候选 token(含少量上下文以辅助辨认) */
|
|
83
|
+
export async function scanLocalTokens(dirs) {
|
|
84
|
+
const deadline = Date.now() + MAX_MS;
|
|
85
|
+
const found = new Map(); // token → { file, count }
|
|
86
|
+
let scanned = 0;
|
|
87
|
+
|
|
88
|
+
async function walk(dir, depth) {
|
|
89
|
+
if (depth > 5 || scanned >= MAX_FILES || Date.now() > deadline) return;
|
|
90
|
+
let entries;
|
|
91
|
+
try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
92
|
+
for (const e of entries) {
|
|
93
|
+
if (scanned >= MAX_FILES || Date.now() > deadline) return;
|
|
94
|
+
const p = path.join(dir, e.name);
|
|
95
|
+
if (e.isDirectory()) {
|
|
96
|
+
if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) await walk(p, depth + 1);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
let st;
|
|
100
|
+
try { st = await fsp.stat(p); } catch { continue; }
|
|
101
|
+
if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) continue;
|
|
102
|
+
scanned++;
|
|
103
|
+
let text;
|
|
104
|
+
try { text = await fsp.readFile(p, 'utf8'); } catch { continue; }
|
|
105
|
+
for (const token of extractTokens(text)) {
|
|
106
|
+
const prev = found.get(token);
|
|
107
|
+
if (prev) prev.count++;
|
|
108
|
+
else found.set(token, { file: p, count: 1 });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const d of dirs) {
|
|
114
|
+
try { if (fs.existsSync(d) && fs.statSync(d).isDirectory()) await walk(d, 0); } catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const list = [];
|
|
118
|
+
for (const [token, meta] of found) {
|
|
119
|
+
const id = putCandidate({ token, file: meta.file });
|
|
120
|
+
list.push({
|
|
121
|
+
id,
|
|
122
|
+
tokenMasked: mask(token),
|
|
123
|
+
kind: token.startsWith('pt-') ? 'PAT' : 'device',
|
|
124
|
+
file: meta.file,
|
|
125
|
+
hits: meta.count,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
list.sort((a, b) => b.hits - a.hits);
|
|
129
|
+
return { scanned, candidates: list.slice(0, 50) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 放入候选表(带 TTL),返回 candidateId。c 至少含 token,可带 provider / user / refreshToken / expiresAt */
|
|
133
|
+
export function putCandidate(c) {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
for (const [k, v] of candidates) if (v.at < now - CAND_TTL_MS) candidates.delete(k);
|
|
136
|
+
const id = crypto.createHash('sha256').update(c.token).digest('hex').slice(0, 16);
|
|
137
|
+
candidates.set(id, { ...c, at: now });
|
|
138
|
+
return id;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 按 candidateId 读取候选(不会消费掉,导入前可反复查询);过期返回 null */
|
|
142
|
+
export function peekCandidate(id) {
|
|
143
|
+
const c = candidates.get(id);
|
|
144
|
+
if (!c) return null;
|
|
145
|
+
if (c.at < Date.now() - CAND_TTL_MS) { candidates.delete(id); return null; }
|
|
146
|
+
return c;
|
|
147
|
+
}
|