charge-connect 0.1.9 → 0.2.1
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/cli.js +220 -15
- package/collect.js +924 -111
- package/package.json +4 -3
- package/updater.js +967 -0
package/collect.js
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Charge 수집기 — ccusage로 토큰 사용량을 뽑아 Charge 백엔드에 업로드한다.
|
|
3
|
-
// 사용법: node collect.js [--dry-run] [--days N] [--log 경로]
|
|
3
|
+
// 사용법: node collect.js [--dry-run] [--days N] [--log 경로] | node collect.js --self-test (자동 업데이트용 자체 점검)
|
|
4
4
|
// 인증: `npx charge-connect <페어링코드>`가 저장한 ~/.charge/config.json의 디바이스 토큰
|
|
5
5
|
|
|
6
6
|
const { execFile } = require("node:child_process");
|
|
7
7
|
const { promisify } = require("node:util");
|
|
8
8
|
const fs = require("node:fs");
|
|
9
9
|
const path = require("node:path");
|
|
10
|
-
const { unknownAccountKey } = require("./identity.js");
|
|
11
10
|
|
|
12
11
|
const execFileAsync = promisify(execFile);
|
|
13
12
|
|
|
14
13
|
const DRY_RUN = process.argv.includes("--dry-run");
|
|
14
|
+
// 자동 업데이트가 교체 전후에 부르는 자체 점검: 런타임 모듈을 모두 불러오기만 하고 끝낸다 (파일 맨 아래)
|
|
15
|
+
const SELF_TEST = process.argv.includes("--self-test");
|
|
15
16
|
const daysArg = process.argv.indexOf("--days");
|
|
16
17
|
const parsedDays = daysArg > -1 ? parseInt(process.argv[daysArg + 1], 10) : NaN;
|
|
17
18
|
const DAYS = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 60;
|
|
18
19
|
const CACHE_FILE = path.join(__dirname, ".last-payload.json");
|
|
19
20
|
const HEALTH_FILE = path.join(__dirname, ".collection-health.json");
|
|
21
|
+
// Claude usage API 429 게이트와 마지막 usage 요청 시각, 결과 (C1, D12, D13). 프로필 조회로 얻은 계정 해시 (D3)
|
|
22
|
+
const RATE_LIMIT_FILE = path.join(__dirname, ".claude-rate-limit.json");
|
|
23
|
+
const CLAUDE_ACCOUNT_FILE = path.join(__dirname, ".claude-account.json");
|
|
20
24
|
const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
21
25
|
|
|
22
26
|
// 스케줄러가 부를 때는 볼 콘솔이 없으니 출력을 직접 파일로 받는다.
|
|
@@ -24,6 +28,10 @@ const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
|
24
28
|
// 5분마다 도는 작업이라 그냥 이어 붙이면 끝없이 자라므로 5MB에서 .old로 민다.
|
|
25
29
|
const logArg = process.argv.indexOf("--log");
|
|
26
30
|
const LOG_FILE = logArg > -1 ? process.argv[logArg + 1] : null;
|
|
31
|
+
// main의 프라미스 밖에서 터진 예외(타이머, 이벤트, 기다리지 않은 거부)로 끝낼 때도 업데이트 확인을 기다리는 최대 시간.
|
|
32
|
+
// 확인은 매니페스트, 다운로드, 검사, 자체 점검까지 합쳐도 이보다 짧다. 5분 주기 안에서 끝낸다.
|
|
33
|
+
const CRASH_UPDATE_CHECK_LIMIT_MS = 180_000;
|
|
34
|
+
let crashExitStarted = false;
|
|
27
35
|
// 아래 로그 리다이렉션·핸들러 등록·process.exit은 스크립트로 직접 실행될 때만 해야 한다.
|
|
28
36
|
// cli.js가 버전 상수를 require할 때 이 부작용이 딸려오면 CLI 콘솔이 파일로 새거나 조기 종료된다.
|
|
29
37
|
if (require.main === module && logArg > -1 && (!LOG_FILE || LOG_FILE.startsWith("-"))) {
|
|
@@ -47,7 +55,7 @@ if (require.main === module && LOG_FILE) {
|
|
|
47
55
|
// 크래시했을 때 로그에 시작 줄만 덩그러니 남아 원인을 알 수 없다.
|
|
48
56
|
const fatal = (label) => (err) => {
|
|
49
57
|
write(`[${new Date().toISOString()}] ${label}: ${err?.stack ?? err}`);
|
|
50
|
-
|
|
58
|
+
exitAfterUpdateCheck();
|
|
51
59
|
};
|
|
52
60
|
process.on("uncaughtException", fatal("치명적 오류"));
|
|
53
61
|
process.on("unhandledRejection", fatal("처리되지 않은 거부"));
|
|
@@ -55,6 +63,54 @@ if (require.main === module && LOG_FILE) {
|
|
|
55
63
|
// install.ps1이 스케줄 작업이 실제로 떴는지 판정하는 근거이기도 하다.
|
|
56
64
|
write(`[${new Date().toISOString()}] 수집 시작`);
|
|
57
65
|
}
|
|
66
|
+
|
|
67
|
+
// 자동 업데이트가 파일 교체 도중 끊겼으면(전원 차단, 작업 강제 종료) 다른 로컬 모듈을 불러오기 전에 app.prev로
|
|
68
|
+
// 되돌린다. 옛 파일과 새 파일이 섞인 채 require하면 바로 아래에서 죽어 다음 업데이트 확인까지 닿지 못한다.
|
|
69
|
+
// 평소에는 표시 파일(updater.js의 UPDATE_MARKER)이 있는지만 본다. 자체 점검은 상태를 건드리지 않으므로 되돌리지
|
|
70
|
+
// 않는다 (설치 직후 점검 중에는 표시가 있는 게 정상이다). updater.js는 node: 내장 모듈만 불러와서 섞인 폴더에서도 뜬다.
|
|
71
|
+
if (require.main === module && !SELF_TEST && fs.existsSync(path.join(__dirname, ".update-in-progress.json"))) {
|
|
72
|
+
let running = null;
|
|
73
|
+
try {
|
|
74
|
+
running = fs.readFileSync(__filename);
|
|
75
|
+
} catch {}
|
|
76
|
+
try {
|
|
77
|
+
const updater = require("./updater.js");
|
|
78
|
+
// 표시를 쓴 업데이트가 아직 살아서 파일을 바꾸는 중이면(겹친 스케줄 실행, 수동 실행, 설치 후 자체 점검 중) 되돌리지 않고
|
|
79
|
+
// 이번 수집만 건너뛴다. 여기서 되돌리면 그 업데이트 밑에서 폴더가 섞이거나, 멀쩡한 릴리스가 자체 점검 실패로 기록된다.
|
|
80
|
+
if (typeof updater.updateInProgress === "function" && updater.updateInProgress({ appDir: __dirname })) {
|
|
81
|
+
console.log("자동 업데이트가 수집기 파일을 교체하는 중이라 이번 수집은 건너뜁니다");
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
updater.recoverInterruptedUpdate({ appDir: __dirname });
|
|
85
|
+
console.error("교체 도중 끊긴 자동 업데이트를 app.prev 백업으로 되돌렸습니다");
|
|
86
|
+
// 지금 도는 collect.js가 새 버전이었다면 되돌린 파일과 내용이 다르다. 이 코드로 계속 돌면 옛 모듈과 섞이므로
|
|
87
|
+
// 되돌린 collect.js를 같은 인자로 다시 실행하고 그 종료 코드로 끝낸다 (드문 복구 경로라 동기 실행을 쓴다).
|
|
88
|
+
if (!running || !running.equals(fs.readFileSync(__filename))) {
|
|
89
|
+
const rerun = require("node:child_process").spawnSync(
|
|
90
|
+
process.execPath,
|
|
91
|
+
[...process.execArgv, __filename, ...process.argv.slice(2)],
|
|
92
|
+
{ stdio: "inherit", windowsHide: true }
|
|
93
|
+
);
|
|
94
|
+
process.exit(rerun.status ?? 1);
|
|
95
|
+
}
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error(`교체 도중 끊긴 자동 업데이트를 되돌리지 못했습니다: ${e?.message ?? e}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const { unknownAccountKey } = require("./identity.js");
|
|
101
|
+
|
|
102
|
+
// collect_status의 "_collector"와 User-Agent에 싣는 버전. 읽지 못하면 싣지 않는다.
|
|
103
|
+
// 끊긴 업데이트를 되돌린 뒤에 읽어야 이번 실행이 실제로 쓰는 파일의 버전이 된다.
|
|
104
|
+
const PACKAGE_VERSION = (() => {
|
|
105
|
+
try {
|
|
106
|
+
const version = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8")).version;
|
|
107
|
+
return typeof version === "string" && /^[0-9A-Za-z.+-]{1,32}$/.test(version) ? version : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
const USER_AGENT = PACKAGE_VERSION ? `charge-connect/${PACKAGE_VERSION}` : "charge-connect";
|
|
113
|
+
|
|
58
114
|
const WIN = process.platform === "win32";
|
|
59
115
|
// 스케줄러(작업 스케줄러/launchd)가 만드는 환경에는 셸 프로필에서 붙는 PATH가 없다.
|
|
60
116
|
// Windows에서 nvm-windows·fnm·volta로 Node를 깐 사용자는 그래서 npx/ccusage를 못 찾는다.
|
|
@@ -141,13 +197,25 @@ function dropExpired(w) {
|
|
|
141
197
|
// {...prev} 전개가 collected_at을 그대로 보존하는 것이 신선도 규칙의 핵심 —
|
|
142
198
|
// 서버는 collected_at이 더 신선한 행만 덮어쓰므로, 토큰이 만료된 기기의 캐시 폴백이
|
|
143
199
|
// 건강한 기기가 방금 올린 값을 덮어쓰지 못한다. collected_at을 여기서 갱신하면 안 된다.
|
|
144
|
-
|
|
200
|
+
// 리셋 시각을 모르는 창은 dropExpired로 절대 만료되지 않아, 수집이 깨진 기기가 몇 주 전 값을
|
|
201
|
+
// 계속 재생한다(앱엔 "0% 사용"처럼 보인다). 관측 후 창 길이(세션 300분, 주간 10080분)가 지났으면
|
|
202
|
+
// 그 창은 이미 한 번 이상 리셋됐다고 보고 버린다. 관측 시각을 모르면 나이를 증명할 수 없어 버린다.
|
|
203
|
+
function dropUnknownResetIfOld(w, collectedAt, fallbackMinutes, now = Date.now()) {
|
|
204
|
+
if (!w || w.resets_at) return w;
|
|
205
|
+
const minutes = Number(w.window_minutes) > 0 ? Number(w.window_minutes) : fallbackMinutes;
|
|
206
|
+
const observed = Date.parse(collectedAt ?? "");
|
|
207
|
+
if (!Number.isFinite(observed)) return null;
|
|
208
|
+
return now - observed > minutes * 60_000 ? null : w;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sanitizeCachedProvider(prev, now = Date.now()) {
|
|
145
212
|
if (!prev || typeof prev !== "object") return null;
|
|
213
|
+
const replay = (w, fallbackMinutes) => dropUnknownResetIfOld(dropExpired(w), prev.collected_at, fallbackMinutes, now);
|
|
146
214
|
const extras = (prev.extras ?? [])
|
|
147
|
-
.map((extra) => ({ ...extra, window:
|
|
215
|
+
.map((extra) => ({ ...extra, window: replay(extra.window, 10_080) }))
|
|
148
216
|
.filter((extra) => extra.window);
|
|
149
|
-
const session =
|
|
150
|
-
const weekly =
|
|
217
|
+
const session = replay(prev.session, 300);
|
|
218
|
+
const weekly = replay(prev.weekly, 10_080);
|
|
151
219
|
// 만료를 걷어내고 표시할 창이 하나도 안 남으면 복원 대상에서 뺀다(null).
|
|
152
220
|
// 전부 null인 껍데기를 며칠 묵은 collected_at과 함께 올리면 앱엔 빈 카드가 뜨고,
|
|
153
221
|
// 서버 행의 나이가 그 옛 시각으로 되감겨 다른 기기의 묵은 업로드까지 신선도 가드를 통과한다.
|
|
@@ -307,12 +375,27 @@ async function fetchOnceRetried(fetchFn, url, options, { timeoutMs = 10_000, del
|
|
|
307
375
|
}
|
|
308
376
|
}
|
|
309
377
|
|
|
378
|
+
// 이 시각 안에 만료되는 토큰은 이미 만료된 것으로 본다 (요청이 도착할 즈음엔 401이다)
|
|
379
|
+
const EXPIRY_MARGIN_MS = 30_000;
|
|
380
|
+
|
|
381
|
+
// Claude Code의 expiresAt은 epoch 밀리초 숫자다. 1e11보다 작으면 초로 보고 바꾼다.
|
|
382
|
+
// 숫자가 아닌 값(문자열 포함), 0 이하, 무한대는 만료 시각을 모르는 것으로 둔다.
|
|
383
|
+
function epochMs(value) {
|
|
384
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
385
|
+
return value < 1e11 ? value * 1000 : value;
|
|
386
|
+
}
|
|
387
|
+
|
|
310
388
|
// Claude Code 자격증명: macOS는 Keychain, Windows/Linux는 ~/.claude/.credentials.json.
|
|
311
389
|
// macOS라도 SSH 세션의 Claude Code는 Keychain에 못 쓰고 파일에 토큰을 갱신하므로
|
|
312
|
-
// 한 곳만 읽으면 낡은 토큰으로 401/429를 반복할 수 있다. 둘 다 읽어
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
390
|
+
// 한 곳만 읽으면 낡은 토큰으로 401/429를 반복할 수 있다. 둘 다 읽어 쓸 수 있는 쪽을 고른다 (D2):
|
|
391
|
+
// 만료 시각이 미래인 소스 중 가장 늦은 것, 없으면 만료 미상 소스 중 앞선 것(Keychain 우선),
|
|
392
|
+
// 모두 만료됐으면 가장 늦게 만료된 것(로컬 만료 확인이 요청 없이 auth_expired로 보고한다).
|
|
393
|
+
function freshestCredentials(sources, now = Date.now()) {
|
|
394
|
+
const expiry = (c) => epochMs((c?.claudeAiOauth ?? c)?.expiresAt);
|
|
395
|
+
const latest = (list) => list.reduce((a, b) => (expiry(b) > expiry(a) ? b : a));
|
|
396
|
+
const live = sources.filter((c) => expiry(c) !== null && expiry(c) > now + EXPIRY_MARGIN_MS);
|
|
397
|
+
if (live.length) return latest(live);
|
|
398
|
+
return sources.find((c) => expiry(c) === null) ?? latest(sources);
|
|
316
399
|
}
|
|
317
400
|
|
|
318
401
|
function claudeCredentialLocation(env = process.env, home = HOME) {
|
|
@@ -324,40 +407,511 @@ function claudeCredentialLocation(env = process.env, home = HOME) {
|
|
|
324
407
|
const scoped = override !== undefined ? !!override : !!env.CLAUDE_CONFIG_DIR;
|
|
325
408
|
const suffix = scoped
|
|
326
409
|
? `-${require("node:crypto").createHash("sha256").update(storageDir).digest("hex").slice(0, 8)}` : "";
|
|
327
|
-
|
|
410
|
+
// Claude Code 전역 설정 파일: CLAUDE_CONFIG_DIR가 있으면 그 안, 없으면 홈 바로 아래 .claude.json
|
|
411
|
+
const globalConfig = env.CLAUDE_CONFIG_DIR ? path.join(configDir, ".claude.json") : path.join(home, ".claude.json");
|
|
412
|
+
// 자격증명 저장소가 설정 폴더와 같을 때만 .claude.json의 계정이 그 자격증명의 계정이라고 볼 수 있다
|
|
413
|
+
const sharedStore = path.resolve(storageDir) === path.resolve(configDir);
|
|
414
|
+
return { configDir, file: path.join(storageDir, ".credentials.json"), service: `Claude Code-credentials${suffix}`, globalConfig, sharedStore };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Claude Code는 리프레시 토큰을 서버가 거절(invalid_grant)하면 자격증명을 지우지 않고 토큰만 비운다
|
|
418
|
+
// ({ ...claudeAiOauth, accessToken: "", refreshToken: "", expiresAt: 0 } — 구독 종류, 한도 등급, 권한 범위는 남는다).
|
|
419
|
+
// /logout과 API 키(Console) 계정 전환은 claudeAiOauth 자체를 지운다. 그래서 "토큰만 빈 claudeAiOauth"는
|
|
420
|
+
// 끝난 구독 로그인이고, 할 일은 /login 하나다. 토큰 키가 아예 없는 모양은 모르는 상태라 껍데기로 보지 않는다.
|
|
421
|
+
function isSignedOutClaudeCredential(data) {
|
|
422
|
+
const oauth = data?.claudeAiOauth;
|
|
423
|
+
if (!oauth || typeof oauth !== "object" || Array.isArray(oauth)) return false;
|
|
424
|
+
const blank = (value) => value === undefined || value === null || (typeof value === "string" && !value.trim());
|
|
425
|
+
return typeof oauth.accessToken === "string" && blank(oauth.accessToken) && blank(oauth.refreshToken);
|
|
328
426
|
}
|
|
329
427
|
|
|
330
|
-
|
|
428
|
+
// 반환하는 자격증명에는 고른 소스("keychain" 또는 "file")를 credentialSource로 붙인다 (진단 로그용).
|
|
429
|
+
// 쓸 수 있는 소스가 없으면 던지는 오류의 signedOut이 "끝난 로그인(껍데기)"인지 알려 준다.
|
|
430
|
+
async function claudeCredentials({
|
|
431
|
+
env = process.env, home = HOME, run = runAsync, read = fs.readFileSync, now = Date.now(), platform = process.platform,
|
|
432
|
+
} = {}) {
|
|
331
433
|
const location = claudeCredentialLocation(env, home);
|
|
332
434
|
const sources = [];
|
|
435
|
+
// security 종료 코드 44(errSecItemNotFound)는 항목이 없다는 확답이다: Keychain에 살아 있는 로그인이 숨어 있을 수 없고,
|
|
436
|
+
// Claude Code도 44면 파일로 넘어간다. 36(잠김, 상호작용 불가)이나 시간 초과는 Keychain 상태를 모르는 것이다.
|
|
437
|
+
let keychainAbsent = false;
|
|
333
438
|
try {
|
|
334
|
-
sources.push(JSON.parse(await run("security", ["find-generic-password", "-s", location.service, "-w"], 10_000)));
|
|
439
|
+
sources.push({ source: "keychain", data: JSON.parse(await run("security", ["find-generic-password", "-s", location.service, "-w"], 10_000)) });
|
|
440
|
+
} catch (e) {
|
|
441
|
+
keychainAbsent = e?.code === 44;
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
sources.push({ source: "file", data: JSON.parse(read(location.file, "utf8")) });
|
|
335
445
|
} catch {}
|
|
446
|
+
const usable = sources.filter(({ data }) => typeof (data?.claudeAiOauth ?? data)?.accessToken === "string"
|
|
447
|
+
&& (data.claudeAiOauth ?? data).accessToken.trim());
|
|
448
|
+
if (!usable.length) {
|
|
449
|
+
// macOS의 기본 저장소는 Keychain이다. Keychain을 못 읽은 채 파일의 껍데기만 보고 로그인이 끝났다고 하면
|
|
450
|
+
// Keychain에 살아 있는 로그인을 두고 다시 로그인하라고 시킨다 (파일 껍데기는 몇 주씩 남는다). 그래서
|
|
451
|
+
// macOS는 Keychain 자체가 껍데기이거나 항목이 없다고 확인됐을 때만, 다른 OS는 읽힌 소스가 모두 껍데기일 때만
|
|
452
|
+
// 끝난 로그인으로 본다.
|
|
453
|
+
const stubs = sources.filter(({ data }) => isSignedOutClaudeCredential(data));
|
|
454
|
+
const signedOut = stubs.length > 0 && stubs.length === sources.length
|
|
455
|
+
&& (platform !== "darwin" || keychainAbsent || stubs.some(({ source }) => source === "keychain"));
|
|
456
|
+
throw Object.assign(new Error("Claude Code 구독 로그인 정보를 읽을 수 없음"), { signedOut });
|
|
457
|
+
}
|
|
458
|
+
const chosen = freshestCredentials(usable.map(({ data }) => data), now);
|
|
459
|
+
return { ...chosen, credentialSource: usable.find(({ data }) => data === chosen).source };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
463
|
+
const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
464
|
+
// 같은 Charge 사용자의 기기 중 누가 이번 주기에 Claude usage를 물을지 서버 임대(charge_claim_poll)로 정한다.
|
|
465
|
+
// 임대 확인이 이 시간 안에 끝나지 않으면 기다리지 않고 평소처럼 수집한다 (막는 쪽으로 실패하지 않는다).
|
|
466
|
+
const LEASE_TIMEOUT_MS = 5_000;
|
|
467
|
+
// 이 기기의 Claude usage 요청 사이 최소 간격 (D13). 잠에서 깬 launchd가 몰아서 띄운 실행이 정규 실행 몇 초 전에
|
|
468
|
+
// 떨어지면 한 주기에 두 번 묻게 된다. 5분 주기보다 짧아야 정규 실행은 막히지 않는다.
|
|
469
|
+
const MIN_REQUEST_SPACING_MS = 240_000;
|
|
470
|
+
// 최소 간격 판단(상태 읽기, 판단, 요청 시각 예약)을 프로세스 사이에서 직렬화하는 잠금(상태 파일 옆 .lock)이 이보다 오래됐으면
|
|
471
|
+
// 만든 실행이 죽은 것으로 보고 넘겨받는다. 잠금은 몇 밀리초만 잡으므로 그보다 오래 남은 잠금은 죽은 실행의 흔적이다.
|
|
472
|
+
const REQUEST_LOCK_STALE_MS = 60_000;
|
|
473
|
+
// 요청이 계속 실패하는 기기는 마지막으로 임대를 잡은 뒤 이 시간 안에는 임대를 다시 잡지 않는다 (한 주기 거른다).
|
|
474
|
+
// 서버 임대(270초)가 그 사이에 끝나 같은 사용자의 다른 기기가 넘겨받을 수 있다. 5분 주기 한 번보다 길고 두 번보다 짧다.
|
|
475
|
+
const FAILING_LEASE_INTERVAL_MS = 540_000;
|
|
476
|
+
const RATE_LIMIT_MIN_BACKOFF_S = 300;
|
|
477
|
+
const RATE_LIMIT_MAX_BACKOFF_S = 1800;
|
|
478
|
+
const RATE_LIMIT_MARGIN_S = 60;
|
|
479
|
+
// Retry-After가 비정상적으로 크면(서버 오류, 시계 문제) 기기가 며칠씩 침묵한다, 하루로 자른다
|
|
480
|
+
const RETRY_AFTER_CAP_S = 86_400;
|
|
481
|
+
const ERROR_BODY_CAP_BYTES = 2048;
|
|
482
|
+
// 프로필 조회가 일시적으로 실패하면(429, 5xx, 네트워크) 같은 토큰이라도 이 시간 뒤 다시 묻는다
|
|
483
|
+
const PROFILE_RETRY_MS = 3600_000;
|
|
484
|
+
// 서버가 401로 거절한 액세스 토큰은 바뀔 때까지 다시 쓰지 않는다. 일시적인 401일 수도 있어 이 시간이 지나면 한 번 확인한다
|
|
485
|
+
const REJECTED_TOKEN_RETRY_MS = 3600_000;
|
|
486
|
+
|
|
487
|
+
// 토큰 원문 대신 저장하는 짧은 식별자 (같은 토큰인지 비교만 한다)
|
|
488
|
+
function credentialKey(secret) {
|
|
489
|
+
if (typeof secret !== "string" || !secret) return null;
|
|
490
|
+
return require("node:crypto").createHash("sha256").update(secret).digest("hex").slice(0, 16);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function readStateFile(file) {
|
|
494
|
+
if (!file) return null;
|
|
495
|
+
try {
|
|
496
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
497
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
498
|
+
} catch {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// 상태 파일은 임시 파일에 쓰고 이름을 바꿔 교체한다, 도중에 죽어도 반쪽 JSON이 남지 않는다
|
|
504
|
+
function writeStateFile(file, data) {
|
|
505
|
+
if (!file) return false;
|
|
506
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
336
507
|
try {
|
|
337
|
-
|
|
508
|
+
fs.writeFileSync(temporary, JSON.stringify(data), { mode: 0o600 });
|
|
509
|
+
fs.renameSync(temporary, file);
|
|
510
|
+
return true;
|
|
511
|
+
} catch {
|
|
512
|
+
return false;
|
|
513
|
+
} finally {
|
|
514
|
+
try { fs.rmSync(temporary, { force: true }); } catch {}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function processAlive(pid) {
|
|
519
|
+
try {
|
|
520
|
+
process.kill(pid, 0);
|
|
521
|
+
return true;
|
|
522
|
+
} catch (e) {
|
|
523
|
+
// EPERM은 프로세스가 살아 있지만 신호를 보낼 권한이 없다는 뜻이다
|
|
524
|
+
return e?.code === "EPERM";
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// D13: 이 기기의 겹친 실행(스케줄 실행과 수동 `charge-connect run`, 깨어난 뒤 몰아서 뜬 실행)이 둘 다 옛 요청 기록을 읽고 둘 다
|
|
529
|
+
// usage를 묻지 않게, 상태 파일 옆의 <상태 파일>.lock으로 "상태 다시 읽기, 게이트와 간격 판단, 요청 시각 예약"을 프로세스 사이에서
|
|
530
|
+
// 직렬화한다. 'wx'로 만들며 내용은 { pid, at }이다. 만든 프로세스가 죽었거나 REQUEST_LOCK_STALE_MS가 지난 잠금은 낡은 것으로
|
|
531
|
+
// 보고 넘겨받는다. 지우고 다시 만드는 것은 원자적이지 않아(같은 잠금을 낡았다고 본 두 실행이 서로의 새 잠금을 지워 둘 다 잡는다)
|
|
532
|
+
// updater.js의 takeOverStaleLock으로 넘겨받기를 직렬화하고, 잠금이 그사이 바뀌지 않았을 때만 지운다. 내용을 읽을 수 없는
|
|
533
|
+
// 잠금(막 만들어져 아직 비어 있을 수 있다)은 파일 시각으로만 낡았는지 본다.
|
|
534
|
+
// 반환: 잡았으면 { acquired: true, release }, 살아 있는 다른 실행이 잡고 있거나 넘겨받는 중이면 { acquired: false, held: true },
|
|
535
|
+
// 예상 밖의 파일 오류(권한, 폴더 없음, 지울 수 없는 낡은 잠금)면 { acquired: false, held: false, error }. 던지지 않는다.
|
|
536
|
+
function acquireClaudeRequestLock(stateFile, now = Date.now(), { isAlive = processAlive } = {}) {
|
|
537
|
+
const noop = () => {};
|
|
538
|
+
if (!stateFile) return { acquired: false, held: false, error: null, release: noop };
|
|
539
|
+
const file = `${stateFile}.lock`;
|
|
540
|
+
const body = JSON.stringify({ pid: process.pid, at: now });
|
|
541
|
+
// 반환: 만들었으면 true, 이미 있으면 false, 그 밖의 오류면 그 오류
|
|
542
|
+
const create = () => {
|
|
543
|
+
try {
|
|
544
|
+
fs.writeFileSync(file, body, { flag: "wx", mode: 0o600 });
|
|
545
|
+
return true;
|
|
546
|
+
} catch (e) {
|
|
547
|
+
return e?.code === "EEXIST" ? false : e ?? new Error("lock");
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
// 이 실행이 만든 잠금만 지운다 (그사이 낡았다고 판정한 다른 실행이 새로 만든 잠금을 지우지 않게)
|
|
551
|
+
const release = () => {
|
|
552
|
+
try {
|
|
553
|
+
if (fs.readFileSync(file, "utf8") === body) fs.rmSync(file, { force: true });
|
|
554
|
+
} catch {}
|
|
555
|
+
};
|
|
556
|
+
let created = create();
|
|
557
|
+
if (created === false) {
|
|
558
|
+
try {
|
|
559
|
+
// 넘겨받기 도구는 updater.js에 있다 (섞인 폴더에서 못 불러오면 잠금 없이 판단하는 쪽으로 떨어진다, 수집은 멈추지 않는다)
|
|
560
|
+
const { lockFingerprint, takeOverStaleLock } = require("./updater.js");
|
|
561
|
+
const seen = lockFingerprint(file);
|
|
562
|
+
if (!seen) {
|
|
563
|
+
// 그사이 풀렸으면 다시 만들어 본다
|
|
564
|
+
created = create();
|
|
565
|
+
} else {
|
|
566
|
+
let info = null;
|
|
567
|
+
try {
|
|
568
|
+
info = JSON.parse(seen.content);
|
|
569
|
+
} catch {}
|
|
570
|
+
const at = Number(info?.at);
|
|
571
|
+
const pid = info?.pid;
|
|
572
|
+
const age = Number.isFinite(at) ? Math.abs(now - at) : Math.abs(Date.now() - seen.mtimeMs);
|
|
573
|
+
if (age > REQUEST_LOCK_STALE_MS || (Number.isSafeInteger(pid) && pid > 0 && !isAlive(pid))) {
|
|
574
|
+
created = takeOverStaleLock(file, seen.key, create, REQUEST_LOCK_STALE_MS);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
} catch (e) {
|
|
578
|
+
created = e ?? new Error("lock");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (created === true) return { acquired: true, held: false, error: null, release };
|
|
582
|
+
if (created === false) return { acquired: false, held: true, error: null, release: noop };
|
|
583
|
+
return { acquired: false, held: false, error: created, release: noop };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Retry-After: 초(정수) 또는 HTTP 날짜. 반환은 지금부터 기다릴 초(0 이상), 해석할 수 없으면 null.
|
|
587
|
+
function parseRetryAfter(value, now = Date.now()) {
|
|
588
|
+
if (typeof value !== "string") return null;
|
|
589
|
+
const text = value.trim();
|
|
590
|
+
if (/^\d+$/.test(text)) return Math.min(Number(text), RETRY_AFTER_CAP_S);
|
|
591
|
+
// Date.parse는 "1.5" 같은 문자열도 날짜로 받아준다, HTTP 날짜 모양(영문 요일/월, 시:분:초)일 때만 믿는다
|
|
592
|
+
if (!/[A-Za-z]{3}/.test(text) || !/\d{1,2}:\d{2}:\d{2}/.test(text)) return null;
|
|
593
|
+
// RFC 9110의 HTTP 날짜는 세 형식 모두 GMT다. asctime 형식("Sun Nov 6 08:49:37 1994")에는 시간대 표기가 없어
|
|
594
|
+
// Date.parse가 이 PC의 현지 시각으로 읽으므로(KST면 9시간 어긋난다), 표기가 없으면 GMT를 붙여 읽는다
|
|
595
|
+
const at = Date.parse(/\b(?:GMT|UTC)\b|\s[+-]\d{4}\b/i.test(text) ? text : `${text} GMT`);
|
|
596
|
+
if (!Number.isFinite(at)) return null;
|
|
597
|
+
return Math.min(Math.max(0, Math.ceil((at - now) / 1000)), RETRY_AFTER_CAP_S);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// 저장된 429 게이트: { account, credential, retryAt(ms), backoff(초), cause }. 깨졌거나 말이 안 되면 게이트 없음.
|
|
601
|
+
// account는 게이트를 건 요청 때 알던 계정 해시(모르면 null), credential은 그때의 자격증명 키,
|
|
602
|
+
// cause는 기한의 출처다 ("retry-after" = 서버가 준 N초, "backoff" = Retry-After 0/없음/깨짐에서 계산한 값).
|
|
603
|
+
function parseRateLimitGate(gate, now = Date.now()) {
|
|
604
|
+
if (!gate || typeof gate !== "object") return null;
|
|
605
|
+
if (typeof gate.credential !== "string" || !/^[0-9a-f]{16}$/.test(gate.credential)) return null;
|
|
606
|
+
const account = gate.account ?? null;
|
|
607
|
+
if (account !== null && !(typeof account === "string" && /^[0-9a-f]{12}$/.test(account))) return null;
|
|
608
|
+
if (gate.cause !== "retry-after" && gate.cause !== "backoff") return null;
|
|
609
|
+
const retryAt = Number(gate.retryAt);
|
|
610
|
+
const backoff = Number(gate.backoff);
|
|
611
|
+
if (!Number.isFinite(retryAt) || !Number.isFinite(backoff) || backoff < 0 || backoff > RATE_LIMIT_MAX_BACKOFF_S) return null;
|
|
612
|
+
// 시계가 크게 뒤로 가면 가능한 최대 대기보다 먼 미래가 된다, 그런 기록은 믿지 않는다
|
|
613
|
+
if (retryAt - now > (RETRY_AFTER_CAP_S + RATE_LIMIT_MARGIN_S) * 1000) return null;
|
|
614
|
+
return { account, credential: gate.credential, retryAt, backoff, cause: gate.cause };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Claude 요청 상태 파일: { gate, lastRequestAt, lastStatus, lastToken, leaseAt? }. 부분마다 따로 검증해 한쪽이 깨져도 다른 쪽은 쓴다.
|
|
618
|
+
// lastRequestAt/lastStatus는 마지막 usage 요청 시도(응답이 무엇이든, 네트워크 실패 포함)의 시각과 결과,
|
|
619
|
+
// lastToken은 그 요청에 쓴 액세스 토큰의 키(credentialKey)다. 모르면 token null.
|
|
620
|
+
// 요청은 보내기 직전에 시각을 먼저 적고 결과는 그 전 요청의 것을 둔다. 그 전 결과가 없으면 lastStatus null(응답 대기 중)이다.
|
|
621
|
+
// leaseAt은 이 기기가 마지막으로 조회 임대를 잡은 시각이다.
|
|
622
|
+
function readClaudeRequestState(file, now = Date.now()) {
|
|
623
|
+
const saved = readStateFile(file);
|
|
624
|
+
const at = Number(saved?.lastRequestAt);
|
|
625
|
+
const status = saved?.lastStatus;
|
|
626
|
+
const token = typeof saved?.lastToken === "string" && /^[0-9a-f]{16}$/.test(saved.lastToken) ? saved.lastToken : null;
|
|
627
|
+
const lastRequest = saved?.lastRequestAt != null && Number.isFinite(at)
|
|
628
|
+
&& (status === null || (typeof status === "string" && /^[a-z_:]{1,40}(;[a-z_]{1,20}=[0-9A-Za-z._-]{0,40}){0,8}$/.test(status)))
|
|
629
|
+
? { at, status, token }
|
|
630
|
+
: null;
|
|
631
|
+
const leaseAt = typeof saved?.leaseAt === "number" && Number.isFinite(saved.leaseAt) ? saved.leaseAt : null;
|
|
632
|
+
return { gate: parseRateLimitGate(saved?.gate, now), lastRequest, leaseAt };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function sameRateLimitGate(a, b) {
|
|
636
|
+
if (!a || !b) return a === b;
|
|
637
|
+
return a.account === b.account && a.credential === b.credential && a.retryAt === b.retryAt && a.backoff === b.backoff && a.cause === b.cause;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// D12: 게이트는 계정을 알면 계정 단위, 모르면 자격증명 단위로 적용한다. 서버가 Retry-After N초로 준 기한은
|
|
641
|
+
// 토큰이 바뀌어도 지나기 전까지 지킨다. Retry-After 0/없음/깨짐에서 나온 백오프만 자격증명이 바뀐 직후 한 번
|
|
642
|
+
// 확인해 볼 수 있다 (그 요청이 또 429면 백오프는 이어서 늘어난다). 확실히 다른 계정이면 그 계정의 한도가 아니다.
|
|
643
|
+
function blockingRateLimitGate(gate, { account = null, credential = null } = {}, now = Date.now()) {
|
|
644
|
+
if (!gate || now >= gate.retryAt) return null;
|
|
645
|
+
if (gate.account && account && gate.account !== account) return null;
|
|
646
|
+
if (gate.cause === "backoff" && gate.credential !== credential) return null;
|
|
647
|
+
return gate;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 429를 받은 뒤의 게이트. 자격증명이 바뀌어 곧바로 보낸 첫 요청이 또 429여도 백오프는 5분으로
|
|
651
|
+
// 되돌리지 않고 이전 값에서 이어서 늘린다 (토큰 갱신이 한도 창을 비워주지는 않는다). 다른 계정의 백오프는 잇지 않는다.
|
|
652
|
+
function nextRateLimitGate(previous, { account = null, credential = null } = {}, retryAfter = null, now = Date.now()) {
|
|
653
|
+
const seconds = parseRetryAfter(retryAfter, now);
|
|
654
|
+
const otherAccount = Boolean(previous?.account && account && previous.account !== account);
|
|
655
|
+
const previousBackoff = previous && !otherAccount ? previous.backoff : 0;
|
|
656
|
+
if (seconds !== null && seconds > 0) {
|
|
657
|
+
return { account, credential, retryAt: now + (seconds + RATE_LIMIT_MARGIN_S) * 1000, backoff: previousBackoff, cause: "retry-after" };
|
|
658
|
+
}
|
|
659
|
+
// Retry-After가 0, 없음, 깨짐 = 창이 가득 찼다는 뜻, 최소 5분에서 1.5배씩 30분까지
|
|
660
|
+
const backoff = Math.min(RATE_LIMIT_MAX_BACKOFF_S, Math.max(RATE_LIMIT_MIN_BACKOFF_S, Math.round(previousBackoff * 1.5)));
|
|
661
|
+
return { account, credential, retryAt: now + backoff * 1000, backoff, cause: "backoff" };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// 로그 조각에서 제어 문자(줄바꿈, 터미널 이스케이프, 글자 방향 전환)를 공백으로 바꾼다. 로그 줄 위조를 막는다.
|
|
665
|
+
function stripControlCharacters(text) {
|
|
666
|
+
return String(text ?? "").replace(/[\p{Cc}\p{Bidi_Control}\p{Zl}\p{Zp}]/gu, " ");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// 오류 응답 본문은 로그에 조금만 남기고, 토큰처럼 보이는 값(긴 base64/hex, sk-, Bearer)은 지운다.
|
|
670
|
+
// 제어 문자는 자르기 전에 먼저 지운다 (잘린 경계에 이스케이프 조각이 남지 않게).
|
|
671
|
+
function sanitizeLogSnippet(text, max = 200) {
|
|
672
|
+
return stripControlCharacters(text)
|
|
673
|
+
.replace(/Bearer\s+[^\s"',;]+/gi, "Bearer [redacted]")
|
|
674
|
+
.replace(/sk-[A-Za-z0-9_-]+/g, "[redacted]")
|
|
675
|
+
.replace(/[A-Za-z0-9+/_=-]{32,}/g, "[redacted]")
|
|
676
|
+
.replace(/\b[0-9a-fA-F]{24,}\b/g, "[redacted]")
|
|
677
|
+
.replace(/\s+/g, " ")
|
|
678
|
+
.trim()
|
|
679
|
+
.slice(0, max);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function readBodySnippet(res, cap = ERROR_BODY_CAP_BYTES) {
|
|
683
|
+
try {
|
|
684
|
+
if (res?.body && typeof res.body.getReader === "function") {
|
|
685
|
+
const reader = res.body.getReader();
|
|
686
|
+
const chunks = [];
|
|
687
|
+
let total = 0;
|
|
688
|
+
while (total < cap) {
|
|
689
|
+
const { done, value } = await reader.read();
|
|
690
|
+
if (done) break;
|
|
691
|
+
chunks.push(Buffer.from(value));
|
|
692
|
+
total += value.byteLength;
|
|
693
|
+
}
|
|
694
|
+
reader.cancel().catch(() => {});
|
|
695
|
+
return Buffer.concat(chunks).subarray(0, cap).toString("utf8");
|
|
696
|
+
}
|
|
697
|
+
if (typeof res?.text === "function") return String(await res.text()).slice(0, cap);
|
|
338
698
|
} catch {}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
699
|
+
return "";
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// 401 본문으로 사용자 조치를 가른다 (D8). 해지나 무효 자격증명이 분명할 때만 다시 로그인하라고 하고,
|
|
703
|
+
// 그 밖의 401은 만료로 본다 (Claude Code를 한 번 열면 풀린다). "invalid"만으로는 무효로 보지 않는다.
|
|
704
|
+
const REVOKED_BODY_PATTERN = /revoked|invalid[_ ]grant|invalid (authentication|bearer|access) (credentials|token)/i;
|
|
705
|
+
function unauthorizedStatus(body) {
|
|
706
|
+
return REVOKED_BODY_PATTERN.test(String(body ?? "")) ? "auth_expired:revoked" : "auth_expired";
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function logHttpFailure(label, res, body = null, hint = "") {
|
|
710
|
+
const retryAfter = res.headers?.get?.("retry-after") ?? null;
|
|
711
|
+
const snippet = sanitizeLogSnippet(body ?? await readBodySnippet(res));
|
|
712
|
+
console.error(`${label} ${res.status} (Retry-After: ${retryAfter === null ? "없음" : sanitizeLogSnippet(retryAfter, 64)})${snippet ? ` ${snippet}` : ""}${hint}`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// 이번 사이클에 Claude usage 요청을 보내지 않았으면 이유와 함께 정확히 한 줄 남긴다 (D7).
|
|
716
|
+
// reason: expired, rejected(서버가 401로 거절한 토큰), gated, shared, spacing, no-credentials, signed-out(토큰만 비운 껍데기). 자격증명 세대(토큰 해시 앞 8자)와 계정 해시는
|
|
717
|
+
// 비밀이 아니라 함께 적는다. 토큰, uuid, 이메일은 넣지 않는다. 값은 제어 문자를 지운 뒤 자른다.
|
|
718
|
+
// spacing에 reason=lock이 붙으면 이 기기의 다른 실행이 마침 간격 잠금을 잡고 판단하는 중이라 물러난 것이다.
|
|
719
|
+
function logClaudeNoRequest(reason, fields = {}, hint = "") {
|
|
720
|
+
const detail = Object.entries(fields)
|
|
721
|
+
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
|
722
|
+
.map(([key, value]) => `${key}=${sanitizeLogSnippet(value, 64)}`)
|
|
723
|
+
.join(" ");
|
|
724
|
+
const line = `claude usage 요청 안 함 (${reason})${detail ? ` ${detail}` : ""}${hint ? `, ${hint}` : ""}`;
|
|
725
|
+
if (reason === "shared" || reason === "spacing") console.log(line);
|
|
726
|
+
else console.error(line);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// 요청을 보내지 않은 rate_limited 사이클에는 ";deferred=1"을 붙인다 (앱이 시도 횟수 대신 기간으로 안내할 수 있게).
|
|
730
|
+
// 다른 접두사는 그대로 둔다.
|
|
731
|
+
function deferredStatus(status) {
|
|
732
|
+
const [prefix, ...params] = String(status).split(";");
|
|
733
|
+
if (prefix !== "error:rate_limited") return status;
|
|
734
|
+
return [prefix, ...params.filter((param) => param && !param.startsWith("deferred=")), "deferred=1"].join(";");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Claude Code 전역 설정(.claude.json)의 oauthAccount.accountUuid, 요청 없이 계정을 알아낸다.
|
|
738
|
+
// 프로필 API의 account.uuid와 같은 값이라 accountHash 결과도 같다. 다만 자격증명 저장소가 설정 폴더와 다르면
|
|
739
|
+
// (CLAUDE_SECURESTORAGE_CONFIG_DIR가 다른 곳) 이 계정이 지금 자격증명의 계정이라는 보장이 없어 읽지 않는다.
|
|
740
|
+
// uuid는 로그에 남기지 않는다.
|
|
741
|
+
async function readClaudeAccountUuid({ env = process.env, home = HOME, readFile = fs.promises.readFile } = {}) {
|
|
742
|
+
const location = claudeCredentialLocation(env, home);
|
|
743
|
+
if (!location.sharedStore) return null;
|
|
744
|
+
try {
|
|
745
|
+
const config = JSON.parse(await readFile(location.globalConfig, "utf8"));
|
|
746
|
+
const uuid = config?.oauthAccount?.accountUuid;
|
|
747
|
+
return typeof uuid === "string" && uuid ? uuid : null;
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// 이 자격증명 키로 프로필을 이미 물어봤으면 { account } (실패였으면 account null), 아니면 null
|
|
754
|
+
// 일시적 실패 기록(retryAt)은 그 시각까지만 쓰고, 지나면 null을 줘 다시 묻게 한다.
|
|
755
|
+
function readAccountCache(file, credential, now = Date.now()) {
|
|
756
|
+
const saved = readStateFile(file);
|
|
757
|
+
if (!saved || !credential || saved.credential !== credential) return null;
|
|
758
|
+
if (saved.retryAt !== undefined) {
|
|
759
|
+
const retryAt = Number(saved.retryAt);
|
|
760
|
+
// 시계가 크게 뒤로 가 재시도 시각이 대기 시간보다 먼 미래가 된 기록은 믿지 않는다
|
|
761
|
+
if (!Number.isFinite(retryAt) || now >= retryAt || retryAt - now > PROFILE_RETRY_MS) return null;
|
|
762
|
+
}
|
|
763
|
+
return { account: typeof saved.account === "string" && /^[0-9a-f]{12}$/.test(saved.account) ? saved.account : null };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// D1: 같은 Charge 사용자의 기기 중 한 대만 이번 주기에 이 계정의 usage를 묻도록 서버 임대를 잡는다.
|
|
767
|
+
// 서버가 JSON false를 줄 때만 거절(다른 기기가 임대 중)이다. RPC 오류, 구버전 서버의 404/PGRST202,
|
|
768
|
+
// 5초 타임아웃, 이상한 응답, 계정 미상은 모두 허락으로 본다 (임대 때문에 수집이 멈추면 안 된다).
|
|
769
|
+
async function claimPollLease(mode, providerId, account, { fetchFn = fetch } = {}) {
|
|
770
|
+
if (!mode?.url || !mode?.anon || !mode?.token || !account) return true;
|
|
771
|
+
try {
|
|
772
|
+
const res = await fetchFn(`${mode.url}/rest/v1/rpc/charge_claim_poll`, {
|
|
773
|
+
method: "POST",
|
|
774
|
+
headers: { apikey: mode.anon, Authorization: `Bearer ${mode.anon}`, "Content-Type": "application/json" },
|
|
775
|
+
body: JSON.stringify({ p_token: mode.token, p_provider: providerId, p_account: account }),
|
|
776
|
+
signal: AbortSignal.timeout(LEASE_TIMEOUT_MS),
|
|
777
|
+
});
|
|
778
|
+
if (!res.ok) return true;
|
|
779
|
+
return (await res.json()) !== false;
|
|
780
|
+
} catch {
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// D3: 지금 자격증명에 묶인 계정 해시. 순서: 이 자격증명 키로 기억해 둔 결과, 프로필 API 한 번(성공과 확정된 거절은
|
|
786
|
+
// 키가 바뀔 때까지, 429, 5xx, 네트워크 오류는 PROFILE_RETRY_MS 동안 기억), .claude.json(저장소 규칙은
|
|
787
|
+
// readClaudeAccountUuid가 지킨다), 그래도 모르면 null. 프로필 요청도 429 게이트를 따른다: 게이트가 막고 있으면
|
|
788
|
+
// 묻지 않고, 429를 받으면 usage와 같은 방식으로 게이트를 건다. 반환하는 gate는 요청 뒤의 게이트다.
|
|
789
|
+
// probed는 살아 있는 게이트(다른 자격증명의 백오프)를 지나 프로필을 물었다는 뜻이다. 그 요청이 이번 사이클의 확인 요청이다 (D12).
|
|
790
|
+
async function resolveClaudeAccount({ credential, headers, fetchFn, accountFile, readAccountUuid, gate, now }) {
|
|
791
|
+
let account = null;
|
|
792
|
+
let nextGate = gate;
|
|
793
|
+
let probed = false;
|
|
794
|
+
// .claude.json은 한 사이클에 한 번만 읽는다
|
|
795
|
+
let configRead = false;
|
|
796
|
+
const configAccount = async () => {
|
|
797
|
+
configRead = true;
|
|
798
|
+
return readAccountUuid ? accountHash(await Promise.resolve().then(readAccountUuid).catch(() => null)) : null;
|
|
799
|
+
};
|
|
800
|
+
const cached = readAccountCache(accountFile, credential, now());
|
|
801
|
+
if (cached) {
|
|
802
|
+
account = cached.account;
|
|
803
|
+
} else if (!blockingRateLimitGate(gate, { credential }, now())) {
|
|
804
|
+
probed = Boolean(gate && now() < gate.retryAt);
|
|
805
|
+
const retryLater = () => ({ credential, account: null, retryAt: now() + PROFILE_RETRY_MS });
|
|
806
|
+
let remember;
|
|
807
|
+
try {
|
|
808
|
+
const res = await fetchFn(CLAUDE_PROFILE_URL, { headers, signal: AbortSignal.timeout(10_000) });
|
|
809
|
+
if (res.ok) {
|
|
810
|
+
account = accountHash((await res.json())?.account?.uuid);
|
|
811
|
+
remember = { credential, account };
|
|
812
|
+
} else {
|
|
813
|
+
let hint = "";
|
|
814
|
+
if (res.status === 429) {
|
|
815
|
+
// 게이트는 계정을 알면 계정 단위로 건다 (D12). 프로필이 막혔으니 D3 순서대로 .claude.json의 계정을 먼저 본다.
|
|
816
|
+
// 계정 없이 걸면 Retry-After 기한이 다음 사이클에 확실히 다른 계정의 요청까지 막는다.
|
|
817
|
+
account = await configAccount();
|
|
818
|
+
nextGate = nextRateLimitGate(gate, { account, credential }, res.headers?.get?.("retry-after") ?? null, now());
|
|
819
|
+
hint = `, ${new Date(nextGate.retryAt).toISOString()}까지 Claude 요청을 보내지 않습니다`;
|
|
820
|
+
}
|
|
821
|
+
await logHttpFailure("claude profile API", res, null, hint);
|
|
822
|
+
const definitive = res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429;
|
|
823
|
+
remember = definitive ? { credential, account: null } : retryLater();
|
|
824
|
+
}
|
|
825
|
+
} catch {
|
|
826
|
+
remember = retryLater();
|
|
827
|
+
}
|
|
828
|
+
if (accountFile && credential) writeStateFile(accountFile, remember);
|
|
829
|
+
}
|
|
830
|
+
if (!account && !configRead) account = await configAccount();
|
|
831
|
+
return { account, gate: nextGate, probed };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// usage API 200 응답을 프로바이더로 바꾼다. 올릴 창이 하나도 없으면 provider null, status "stale".
|
|
835
|
+
function claudeUsageResult(d, { plan, account }) {
|
|
836
|
+
// 200인데 수치가 비어 있으면 창을 버린다. 0%로 대신 채우면 그건 서버의 빈 창 가드를
|
|
837
|
+
// 통과하는 "정상 값"이라, 다른 기기가 올린 멀쩡한 게이지를 0으로 덮어버린다.
|
|
838
|
+
const win = (w, mins) => {
|
|
839
|
+
const percent = percentValue(w?.utilization);
|
|
840
|
+
return percent === null ? null : { percent, resets_at: w.resets_at ?? null, window_minutes: mins };
|
|
841
|
+
};
|
|
842
|
+
// limits 배열의 weekly_scoped 항목 = 모델별 주간 한도 (예: "Fable only")
|
|
843
|
+
const extras = (d?.limits ?? []).flatMap((l) => {
|
|
844
|
+
const name = l?.kind === "weekly_scoped" ? l.scope?.model?.display_name : null;
|
|
845
|
+
const percent = percentValue(l?.percent);
|
|
846
|
+
if (!name || percent === null) return [];
|
|
847
|
+
return [{
|
|
848
|
+
name,
|
|
849
|
+
window: {
|
|
850
|
+
percent,
|
|
851
|
+
resets_at: l.resets_at ?? null,
|
|
852
|
+
window_minutes: 10080,
|
|
853
|
+
label: `${name} weekly`,
|
|
854
|
+
},
|
|
855
|
+
}];
|
|
856
|
+
});
|
|
857
|
+
const session = dropExpired(win(d?.five_hour, 300));
|
|
858
|
+
const weekly = dropExpired(win(d?.seven_day, 10080));
|
|
859
|
+
// codexProvider와 같은 규칙: 올릴 창이 하나도 없으면 프로바이더 자체를 올리지 않는다.
|
|
860
|
+
// percentValue 도입 후 이 경로가 실제로 생긴다 (200 응답인데 utilization이 비어 있는 경우).
|
|
861
|
+
// 전부 null인 행을 신선한 스탬프로 올리면 서버에서 빈 카드가 이긴다.
|
|
862
|
+
if (!session && !weekly && !extras.length) return { provider: null, status: "stale" };
|
|
863
|
+
return {
|
|
864
|
+
provider: {
|
|
865
|
+
id: "claude",
|
|
866
|
+
name: "Claude",
|
|
867
|
+
plan,
|
|
868
|
+
account,
|
|
869
|
+
session,
|
|
870
|
+
weekly,
|
|
871
|
+
extras: extras.length ? extras : null,
|
|
872
|
+
collected_at: new Date().toISOString(), // usage API 성공 시각
|
|
873
|
+
},
|
|
874
|
+
status: "ok",
|
|
875
|
+
};
|
|
343
876
|
}
|
|
344
877
|
|
|
345
878
|
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API 조회.
|
|
346
|
-
// 반환: { provider, status }
|
|
347
|
-
//
|
|
348
|
-
//
|
|
879
|
+
// 반환: { provider, status, held? }. status는 "ok", "stale", "shared", "auth_expired[:revoked]",
|
|
880
|
+
// "error[:reason][;key=value]". held는 최소 간격(D13) 때문에 요청을 미룬 사이클이다: 상태는 지난 요청의 결과를
|
|
881
|
+
// 그대로 싣고, 연속 실패 기록은 늘리지 않는다 (recordCollectionHealth의 held).
|
|
882
|
+
// 설치 흔적이 없으면 status null; 설치 흔적은 있지만 로그인 정보를 못 읽으면 설정 안내(error:credentials_missing),
|
|
883
|
+
// Claude Code가 토큰만 비운 껍데기를 남겼으면 다시 로그인 안내(error:credentials_missing:signed_out).
|
|
884
|
+
// 순서 (D1): 자격증명, 로컬 만료 확인, 401로 거절된 토큰 확인, 계정 해시, 임대, 간격 잠금, 429 게이트, 최소 간격, 요청.
|
|
885
|
+
// 게이트와 최소 간격은 잠금 안에서 상태 파일을 다시 읽고 판단한다 (같은 기기의 겹친 실행이 둘 다 묻지 않게, D13).
|
|
886
|
+
// 요청을 보내지 않으면 한 줄 로그 (D7).
|
|
887
|
+
// fetchFn/loadCredentials는 테스트 주입용. 계정 읽기, 임대, 상태 파일은 collectProviders가 넘기며
|
|
888
|
+
// 기본값(null)이면 임대 없이, 상태를 저장하지 않고, 계정은 프로필 API로만 알아낸다.
|
|
349
889
|
async function claudeProvider({
|
|
350
890
|
fetchFn = fetch, loadCredentials = claudeCredentials,
|
|
351
891
|
hasClaude = () => fs.existsSync(claudeCredentialLocation().configDir),
|
|
892
|
+
readAccountUuid = null,
|
|
893
|
+
claimLease = null,
|
|
894
|
+
stateFile = null,
|
|
895
|
+
accountFile = null,
|
|
896
|
+
now = Date.now,
|
|
352
897
|
} = {}) {
|
|
353
898
|
let cred;
|
|
354
899
|
try {
|
|
355
900
|
cred = await loadCredentials();
|
|
356
|
-
} catch {
|
|
901
|
+
} catch (e) {
|
|
902
|
+
// Claude Code가 토큰만 비운 껍데기를 남겼으면 로그인이 끝난 것이다 (isSignedOutClaudeCredential).
|
|
903
|
+
// 설치 흔적은 껍데기 자체가 증명하므로 hasClaude를 묻지 않는다. 접두사는 credentials_missing을
|
|
904
|
+
// 지켜 구버전 앱은 기존 설정 안내를, 새 앱은 /login 안내를 보여 준다.
|
|
905
|
+
if (e?.signedOut) {
|
|
906
|
+
logClaudeNoRequest("signed-out", {}, "Claude Code 구독 로그인이 끝났습니다. 이 PC의 Claude Code에서 /login 하세요");
|
|
907
|
+
return { provider: null, status: "error:credentials_missing:signed_out" };
|
|
908
|
+
}
|
|
357
909
|
// A config directory without readable subscription credentials is a setup
|
|
358
910
|
// issue, not proof that Claude Code is absent. Surface it even on first use.
|
|
359
911
|
const detected = hasClaude();
|
|
360
|
-
|
|
912
|
+
logClaudeNoRequest("no-credentials", {}, detected
|
|
913
|
+
? "Claude Code 구독 로그인 정보를 읽을 수 없습니다. 이 PC의 Claude Code에서 /status와 /login을 확인하세요 (API 키 계정은 구독 한도 조회 미지원)"
|
|
914
|
+
: "이 PC에서 Claude Code 설치 흔적을 찾지 못했습니다");
|
|
361
915
|
return { provider: null, status: detected ? "error:credentials_missing" : null };
|
|
362
916
|
}
|
|
363
917
|
try {
|
|
@@ -370,69 +924,180 @@ async function claudeProvider({
|
|
|
370
924
|
: oauth.subscriptionType
|
|
371
925
|
? oauth.subscriptionType[0].toUpperCase() + oauth.subscriptionType.slice(1)
|
|
372
926
|
: null;
|
|
373
|
-
//
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
:
|
|
383
|
-
:
|
|
384
|
-
|
|
927
|
+
// 자격증명 키: 리프레시 토큰(없으면 액세스 토큰)의 해시. 계정 캐시와 429 게이트가 쓰고, 앞 8자는 로그의 세대 표시다.
|
|
928
|
+
const credential = credentialKey(typeof oauth.refreshToken === "string" && oauth.refreshToken ? oauth.refreshToken : token);
|
|
929
|
+
const generation = credential ? credential.slice(0, 8) : null;
|
|
930
|
+
|
|
931
|
+
// 이미 만료된 토큰으로 물어보면 401만 돌아오고 한도 창만 깎인다. 요청 없이 만료로 보고하고 임대도 잡지 않는다
|
|
932
|
+
// (임대를 잡으면 토큰이 살아 있는 같은 사용자의 다른 기기가 이어받지 못한다).
|
|
933
|
+
const expiresAt = epochMs(oauth.expiresAt);
|
|
934
|
+
if (expiresAt !== null && expiresAt <= now() + EXPIRY_MARGIN_MS) {
|
|
935
|
+
logClaudeNoRequest("expired", {
|
|
936
|
+
expires_at: new Date(expiresAt).toISOString(),
|
|
937
|
+
source: cred.credentialSource,
|
|
938
|
+
generation,
|
|
939
|
+
account: readAccountCache(accountFile, credential, now())?.account,
|
|
940
|
+
}, "이 PC에서 Claude Code를 한 번 열면 갱신됩니다");
|
|
941
|
+
return { provider: null, status: "auth_expired" };
|
|
385
942
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
943
|
+
|
|
944
|
+
const headers = { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20", "User-Agent": USER_AGENT };
|
|
945
|
+
const tokenKey = credentialKey(token);
|
|
946
|
+
const state = readClaudeRequestState(stateFile, now());
|
|
947
|
+
const persist = () => {
|
|
948
|
+
const saved = writeStateFile(stateFile, {
|
|
949
|
+
gate: state.gate,
|
|
950
|
+
lastRequestAt: state.lastRequest?.at ?? null,
|
|
951
|
+
lastStatus: state.lastRequest?.status ?? null,
|
|
952
|
+
lastToken: state.lastRequest?.token ?? null,
|
|
953
|
+
...(state.leaseAt !== null ? { leaseAt: state.leaseAt } : {}),
|
|
393
954
|
});
|
|
394
|
-
if (
|
|
395
|
-
} catch {}
|
|
396
|
-
// 200인데 수치가 비어 있으면 창을 버린다. 0%로 대신 채우면 그건 서버의 빈 창 가드를
|
|
397
|
-
// 통과하는 "정상 값"이라, 다른 기기가 올린 멀쩡한 게이지를 0으로 덮어버린다.
|
|
398
|
-
const win = (w, mins) => {
|
|
399
|
-
const percent = percentValue(w?.utilization);
|
|
400
|
-
return percent === null ? null : { percent, resets_at: w.resets_at ?? null, window_minutes: mins };
|
|
955
|
+
if (stateFile && !saved) console.error("Claude 요청 상태(429 게이트, 마지막 요청 시각)를 저장하지 못했습니다");
|
|
401
956
|
};
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
window_minutes: 10080,
|
|
413
|
-
label: `${name} weekly`,
|
|
414
|
-
},
|
|
415
|
-
}];
|
|
416
|
-
});
|
|
417
|
-
const session = dropExpired(win(d.five_hour, 300));
|
|
418
|
-
const weekly = dropExpired(win(d.seven_day, 10080));
|
|
419
|
-
// codexProvider와 같은 규칙: 올릴 창이 하나도 없으면 프로바이더 자체를 올리지 않는다.
|
|
420
|
-
// percentValue 도입 후 이 경로가 실제로 생긴다 (200 응답인데 utilization이 비어 있는 경우).
|
|
421
|
-
// 전부 null인 행을 신선한 스탬프로 올리면 서버에서 빈 카드가 이긴다.
|
|
422
|
-
if (!session && !weekly && !extras.length) return { provider: null, status: "stale" };
|
|
423
|
-
return {
|
|
424
|
-
provider: {
|
|
425
|
-
id: "claude",
|
|
426
|
-
name: "Claude",
|
|
427
|
-
plan,
|
|
428
|
-
account,
|
|
429
|
-
session,
|
|
430
|
-
weekly,
|
|
431
|
-
extras: extras.length ? extras : null,
|
|
432
|
-
collected_at: new Date().toISOString(), // usage API 성공 시각
|
|
433
|
-
},
|
|
434
|
-
status: "ok",
|
|
957
|
+
// 겹친 실행(페어링 직후 스케줄러의 첫 실행과 CLI의 첫 수집 등)이 그사이 상태 파일에 쓴 것을 받아들인다: 더 늦은 요청 기록,
|
|
958
|
+
// 그리고 base(이 실행이 알던 게이트) 뒤에 새로 걸린 게이트가 이 실행의 것보다 늦으면 그 게이트. 한 실행의 200이 다른 실행이
|
|
959
|
+
// 방금 건 429 게이트를 지우지 않게 한다. 사이클 첫머리에 읽은 state는 낡았을 수 있으므로 요청 전 판단은 간격 잠금 안에서
|
|
960
|
+
// 이 함수로 다시 읽은 뒤 한다. 응답 뒤의 마지막 쓰기는 잠금 없이 하되(잠금 안에서 쓴 예약이 240초 동안 뒤이은 실행을 쉬게
|
|
961
|
+
// 하고, 요청은 그보다 훨씬 먼저 끝난다) 같은 프로세스 안의 이 확인과 뒤이은 쓰기 사이에 기다리는 작업은 두지 않는다.
|
|
962
|
+
const adoptConcurrentState = (base) => {
|
|
963
|
+
if (!stateFile) return;
|
|
964
|
+
const disk = readClaudeRequestState(stateFile, now());
|
|
965
|
+
if (disk.lastRequest && (!state.lastRequest || disk.lastRequest.at > state.lastRequest.at)) state.lastRequest = disk.lastRequest;
|
|
966
|
+
if (disk.gate && !sameRateLimitGate(disk.gate, base) && (!state.gate || disk.gate.retryAt > state.gate.retryAt)) state.gate = disk.gate;
|
|
435
967
|
};
|
|
968
|
+
|
|
969
|
+
// 지금 액세스 토큰으로 보낸 마지막 usage 요청이 실패했는가. 429는 계정 한도라 여기서 실패로 치지 않는다
|
|
970
|
+
// (게이트에 막힌 임대 보유 기기는 임대를 계속 잡는다, D1). 결과가 아직 없는 요청 기록은 실패가 아니다.
|
|
971
|
+
const last = state.lastRequest;
|
|
972
|
+
const lastKind = last?.status ? last.status.split(";")[0] : null;
|
|
973
|
+
const lastFailed = Boolean(lastKind && tokenKey && last.token === tokenKey && !["ok", "stale", "error:rate_limited"].includes(lastKind));
|
|
974
|
+
// 서버가 401로 거절한 토큰은 로컬 만료처럼 다룬다: 요청도 임대도 없이 그 결과를 되풀이한다. 거절될 요청으로 한도 창을
|
|
975
|
+
// 깎지 않고, 임대를 놓아 토큰이 살아 있는 같은 사용자의 다른 기기가 이어받게 한다. 토큰이 바뀌면(Claude Code를 열거나
|
|
976
|
+
// /login) 바로, 그대로면 REJECTED_TOKEN_RETRY_MS 뒤에 한 번 다시 묻는다 (일시적인 401일 수 있다).
|
|
977
|
+
const sinceRejected = last ? now() - last.at : Number.NaN;
|
|
978
|
+
if (lastFailed && lastKind.startsWith("auth_expired") && sinceRejected >= 0 && sinceRejected < REJECTED_TOKEN_RETRY_MS) {
|
|
979
|
+
logClaudeNoRequest("rejected", {
|
|
980
|
+
status: lastKind,
|
|
981
|
+
last_request: new Date(last.at).toISOString(),
|
|
982
|
+
source: cred.credentialSource,
|
|
983
|
+
generation,
|
|
984
|
+
account: readAccountCache(accountFile, credential, now())?.account,
|
|
985
|
+
}, lastKind === "auth_expired:revoked"
|
|
986
|
+
? "로그인이 무효입니다. 이 PC의 Claude Code에서 /login으로 다시 로그인하세요"
|
|
987
|
+
: "이 PC에서 Claude Code를 한 번 열면 갱신됩니다");
|
|
988
|
+
return { provider: null, status: lastKind };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const resolved = await resolveClaudeAccount({ credential, headers, fetchFn, accountFile, readAccountUuid, gate: state.gate, now });
|
|
992
|
+
const { account } = resolved;
|
|
993
|
+
if (resolved.gate !== state.gate) {
|
|
994
|
+
state.gate = resolved.gate;
|
|
995
|
+
persist();
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// 같은 Charge 사용자의 다른 기기가 이번 주기에 이 계정을 조회한다 (임대 거절). 게이트와 상태는 그대로 둔다.
|
|
999
|
+
// 게이트에 막힌 기기도 임대는 매 사이클 잡아, 계정 단위 차단 동안 같은 사용자의 다른 기기까지 조용해진다.
|
|
1000
|
+
// 한 시간 뒤 다시 확인하는 401 토큰은 임대를 잡지 않는다. 지금 토큰으로 보낸 마지막 요청이 그 밖의 이유로 실패했으면(네트워크,
|
|
1001
|
+
// 5xx, 403) 요청은 매 사이클 보내 복구를 바로 알아채되, 임대는 한 사이클 걸러 잡는다. 매번 갱신하면 같은 사용자의 멀쩡한 기기가
|
|
1002
|
+
// 끝내 조회하지 못하고, 아예 안 잡으면 그 기기가 넘겨받은 뒤에도 둘이 매 사이클 같이 묻는다. 거른 사이클에 임대가 끝나 다른
|
|
1003
|
+
// 기기가 넘겨받으면, 다음에 잡을 때 거절되어 이 기기는 shared로 물러난다.
|
|
1004
|
+
const sinceLease = state.leaseAt === null ? Number.NaN : now() - state.leaseAt;
|
|
1005
|
+
const skipClaim = lastFailed
|
|
1006
|
+
&& (lastKind.startsWith("auth_expired") || (sinceLease >= 0 && sinceLease < FAILING_LEASE_INTERVAL_MS));
|
|
1007
|
+
if (account && claimLease && !skipClaim) {
|
|
1008
|
+
const granted = await Promise.resolve().then(() => claimLease(account)).catch(() => true);
|
|
1009
|
+
if (granted === false) {
|
|
1010
|
+
logClaudeNoRequest("shared", { generation, account }, "같은 계정을 다른 기기가 이번 주기에 조회합니다");
|
|
1011
|
+
return { provider: null, status: "shared" };
|
|
1012
|
+
}
|
|
1013
|
+
state.leaseAt = now();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// 여기서부터 요청 시각을 예약할 때까지는 이 기기의 다른 프로세스와 직렬화한다 (D13). 같은 프로세스 안에서는 기다리는 작업이
|
|
1017
|
+
// 없지만, 겹친 두 프로세스(스케줄 실행과 수동 `charge-connect run`, 깨어난 뒤 몰아서 뜬 실행)는 둘 다 옛 기록을 읽고 둘 다
|
|
1018
|
+
// 임대를 받아(같은 기기다) 둘 다 간격을 통과할 수 있다. 그래서 잠금을 잡은 뒤 상태 파일을 다시 읽고 판단한 다음 예약을 쓰고,
|
|
1019
|
+
// HTTP 요청 전에 잠금을 푼다 (응답을 기다리는 동안은 예약이 뒤이은 실행을 쉬게 한다).
|
|
1020
|
+
// 임대 뒤에 잡는다: 잠금이나 간격 때문에 쉬는 임대 보유 기기도 임대는 이미 갱신했다.
|
|
1021
|
+
const lock = acquireClaudeRequestLock(stateFile, now());
|
|
1022
|
+
if (lock.held) {
|
|
1023
|
+
// 살아 있는 다른 실행이 지금 판단하는 중이다: 최소 간격에 걸린 것과 같이 물러난다. 그 실행이 이번 주기의 요청을 보내거나 이미 보냈다.
|
|
1024
|
+
adoptConcurrentState(state.gate);
|
|
1025
|
+
logClaudeNoRequest("spacing", {
|
|
1026
|
+
reason: "lock",
|
|
1027
|
+
last_request: state.lastRequest ? new Date(state.lastRequest.at).toISOString() : null,
|
|
1028
|
+
generation,
|
|
1029
|
+
account,
|
|
1030
|
+
});
|
|
1031
|
+
const status = state.lastRequest?.status == null ? "shared" : deferredStatus(state.lastRequest.status);
|
|
1032
|
+
return { provider: null, status, held: true };
|
|
1033
|
+
}
|
|
1034
|
+
if (lock.error) {
|
|
1035
|
+
// 잠금 파일을 만들 수 없는 폴더(권한, 폴더 없음)면 잠금 없이 한 번 판단한다 (잠금 때문에 수집이 멈추면 안 된다)
|
|
1036
|
+
console.error(`Claude 요청 간격 잠금을 만들지 못해 이번에는 잠금 없이 판단합니다: ${lock.error?.code ?? lock.error?.message ?? lock.error}`);
|
|
1037
|
+
}
|
|
1038
|
+
let requestedAt;
|
|
1039
|
+
let gateBeforeRequest;
|
|
1040
|
+
try {
|
|
1041
|
+
adoptConcurrentState(state.gate);
|
|
1042
|
+
// 계산한 백오프 중에 자격증명이 바뀌어 이번 사이클에 프로필을 이미 확인 요청으로 보냈으면 usage 확인은 다음 사이클로 미룬다.
|
|
1043
|
+
// D12가 허락하는 확인은 한 번이다. 프로필이 확실히 다른 계정을 알려줬으면 그 계정의 한도가 아니므로 막지 않는다.
|
|
1044
|
+
const gate = blockingRateLimitGate(state.gate, { account, credential }, now())
|
|
1045
|
+
?? (resolved.probed ? blockingRateLimitGate(state.gate, { account, credential: state.gate?.credential }, now()) : null);
|
|
1046
|
+
if (gate) {
|
|
1047
|
+
logClaudeNoRequest("gated", { retry_at: new Date(gate.retryAt).toISOString(), cause: gate.cause, generation, account });
|
|
1048
|
+
return { provider: null, status: `error:rate_limited;retry_at=${Math.floor(gate.retryAt / 1000)};deferred=1` };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const sinceLast = state.lastRequest ? now() - state.lastRequest.at : Number.NaN;
|
|
1052
|
+
if (sinceLast >= 0 && sinceLast < MIN_REQUEST_SPACING_MS) {
|
|
1053
|
+
logClaudeNoRequest("spacing", { last_request: new Date(state.lastRequest.at).toISOString(), generation, account });
|
|
1054
|
+
// 결과가 아직 없는 요청은 겹친 실행이 지금 이 계정을 묻고 있다는 뜻이다 (되풀이할 지난 결과도 없다). 그 실행이 결과를 올린다.
|
|
1055
|
+
const status = state.lastRequest.status === null ? "shared" : deferredStatus(state.lastRequest.status);
|
|
1056
|
+
return { provider: null, status, held: true };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// 응답이 무엇이든(네트워크 실패 포함) 요청 시각과 결과를 남긴다, 다음 사이클의 최소 간격 판단에 쓴다.
|
|
1060
|
+
// 시각은 요청 전에 먼저 적는다 (결과는 지난 요청의 것을 그대로 둔다). 응답을 기다리는 동안 겹친 실행은 이 기록을 보고 쉰다.
|
|
1061
|
+
requestedAt = now();
|
|
1062
|
+
gateBeforeRequest = state.gate;
|
|
1063
|
+
state.lastRequest = { at: requestedAt, status: state.lastRequest?.status ?? null, token: state.lastRequest?.token ?? null };
|
|
1064
|
+
persist();
|
|
1065
|
+
} finally {
|
|
1066
|
+
lock.release();
|
|
1067
|
+
}
|
|
1068
|
+
let result;
|
|
1069
|
+
try {
|
|
1070
|
+
// signal은 넘기지 않는다, fetchOnceRetried가 시도마다 새 10초 예산을 만들어 붙인다
|
|
1071
|
+
const res = await fetchOnceRetried(fetchFn, CLAUDE_USAGE_URL, { headers }, { timeoutMs: 10_000 });
|
|
1072
|
+
if (res.ok) {
|
|
1073
|
+
state.gate = null;
|
|
1074
|
+
result = claudeUsageResult(await res.json(), { plan, account });
|
|
1075
|
+
} else {
|
|
1076
|
+
// 이 구분이 서버 collect_status로 올라가 앱이 상황별 조치를 안내한다.
|
|
1077
|
+
const body = await readBodySnippet(res);
|
|
1078
|
+
let status = res.status === 403 ? "error:access_denied" : "error";
|
|
1079
|
+
let hint = "";
|
|
1080
|
+
if (res.status === 401) {
|
|
1081
|
+
status = unauthorizedStatus(body);
|
|
1082
|
+
hint = status === "auth_expired:revoked"
|
|
1083
|
+
? ", 로그인이 무효입니다. 이 PC의 Claude Code에서 /login으로 다시 로그인하세요"
|
|
1084
|
+
: ", 토큰 만료. 이 PC에서 Claude Code를 한 번 열면 갱신됩니다";
|
|
1085
|
+
} else if (res.status === 429) {
|
|
1086
|
+
state.gate = nextRateLimitGate(state.gate, { account, credential }, res.headers?.get?.("retry-after") ?? null, now());
|
|
1087
|
+
status = `error:rate_limited;retry_at=${Math.floor(state.gate.retryAt / 1000)}`;
|
|
1088
|
+
hint = `, ${new Date(state.gate.retryAt).toISOString()}까지 요청하지 않습니다`;
|
|
1089
|
+
}
|
|
1090
|
+
await logHttpFailure("claude usage API", res, body, hint);
|
|
1091
|
+
result = { provider: null, status };
|
|
1092
|
+
}
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
console.error(`claude 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
1095
|
+
result = { provider: null, status: "error" };
|
|
1096
|
+
}
|
|
1097
|
+
state.lastRequest = { at: requestedAt, status: result.status, token: tokenKey };
|
|
1098
|
+
adoptConcurrentState(gateBeforeRequest);
|
|
1099
|
+
persist();
|
|
1100
|
+
return result;
|
|
436
1101
|
} catch (e) {
|
|
437
1102
|
console.error(`claude 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
438
1103
|
return { provider: null, status: "error" };
|
|
@@ -617,8 +1282,10 @@ async function codexProvider({
|
|
|
617
1282
|
const snapshot = live ? null : await snapshotWindows();
|
|
618
1283
|
const windows = live ?? snapshot;
|
|
619
1284
|
if (!windows) return { provider: null, status: "error" }; // 실시간, 스냅샷 둘 다 관측 실패
|
|
620
|
-
|
|
621
|
-
const
|
|
1285
|
+
// 스냅샷은 과거 관측의 재생이다, 리셋 시각을 모르는 창은 캐시 복원과 같은 나이 규칙을 적용한다
|
|
1286
|
+
const replay = (w, fallbackMinutes) => (live ? w : dropUnknownResetIfOld(w, snapshot.collected_at, fallbackMinutes));
|
|
1287
|
+
const session = replay(dropExpired(windows.session), 300);
|
|
1288
|
+
const weekly = replay(dropExpired(windows.weekly), 10_080);
|
|
622
1289
|
// 리셋이 다 지나 남는 창이 하나도 없으면 프로바이더 자체를 올리지 않는다. 전부 null인 행을
|
|
623
1290
|
// 올리면 앱엔 빈 카드가 뜨고, 서버 행의 collected_at이 스냅샷 mtime(며칠 전)으로 되감겨
|
|
624
1291
|
// 다른 기기의 묵은 업로드까지 신선도 가드를 통과한다. 대신 캐시 폴백이 마지막 값을 유지한다.
|
|
@@ -811,9 +1478,15 @@ async function statusOf(url) {
|
|
|
811
1478
|
}
|
|
812
1479
|
}
|
|
813
1480
|
|
|
814
|
-
|
|
1481
|
+
// mode는 페어링 설정 (없으면 Claude 조회 임대를 잡지 않는다)
|
|
1482
|
+
async function collectProviders(mode = null) {
|
|
815
1483
|
const [claude, codex, claudeStatus, codexStatus, codexBar] = await Promise.all([
|
|
816
|
-
claudeProvider(
|
|
1484
|
+
claudeProvider({
|
|
1485
|
+
readAccountUuid: () => readClaudeAccountUuid(),
|
|
1486
|
+
claimLease: mode ? (account) => claimPollLease(mode, "claude", account) : null,
|
|
1487
|
+
stateFile: RATE_LIMIT_FILE,
|
|
1488
|
+
accountFile: CLAUDE_ACCOUNT_FILE,
|
|
1489
|
+
}),
|
|
817
1490
|
codexProvider(),
|
|
818
1491
|
statusOf("https://status.anthropic.com/api/v2/status.json"),
|
|
819
1492
|
statusOf("https://status.openai.com/api/v2/status.json"),
|
|
@@ -829,6 +1502,8 @@ async function collectProviders() {
|
|
|
829
1502
|
providers: [claude.provider, codex.provider, ...codexBar.providers].filter(Boolean),
|
|
830
1503
|
codexBarComplete: codexBar.complete,
|
|
831
1504
|
statuses,
|
|
1505
|
+
// 최소 간격으로 요청을 미룬 프로바이더: 지난 결과를 싣되 연속 실패는 늘리지 않는다 (D13)
|
|
1506
|
+
held: claude.held && claude.status ? ["claude"] : [],
|
|
832
1507
|
};
|
|
833
1508
|
}
|
|
834
1509
|
|
|
@@ -852,8 +1527,11 @@ function dedupeProviders(providers) {
|
|
|
852
1527
|
}
|
|
853
1528
|
|
|
854
1529
|
// 이번 사이클 수집 결과에 캐시 복원분을 얹고 업로드 계약(키 유일성)에 맞춰 정리한다.
|
|
1530
|
+
// 반환: { providers(업로드할 목록), statuses, cache(다음 사이클을 위해 캐시 파일에 남길 목록) }.
|
|
855
1531
|
function mergeCachedProviders({ providers, cached = [], codexBarComplete = false, statuses = null }) {
|
|
856
1532
|
const merged = [...providers];
|
|
1533
|
+
// 업로드에서는 빼지만 캐시 파일에는 남기는 항목 (shared 사이클의 마지막 정상 값)
|
|
1534
|
+
const retained = [];
|
|
857
1535
|
// statuses가 null이면 수집이 통째로 죽어 아무것도 판정할 수 없는 경우, 미상 그대로 둔다
|
|
858
1536
|
const nextStatuses = statuses ? { ...statuses } : statuses;
|
|
859
1537
|
for (const prev of cached) {
|
|
@@ -862,6 +1540,13 @@ function mergeCachedProviders({ providers, cached = [], codexBarComplete = false
|
|
|
862
1540
|
if (codexBarComplete && prev.collector_source === "codexbar") continue;
|
|
863
1541
|
const sanitized = sanitizeCachedProvider(prev);
|
|
864
1542
|
if (!sanitized) continue;
|
|
1543
|
+
// 같은 계정을 다른 기기가 이번 주기에 조회한 사이클(shared)에 이 기기의 묵은 캐시를 다시 올리면
|
|
1544
|
+
// 서버에 이 기기 행이 계속 살아남는다. 업로드에서는 빼되, 캐시 파일에서는 지우지 않는다 (D4):
|
|
1545
|
+
// 나중에 이 기기가 임대를 넘겨받았는데 요청이 실패하면 그 마지막 정상 값으로 폴백해야 한다.
|
|
1546
|
+
if (typeof nextStatuses?.[prev.id] === "string" && nextStatuses[prev.id].split(";")[0] === "shared") {
|
|
1547
|
+
retained.push(sanitized);
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
865
1550
|
// 0.1.4 이전 캐시엔 collected_at이 없다, 없는 채로 보낸다. 서버는 스탬프 없는 업로드를
|
|
866
1551
|
// "최신"이 아니라 "나이 미상"으로 보고 기존 행이 충분히 묵었을 때만 받아준다.
|
|
867
1552
|
// (예전엔 epoch로 찍어 지게 만들었는데, 그 값이 앱까지 새어 "56년 전"으로 보였다.)
|
|
@@ -871,7 +1556,8 @@ function mergeCachedProviders({ providers, cached = [], codexBarComplete = false
|
|
|
871
1556
|
// 정상 수집이 남긴 판정(auth_expired/error 등)은 덮지 않는다.
|
|
872
1557
|
if (nextStatuses && !nextStatuses[sanitized.id]) nextStatuses[sanitized.id] = "stale";
|
|
873
1558
|
}
|
|
874
|
-
|
|
1559
|
+
const upload = dedupeProviders(merged);
|
|
1560
|
+
return { providers: upload, statuses: nextStatuses, cache: retained.length ? dedupeProviders([...merged, ...retained]) : upload };
|
|
875
1561
|
}
|
|
876
1562
|
|
|
877
1563
|
function namespaceUnknownAccounts(providers, installationID) {
|
|
@@ -886,14 +1572,24 @@ function namespaceUnknownAccounts(providers, installationID) {
|
|
|
886
1572
|
// Count actual collection cycles, not app refreshes or retries within one request.
|
|
887
1573
|
// A long gap (sleep/offline), recovery, or missing observation breaks the streak.
|
|
888
1574
|
// Keep the status a string with its original prefix so existing apps/servers can read it.
|
|
889
|
-
|
|
1575
|
+
// Other ";key=value" parameters (for example retry_at) are preserved ahead of failures and since.
|
|
1576
|
+
// Different error kinds keep one streak (a persistent failure is real); the latest kind is stored.
|
|
1577
|
+
// Ids in `held` sent no request this cycle because of the local request spacing (D13): their streak is
|
|
1578
|
+
// repeated as it was (not advanced, not reset), and a streak that already lapsed is not annotated.
|
|
1579
|
+
function advanceCollectionHealth(statuses, previous = {}, now = Date.now(), held = []) {
|
|
890
1580
|
if (statuses === null) return { statuses: null, failures: {} };
|
|
891
1581
|
const failures = {};
|
|
892
1582
|
const annotated = {};
|
|
893
1583
|
for (const [id, value] of Object.entries(statuses)) {
|
|
894
|
-
|
|
1584
|
+
if (id.startsWith("_") || typeof value !== "string") {
|
|
1585
|
+
annotated[id] = value;
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
const [status, ...params] = value.split(";");
|
|
1589
|
+
const kept = params.filter((param) => param && !/^(failures|since)=/.test(param));
|
|
1590
|
+
const base = [status, ...kept].join(";");
|
|
895
1591
|
if (!status.startsWith("error") && !status.startsWith("auth_expired")) {
|
|
896
|
-
annotated[id] =
|
|
1592
|
+
annotated[id] = base;
|
|
897
1593
|
continue;
|
|
898
1594
|
}
|
|
899
1595
|
const prev = previous?.[id];
|
|
@@ -901,23 +1597,32 @@ function advanceCollectionHealth(statuses, previous = {}, now = Date.now()) {
|
|
|
901
1597
|
&& Number.isFinite(prev.since) && prev.since <= prev.lastAttempt
|
|
902
1598
|
&& Number.isFinite(prev.lastAttempt) && now >= prev.lastAttempt
|
|
903
1599
|
&& now - prev.lastAttempt <= 12 * 60_000;
|
|
1600
|
+
if (held.includes(id)) {
|
|
1601
|
+
if (continues) {
|
|
1602
|
+
failures[id] = { count: prev.count, since: prev.since, lastAttempt: prev.lastAttempt, kind: typeof prev.kind === "string" ? prev.kind : status };
|
|
1603
|
+
annotated[id] = `${base};failures=${prev.count};since=${Math.floor(prev.since / 1000)}`;
|
|
1604
|
+
} else {
|
|
1605
|
+
annotated[id] = base;
|
|
1606
|
+
}
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
904
1609
|
const count = continues ? Math.min(prev.count + 1, 9999) : 1;
|
|
905
1610
|
const since = continues ? prev.since : now;
|
|
906
|
-
failures[id] = { count, since, lastAttempt: now };
|
|
907
|
-
annotated[id] = `${
|
|
1611
|
+
failures[id] = { count, since, lastAttempt: now, kind: status };
|
|
1612
|
+
annotated[id] = `${base};failures=${count};since=${Math.floor(since / 1000)}`;
|
|
908
1613
|
}
|
|
909
1614
|
return { statuses: annotated, failures };
|
|
910
1615
|
}
|
|
911
1616
|
|
|
912
1617
|
function recordCollectionHealth(statuses, {
|
|
913
|
-
file = HEALTH_FILE, scope = null, now = Date.now(), persist = true,
|
|
1618
|
+
file = HEALTH_FILE, scope = null, now = Date.now(), persist = true, held = [],
|
|
914
1619
|
} = {}) {
|
|
915
1620
|
let previous = {};
|
|
916
1621
|
try {
|
|
917
1622
|
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
918
1623
|
if (saved.scope === scope) previous = saved.failures;
|
|
919
1624
|
} catch {}
|
|
920
|
-
const next = advanceCollectionHealth(statuses, previous, now);
|
|
1625
|
+
const next = advanceCollectionHealth(statuses, previous, now, held);
|
|
921
1626
|
if (persist) {
|
|
922
1627
|
// Save independently of the payload: an upload failure must not erase attempts.
|
|
923
1628
|
const temporary = `${file}.${process.pid}.tmp`;
|
|
@@ -933,6 +1638,57 @@ function recordCollectionHealth(statuses, {
|
|
|
933
1638
|
return next.statuses;
|
|
934
1639
|
}
|
|
935
1640
|
|
|
1641
|
+
// collect_status에 수집기 버전을 싣는다 (서버가 charge_devices.collector_version으로 옮기고 키는 지운다).
|
|
1642
|
+
// 상태가 null(수집이 통째로 실패)이면 미상 그대로 둔다.
|
|
1643
|
+
function withCollectorVersion(statuses, version = PACKAGE_VERSION) {
|
|
1644
|
+
if (!statuses || typeof statuses !== "object" || !version) return statuses;
|
|
1645
|
+
return { ...statuses, _collector: version };
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// 설치된 런타임(~/.charge/app)이면 서명된 새 릴리스로 스스로 교체한다. 실패는 로그 한 줄로 끝낸다.
|
|
1649
|
+
// 한 실행에서 한 번만 확인한다: 업로드 뒤, main이 예외로 끝났을 때, 처리되지 않은 예외로 끝낼 때 모두 같은 확인을 기다린다.
|
|
1650
|
+
let autoUpdateRun = null;
|
|
1651
|
+
function runAutoUpdate(mode) {
|
|
1652
|
+
autoUpdateRun ??= (async () => {
|
|
1653
|
+
let updater;
|
|
1654
|
+
try {
|
|
1655
|
+
updater = require("./updater.js");
|
|
1656
|
+
} catch (e) {
|
|
1657
|
+
console.error(`자동 업데이트 모듈을 불러오지 못했습니다: ${e?.message ?? e}`);
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
try {
|
|
1661
|
+
await updater.maybeAutoUpdate({
|
|
1662
|
+
mode,
|
|
1663
|
+
dryRun: DRY_RUN,
|
|
1664
|
+
appDir: __dirname,
|
|
1665
|
+
chargeHome: process.env.CHARGE_HOME ?? path.join(HOME, ".charge"),
|
|
1666
|
+
currentVersion: PACKAGE_VERSION,
|
|
1667
|
+
});
|
|
1668
|
+
} catch (e) {
|
|
1669
|
+
console.error(`자동 업데이트 실패: ${e?.message ?? e}`);
|
|
1670
|
+
}
|
|
1671
|
+
})();
|
|
1672
|
+
return autoUpdateRun;
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// 처리되지 않은 예외나 거부로 끝낼 때(오류 기록은 처리기가 먼저 한다). 이 버전의 결함으로 매 실행이 그렇게 죽더라도
|
|
1676
|
+
// 업데이트 확인까지는 닿아야 고친 릴리스로 스스로 복구한다. 확인하는 동안 또 터진 예외는 기록만 되고, 확인이 멈추면
|
|
1677
|
+
// CRASH_UPDATE_CHECK_LIMIT_MS 뒤에 끝낸다. 자체 점검 중이거나 이 파일을 다 불러오기 전(업데이트 함수를 아직 못 쓴다)이면 바로 끝낸다.
|
|
1678
|
+
function exitAfterUpdateCheck() {
|
|
1679
|
+
if (crashExitStarted) return;
|
|
1680
|
+
crashExitStarted = true;
|
|
1681
|
+
let check;
|
|
1682
|
+
try {
|
|
1683
|
+
if (SELF_TEST) process.exit(1);
|
|
1684
|
+
check = runAutoUpdate(resolveMode());
|
|
1685
|
+
} catch {
|
|
1686
|
+
process.exit(1);
|
|
1687
|
+
}
|
|
1688
|
+
setTimeout(() => process.exit(1), CRASH_UPDATE_CHECK_LIMIT_MS);
|
|
1689
|
+
check.finally(() => process.exit(1));
|
|
1690
|
+
}
|
|
1691
|
+
|
|
936
1692
|
// 업로드 설정 — 우선순위: CHARGE_* 환경변수(테스트용) → ~/.charge/config.json(페어링)
|
|
937
1693
|
function resolveMode() {
|
|
938
1694
|
if (process.env.CHARGE_TOKEN && process.env.CHARGE_URL && process.env.CHARGE_ANON) {
|
|
@@ -1009,7 +1765,7 @@ async function main() {
|
|
|
1009
1765
|
const [dailyR, liveR, providersR] = await Promise.allSettled([
|
|
1010
1766
|
collectDaily(),
|
|
1011
1767
|
collectLive(),
|
|
1012
|
-
collectProviders(),
|
|
1768
|
+
collectProviders(mode),
|
|
1013
1769
|
]);
|
|
1014
1770
|
let daily, live, providers;
|
|
1015
1771
|
if (dailyR.status === "fulfilled") {
|
|
@@ -1032,11 +1788,12 @@ async function main() {
|
|
|
1032
1788
|
} else {
|
|
1033
1789
|
console.error(`프로바이더 수집 실패, 이전 값 유지: ${providersR.reason?.message ?? providersR.reason}`);
|
|
1034
1790
|
// 수집이 통째로 죽으면 상태를 알 수 없다 — null로 보내 정직한 미상 처리 (서버도 null로 덮음)
|
|
1035
|
-
providerCollection = { providers: [], codexBarComplete: false, statuses: null };
|
|
1791
|
+
providerCollection = { providers: [], codexBarComplete: false, statuses: null, held: [] };
|
|
1036
1792
|
}
|
|
1037
1793
|
// 일부 프로바이더만 실패해도 목록에서 사라지지 않게 이전 값으로 채운다 (앱 설정 토글 유지)
|
|
1038
1794
|
let statuses;
|
|
1039
|
-
|
|
1795
|
+
let cacheProviders;
|
|
1796
|
+
({ providers, statuses, cache: cacheProviders } = mergeCachedProviders({
|
|
1040
1797
|
providers: providerCollection.providers,
|
|
1041
1798
|
cached: cache.providers ?? [],
|
|
1042
1799
|
codexBarComplete: providerCollection.codexBarComplete,
|
|
@@ -1044,10 +1801,12 @@ async function main() {
|
|
|
1044
1801
|
}));
|
|
1045
1802
|
|
|
1046
1803
|
providers = namespaceUnknownAccounts(providers, mode?.install_id);
|
|
1047
|
-
|
|
1804
|
+
cacheProviders = namespaceUnknownAccounts(cacheProviders, mode?.install_id);
|
|
1805
|
+
statuses = withCollectorVersion(recordCollectionHealth(statuses, {
|
|
1048
1806
|
scope: accountHash(mode?.token),
|
|
1049
1807
|
persist: !DRY_RUN && !!mode,
|
|
1050
|
-
|
|
1808
|
+
held: providerCollection.held ?? [],
|
|
1809
|
+
}));
|
|
1051
1810
|
|
|
1052
1811
|
if (DRY_RUN) {
|
|
1053
1812
|
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers, collect_status: statuses }, null, 2));
|
|
@@ -1061,25 +1820,35 @@ async function main() {
|
|
|
1061
1820
|
}
|
|
1062
1821
|
|
|
1063
1822
|
const now = new Date().toISOString();
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1823
|
+
let uploadError = null;
|
|
1824
|
+
try {
|
|
1825
|
+
await pairedUpload(mode, daily, live, providers, statuses);
|
|
1826
|
+
// 캐시에는 업로드 목록이 아니라 캐시 목록을 쓴다 (shared 사이클에도 마지막 정상 Claude 값을 지우지 않는다)
|
|
1827
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify({ daily, live, providers: cacheProviders }));
|
|
1828
|
+
console.log(`[${now}] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 완료`);
|
|
1829
|
+
} catch (e) {
|
|
1830
|
+
uploadError = e;
|
|
1831
|
+
}
|
|
1832
|
+
// 업로드가 끝난 뒤(실패했더라도) 업데이트를 확인한다. 서버 계약이 바뀌어 업로드가 계속 실패하는
|
|
1833
|
+
// 구버전 수집기도 새 버전을 받아 스스로 복구할 수 있어야 한다.
|
|
1834
|
+
await runAutoUpdate(mode);
|
|
1835
|
+
if (uploadError) throw uploadError;
|
|
1075
1836
|
}
|
|
1076
1837
|
|
|
1077
1838
|
module.exports = {
|
|
1078
1839
|
CCUSAGE_PKG,
|
|
1840
|
+
MIN_REQUEST_SPACING_MS,
|
|
1841
|
+
PACKAGE_VERSION,
|
|
1842
|
+
REQUEST_LOCK_STALE_MS,
|
|
1843
|
+
USER_AGENT,
|
|
1844
|
+
acquireClaudeRequestLock,
|
|
1079
1845
|
advanceCollectionHealth,
|
|
1080
1846
|
accountHash,
|
|
1847
|
+
blockingRateLimitGate,
|
|
1848
|
+
claimPollLease,
|
|
1081
1849
|
claudeCredentialLocation,
|
|
1082
1850
|
claudeCredentials,
|
|
1851
|
+
isSignedOutClaudeCredential,
|
|
1083
1852
|
claudeProvider,
|
|
1084
1853
|
codexBarEntryToProvider,
|
|
1085
1854
|
codexLiveWindows,
|
|
@@ -1087,22 +1856,66 @@ module.exports = {
|
|
|
1087
1856
|
codexSnapshotWindows,
|
|
1088
1857
|
collectCodexBarProviders,
|
|
1089
1858
|
collectLive,
|
|
1859
|
+
collectProviders,
|
|
1860
|
+
credentialKey,
|
|
1090
1861
|
dedupeProviders,
|
|
1862
|
+
deferredStatus,
|
|
1091
1863
|
dropExpired,
|
|
1864
|
+
dropUnknownResetIfOld,
|
|
1092
1865
|
durationLabel,
|
|
1866
|
+
epochMs,
|
|
1093
1867
|
fetchOnceRetried,
|
|
1094
1868
|
freshestCredentials,
|
|
1095
1869
|
mergeCachedProviders,
|
|
1096
1870
|
namespaceUnknownAccounts,
|
|
1871
|
+
nextRateLimitGate,
|
|
1097
1872
|
normalizeRateWindow,
|
|
1098
1873
|
normalizeResetAt,
|
|
1099
1874
|
pairedUpload,
|
|
1100
1875
|
parseCodexBarJSON,
|
|
1876
|
+
parseRateLimitGate,
|
|
1877
|
+
parseRetryAfter,
|
|
1101
1878
|
percentValue,
|
|
1879
|
+
readClaudeAccountUuid,
|
|
1880
|
+
readClaudeRequestState,
|
|
1102
1881
|
recordCollectionHealth,
|
|
1103
1882
|
resolveMode,
|
|
1104
1883
|
runAsync,
|
|
1105
1884
|
sanitizeCachedProvider,
|
|
1885
|
+
sanitizeLogSnippet,
|
|
1106
1886
|
titleCaseProvider,
|
|
1887
|
+
unauthorizedStatus,
|
|
1107
1888
|
usefulPlan,
|
|
1889
|
+
withCollectorVersion,
|
|
1108
1890
|
};
|
|
1891
|
+
|
|
1892
|
+
// 진입점은 module.exports 뒤에 둔다. 자체 점검이 불러오는 cli.js가 이 파일을 다시 require하면
|
|
1893
|
+
// 완성된 exports를 받아야 한다.
|
|
1894
|
+
if (require.main === module && SELF_TEST) {
|
|
1895
|
+
// 자동 업데이트가 교체 전(스테이징 폴더)과 교체 후(앱 폴더)에 부른다 (D9). 런타임 모듈을 모두 불러와
|
|
1896
|
+
// 빠진 파일이나 불러오는 순간의 예외를 잡고, 네트워크, 키체인, 상태 파일에는 닿지 않은 채 끝낸다.
|
|
1897
|
+
// 마지막 줄의 버전은 updater.js가 설치하려는 버전과 같은지 확인한다.
|
|
1898
|
+
require("./identity.js");
|
|
1899
|
+
require("./updater.js");
|
|
1900
|
+
require("./cli.js");
|
|
1901
|
+
console.log(`charge-connect self-test ok ${PACKAGE_VERSION ?? "unknown"}`);
|
|
1902
|
+
} else if (require.main === module) {
|
|
1903
|
+
// --log 없이 돌 때(launchd, CLI의 첫 수집)도 main 밖에서 터진 예외가 업데이트 확인을 건너뛰지 않게 한다.
|
|
1904
|
+
// --log가 있으면 맨 위의 처리기가 로그 파일에 쓰고 같은 확인을 거친다.
|
|
1905
|
+
if (!LOG_FILE) {
|
|
1906
|
+
const crashed = (err) => {
|
|
1907
|
+
console.error(err?.stack ?? err);
|
|
1908
|
+
exitAfterUpdateCheck();
|
|
1909
|
+
};
|
|
1910
|
+
process.on("uncaughtException", crashed);
|
|
1911
|
+
process.on("unhandledRejection", crashed);
|
|
1912
|
+
}
|
|
1913
|
+
main().catch(async (e) => {
|
|
1914
|
+
console.error(e.message ?? e);
|
|
1915
|
+
// 업로드 전에 예외로 끝나도 업데이트 확인까지는 닿게 한다. 이 버전의 결함으로 매 실행이 죽더라도
|
|
1916
|
+
// 고친 릴리스를 받아 스스로 복구해야 한다 (dry-run, 미페어링, 12시간 창은 maybeAutoUpdate가 거른다).
|
|
1917
|
+
// 이미 시작한 확인이 있으면 그 확인을 기다린다.
|
|
1918
|
+
await runAutoUpdate(resolveMode());
|
|
1919
|
+
process.exit(1);
|
|
1920
|
+
});
|
|
1921
|
+
}
|