creditdaddy 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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,8 @@ 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';
60
62
  import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, autoClaimUntil, setAutoClaimEnabled, claimIntervalMin, setClaimIntervalMin, claimWindowMin, setClaimWindowMin } from './zcodeClient.js';
61
63
  import { exportAccounts, parseImport, TransferError } from './transfer.js';
62
64
  import { syncAccountsTo10r } from './tenrouterAccounts.js';
@@ -69,7 +71,7 @@ import { startDeviceFlow, pollDeviceFlow, LOGIN_KINDS } from './authDevice.js';
69
71
  import { detectInstalls, scanLocalTokens, putCandidate, peekCandidate } from './localDetect.js';
70
72
  import { PROVIDER_LABEL, APP_VERSION, PROVIDERS, PROJECT_URL } from './constants.js';
71
73
 
72
- const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae'];
74
+ const PRODUCT_IDS = ['qoder', 'workbuddy', 'zcode', 'mirasim', 'catpaw', 'trae', 'minimax'];
73
75
 
74
76
  /** 面板访问密码:面板「设置」写入的 settings.json.panelKey 优先(可设 / 可关);
75
77
  * 未设置(或被面板关闭)时回退环境变量 CREDITDADDY_PASSWORD(fnOS / 命令行部署注入),
@@ -383,6 +385,29 @@ async function handleApi(req, res, url) {
383
385
  }
384
386
  return json(res, 200, await zcodeGateway.gatewayStatus());
385
387
  }
388
+ // MiniMax 本地网关控制
389
+ if (p === '/api/minimax-gateway' && method === 'GET') {
390
+ return json(res, 200, await minimaxGateway.gatewayStatus());
391
+ }
392
+ if (p === '/api/minimax-gateway' && method === 'PUT') {
393
+ const body = await readBody(req).catch(() => ({}));
394
+ if (body?.enabled !== undefined) await minimaxGateway.setGatewayEnabled(body.enabled === true);
395
+ if (body?.lan !== undefined || body?.allow !== undefined) {
396
+ const patch = {};
397
+ if (body?.lan !== undefined) patch.minimaxGatewayLan = body.lan === true;
398
+ if (body?.allow !== undefined) {
399
+ if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
400
+ const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
401
+ if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
402
+ patch.minimaxGatewayAllow = cleaned;
403
+ }
404
+ await (await import('./store.js')).saveSettings(patch);
405
+ const lanNow = patch.minimaxGatewayLan ?? (await (await import('./store.js')).loadSettings()).minimaxGatewayLan === true;
406
+ const allowText = patch.minimaxGatewayAllow ? patch.minimaxGatewayAllow.join(', ') : '';
407
+ logger.info('DAEMON', `MiniMax 网关局域网:${lanNow ? '开' : '关(仅本机)'}${allowText ? `,白名单:${allowText}` : ''}`);
408
+ }
409
+ return json(res, 200, await minimaxGateway.gatewayStatus());
410
+ }
386
411
  if (p === '/api/zcode/net' && method === 'GET') {
387
412
  const u = proxyUrl();
388
413
  const masked = u ? (() => { try { const x = new URL(u); x.password = x.password ? '*'.repeat(4) : ''; return x.toString(); } catch { return '***'; } })() : null;
@@ -598,8 +623,12 @@ async function handleApi(req, res, url) {
598
623
  });
599
624
  if (!bound) {
600
625
  // 登录窗口可能先于设备码入库关闭:暂存待绑定会话,addAccount 补全 uid 后自动挂上
601
- const pending = (await loadSettings()).pendingQoderWebSessions || {};
602
- await saveSettings({ pendingQoderWebSessions: { ...pending, [probe.user_id]: { kind, cookie, capturedAt: now } } });
626
+ await withSettings((settings) => {
627
+ settings.pendingQoderWebSessions = {
628
+ ...(settings.pendingQoderWebSessions || {}),
629
+ [probe.user_id]: { kind, cookie, capturedAt: now },
630
+ };
631
+ });
603
632
  logger.info('DAEMON', `收到 Qoder 网页会话(uid ${probe.user_id.slice(0, 8)}…),暂无匹配账号,已暂存待绑定`);
604
633
  } else {
605
634
  logger.info('DAEMON', `已绑定 Qoder 网页会话到 ${bound} 个账号(逐资源包明细可用)`);
@@ -609,7 +638,7 @@ async function handleApi(req, res, url) {
609
638
 
610
639
  // ── 本机检测 / 凭据扫描 ──
611
640
  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() });
641
+ return json(res, 200, { apps: detectQoderApps(), workbuddyDir: workbuddyAuthDir(), zcode: detectZcode(), mirasim: detectMirasim(), catpaw: detectCatpaw(), trae: detectTrae(), minimax: detectMiniMax(), legacy: detectInstalls() });
613
642
  }
614
643
  if (p === '/api/local/scan' && method === 'POST') {
615
644
  const existing = await loadAccounts();
@@ -669,7 +698,12 @@ async function handleApi(req, res, url) {
669
698
  const tr = await traeLiveAccount();
670
699
  if (tr) addRecord(tr, { source: 'Trae 当前登录', current: true });
671
700
  } catch (e) { errors.push({ file: 'TRAE SOLO CN/User/globalStorage/storage.json', error: e.message }); }
672
- // 7) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
701
+ // 7) MiniMax 客户端:读取 ~/.minimax 凭据当前登录
702
+ try {
703
+ const mm = await minimaxLiveAccount();
704
+ if (mm) addRecord(mm, { source: 'MiniMax 当前登录', current: true });
705
+ } catch (e) { errors.push({ file: '~/.minimax/auth/.../auth.json', error: e.message }); }
706
+ // 8) 旧版 VS Code 系 Qoder IDE / CLI:明文 token 扫描(归属需用户选择)
673
707
  const det = detectInstalls();
674
708
  const dirs = det.ideDataDirs.filter(d => d.exists).map(d => d.path);
675
709
  if (det.cliDir.exists) dirs.push(det.cliDir.path);
@@ -760,9 +794,10 @@ async function handleApi(req, res, url) {
760
794
  } catch (e) { return json(res, 500, { error: e.message }); }
761
795
  }
762
796
 
763
- // 日志
797
+ // 日志(tag 过滤:?tag=MINIMAX-GW / ZCODE-GW 取网关调用日志,与运行日志分开展示)
764
798
  if (p === '/api/logs' && method === 'GET') {
765
- return json(res, 200, { logs: getLogs(200) });
799
+ const tag = url.searchParams.get('tag') || null;
800
+ return json(res, 200, { logs: getLogs(200, tag) });
766
801
  }
767
802
 
768
803
  // 状态
@@ -786,6 +821,17 @@ async function handleApi(req, res, url) {
786
821
  }
787
822
  // 妙手凭据文件只存 token:当前登录按 token 对齐账号库,避免每轮状态都打网关查 uid
788
823
  const cpToken = currentCatpawToken();
824
+ const mmToken = currentMiniMaxToken();
825
+ const mmRecKey = currentMiniMaxRecordKey();
826
+ // 1) token match;2) record-key match (token 轮换时仍稳定);3) uid-cache fallback
827
+ let mmLiveUid = null;
828
+ if (mmToken) {
829
+ mmLiveUid = accounts.find((a) => a.provider === 'minimax' && a.token === mmToken)?.uid;
830
+ }
831
+ if (!mmLiveUid && mmRecKey) {
832
+ mmLiveUid = accounts.find((a) => a.provider === 'minimax' && a.meta?.authRecordKey === mmRecKey)?.uid;
833
+ }
834
+ if (!mmLiveUid) mmLiveUid = currentMiniMaxUid();
789
835
  const traeDet = detectTrae();
790
836
  return json(res, 200, {
791
837
  ok: true,
@@ -796,6 +842,12 @@ async function handleApi(req, res, url) {
796
842
  dataDir: dataDir(),
797
843
  today: dayKey(),
798
844
  todayDone: state?.qoderDailyDone || {},
845
+ todayByProduct: {
846
+ qoder: dayKey(Date.now(), 'qoder'),
847
+ workbuddy: dayKey(Date.now(), 'workbuddy'),
848
+ trae: dayKey(Date.now(), 'trae'),
849
+ minimax: dayKey(Date.now(), 'minimax'),
850
+ },
799
851
  scheduler: getSchedulerInfo(),
800
852
  riskIdentity: riskIdentityAvailable(),
801
853
  riskSource: riskIdentitySource(),
@@ -813,6 +865,8 @@ async function handleApi(req, res, url) {
813
865
  catpawClient: (() => { const d = detectCatpaw(); return { installed: d.clientInstalled, signedIn: d.signedIn, running: d.running }; })(),
814
866
  traeCurrentUid: traeDet.uid,
815
867
  traeClient: { installed: traeDet.clientInstalled, signedIn: traeDet.signedIn, running: traeDet.running },
868
+ minimaxCurrentUid: mmLiveUid,
869
+ minimaxClient: (() => { const d = detectMiniMax(); return { installed: d.installed, signedIn: d.signedIn, running: d.running }; })(),
816
870
  keyRequired: Boolean(PANEL_KEY),
817
871
  });
818
872
  }
@@ -884,6 +938,10 @@ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
884
938
  if (url.pathname === '/gateway/v1/messages' || url.pathname === '/v1/messages') {
885
939
  return await zcodeGateway.handleGateway(req, res);
886
940
  }
941
+ // MiniMax Code 本地 Anthropic 兼容网关
942
+ if (url.pathname === '/gateway/minimax/v1/messages') {
943
+ return await minimaxGateway.handleGateway(req, res);
944
+ }
887
945
  if (url.pathname.startsWith('/api/')) {
888
946
  return await handleApi(req, res, url);
889
947
  }
@@ -902,7 +960,7 @@ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
902
960
  }
903
961
  });
904
962
 
905
- return new Promise((resolve) => {
963
+ return new Promise((resolve, reject) => {
906
964
  let attempts = 0;
907
965
  const bind = (p) => {
908
966
  // 失败重试时必须摘掉上一次的 listening 监听,否则成功后会重复触发
@@ -914,7 +972,11 @@ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
914
972
  server.close();
915
973
  setTimeout(() => bind(p + 1), 300);
916
974
  } else {
917
- logger.error('DAEMON', `监听失败:${err?.message || err}`);
975
+ // 兜底失败必须 reject:否则这个 promise 永远挂着,调用方的 startScheduler() 不会跑,
976
+ // 进程既不监听也不退出,只在日志里留一行,看起来像「启动了但没反应」
977
+ const msg = err?.message || String(err);
978
+ logger.error('DAEMON', `监听失败:${msg}`);
979
+ reject(new Error(`无法监听 ${host}:${p}:${msg}`));
918
980
  }
919
981
  };
920
982
  const onListening = () => {
@@ -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
+ }
package/src/logger.js CHANGED
@@ -3,10 +3,13 @@
3
3
  const RING_SIZE = 300;
4
4
  const ring = [];
5
5
 
6
- // ─── 按日期归档:logs/daemon-YYYY-MM-DD.log(跨天自动换文件) ───
6
+ // ─── 按日期归档:logs/daemon-YYYY-MM-DD.log(跨天自动换文件,历史自动 gzip) ───
7
7
  import fsSync from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import os from 'node:os';
10
+ import zlib from 'node:zlib';
11
+
12
+ const ARCHIVE_RETENTION_DAYS = 7; // .log.gz 归档保留天数,更早的自动清理
10
13
 
11
14
  let archiveDir = null;
12
15
  try {
@@ -24,7 +27,15 @@ function archiveLine(line) {
24
27
  try {
25
28
  const day = line.at.slice(0, 10);
26
29
  if (archiveDay !== day || !archiveStream) {
27
- if (archiveStream) archiveStream.end();
30
+ if (archiveStream) {
31
+ // 跨天 / 重启:把上一份流关闭,待其落盘完成(close 事件)后再 gzip
32
+ const prev = archiveStream;
33
+ const prevFile = path.join(archiveDir, `daemon-${archiveDay}.log`);
34
+ archiveStream = null;
35
+ prev.on('error', () => {});
36
+ prev.on('close', () => gzipAndCleanup(prevFile));
37
+ prev.end();
38
+ }
28
39
  archiveStream = fsSync.createWriteStream(path.join(archiveDir, `daemon-${day}.log`), { flags: 'a' });
29
40
  archiveDay = day;
30
41
  }
@@ -40,6 +51,41 @@ function ts() {
40
51
  + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
41
52
  }
42
53
 
54
+ /** 把一份已关闭的日志 gzip 成 .log.gz(此时流已 flush、fd 已释放,可安全同步读),并按保留期清理过期归档 */
55
+ function gzipAndCleanup(file) {
56
+ try {
57
+ if (fsSync.existsSync(file)) {
58
+ const raw = fsSync.readFileSync(file);
59
+ if (raw.length) {
60
+ fsSync.writeFileSync(file + '.gz', zlib.gzipSync(raw));
61
+ fsSync.unlinkSync(file);
62
+ }
63
+ }
64
+ } catch {}
65
+ cleanupArchive();
66
+ }
67
+
68
+ /** 清理超过保留期的 .log.gz 归档(以及残留的旧 .log),按文件名里的日期判断,不依赖 mtime */
69
+ function cleanupArchive() {
70
+ if (!archiveDir) return;
71
+ try {
72
+ // 保留期按「文件名日期距今 N 天」算:今天与最近 7 天的归档都保留
73
+ const cutoff = new Date();
74
+ cutoff.setDate(cutoff.getDate() - ARCHIVE_RETENTION_DAYS);
75
+ cutoff.setHours(0, 0, 0, 0);
76
+ for (const name of fsSync.readdirSync(archiveDir)) {
77
+ const m = /^daemon-(\d{4}-\d{2}-\d{2})\.log(\.gz)?$/.exec(name);
78
+ if (!m) continue; // 只动本 daemon 的归档文件,不碰目录里其他文件
79
+ const f = path.join(archiveDir, name);
80
+ let fileDay;
81
+ try { fileDay = new Date(m[1] + 'T00:00:00'); } catch { continue; }
82
+ if (fileDay.getTime() < cutoff.getTime()) {
83
+ try { fsSync.unlinkSync(f); } catch {}
84
+ }
85
+ }
86
+ } catch {}
87
+ }
88
+
43
89
  export function log(level, tag, msg) {
44
90
  const line = { at: ts(), level, tag, msg: String(msg) };
45
91
  ring.push(line);
@@ -56,13 +102,20 @@ export const logger = {
56
102
  debug: (tag, msg) => log('debug', tag, msg),
57
103
  };
58
104
 
59
- export function getLogs(limit = 100) {
60
- return ring.slice(-limit);
105
+ export function getLogs(limit = 100, tag = null) {
106
+ const lines = tag ? ring.filter((l) => l.tag === tag) : ring;
107
+ return lines.slice(-limit);
61
108
  }
62
109
 
63
- /** 测试收尾用:归档流的句柄不关,测试进程的临时目录在 Windows 上删不掉 */
110
+ /** 测试收尾 / 进程退出用:关闭当前归档流,落盘后 gzip 归档并清理过期文件 */
64
111
  export function closeArchiveStream() {
65
- try { archiveStream?.end(); } catch {}
66
- archiveStream = null;
112
+ if (archiveStream) {
113
+ const prev = archiveStream;
114
+ const prevFile = path.join(archiveDir, `daemon-${archiveDay}.log`);
115
+ archiveStream = null;
116
+ prev.on('error', () => {});
117
+ prev.on('close', () => gzipAndCleanup(prevFile));
118
+ prev.end();
119
+ }
67
120
  archiveDay = null;
68
121
  }