charge-connect 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/collect.js +142 -60
- package/package.json +1 -1
package/collect.js
CHANGED
|
@@ -124,6 +124,9 @@ function dropExpired(w) {
|
|
|
124
124
|
|
|
125
125
|
// 캐시에서 복원한 프로바이더도 신선 수집과 같은 만료 규칙을 적용한다.
|
|
126
126
|
// 안 하면 수집이 실패하는 동안 리셋이 지난 창이 무한 재업로드된다 (유령 게이지).
|
|
127
|
+
// {...prev} 전개가 collected_at을 그대로 보존하는 것이 신선도 규칙의 핵심 —
|
|
128
|
+
// 서버는 collected_at이 더 신선한 행만 덮어쓰므로, 토큰이 만료된 기기의 캐시 폴백이
|
|
129
|
+
// 건강한 기기가 방금 올린 값을 덮어쓰지 못한다. collected_at을 여기서 갱신하면 안 된다.
|
|
127
130
|
function sanitizeCachedProvider(prev) {
|
|
128
131
|
if (!prev || typeof prev !== "object") return null;
|
|
129
132
|
const extras = (prev.extras ?? [])
|
|
@@ -252,19 +255,38 @@ async function collectLive() {
|
|
|
252
255
|
return (data.blocks ?? []).find((b) => b.isActive) ?? null;
|
|
253
256
|
}
|
|
254
257
|
|
|
255
|
-
// Claude Code 자격증명: macOS는 Keychain, Windows/Linux는 ~/.claude/.credentials.json
|
|
258
|
+
// Claude Code 자격증명: macOS는 Keychain, Windows/Linux는 ~/.claude/.credentials.json.
|
|
259
|
+
// macOS라도 SSH 세션의 Claude Code는 Keychain에 못 쓰고 파일에 토큰을 갱신하므로
|
|
260
|
+
// 한 곳만 읽으면 낡은 토큰으로 401/429를 반복할 수 있다. 둘 다 읽어 최신 쪽을 쓴다.
|
|
261
|
+
function freshestCredentials(sources) {
|
|
262
|
+
const expiresAt = (c) => Number((c?.claudeAiOauth ?? c)?.expiresAt) || 0;
|
|
263
|
+
return sources.reduce((a, b) => (expiresAt(b) > expiresAt(a) ? b : a));
|
|
264
|
+
}
|
|
265
|
+
|
|
256
266
|
async function claudeCredentials() {
|
|
267
|
+
const sources = [];
|
|
257
268
|
try {
|
|
258
|
-
|
|
259
|
-
} catch {
|
|
260
|
-
|
|
261
|
-
|
|
269
|
+
sources.push(JSON.parse(await runAsync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"])));
|
|
270
|
+
} catch {}
|
|
271
|
+
try {
|
|
272
|
+
sources.push(JSON.parse(fs.readFileSync(path.join(HOME, ".claude", ".credentials.json"), "utf8")));
|
|
273
|
+
} catch {}
|
|
274
|
+
if (!sources.length) throw new Error("Claude Code 자격증명 없음");
|
|
275
|
+
return freshestCredentials(sources);
|
|
262
276
|
}
|
|
263
277
|
|
|
264
|
-
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API
|
|
265
|
-
|
|
278
|
+
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API 조회.
|
|
279
|
+
// 반환: { provider, status } — status "ok" | "auth_expired" | "error",
|
|
280
|
+
// 자격증명 자체가 없으면(미설치) status null이라 collect_status에 항목이 안 생긴다.
|
|
281
|
+
// fetchFn/loadCredentials는 테스트 주입용 — 기본값이면 기존 동작 그대로.
|
|
282
|
+
async function claudeProvider({ fetchFn = fetch, loadCredentials = claudeCredentials } = {}) {
|
|
283
|
+
let cred;
|
|
284
|
+
try {
|
|
285
|
+
cred = await loadCredentials();
|
|
286
|
+
} catch {
|
|
287
|
+
return { provider: null, status: null }; // 자격증명 없음 = Claude Code 미설치
|
|
288
|
+
}
|
|
266
289
|
try {
|
|
267
|
-
const cred = await claudeCredentials();
|
|
268
290
|
const oauth = cred.claudeAiOauth ?? cred;
|
|
269
291
|
const token = oauth.accessToken;
|
|
270
292
|
// rateLimitTier "default_claude_max_20x" → "Max 20x", 없으면 subscriptionType "max" → "Max"
|
|
@@ -274,20 +296,21 @@ async function claudeProvider() {
|
|
|
274
296
|
: oauth.subscriptionType
|
|
275
297
|
? oauth.subscriptionType[0].toUpperCase() + oauth.subscriptionType.slice(1)
|
|
276
298
|
: null;
|
|
277
|
-
const res = await
|
|
299
|
+
const res = await fetchFn("https://api.anthropic.com/api/oauth/usage", {
|
|
278
300
|
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
279
301
|
signal: AbortSignal.timeout(10_000),
|
|
280
302
|
});
|
|
281
303
|
if (!res.ok) {
|
|
282
|
-
// 401 = 토큰 만료 (Claude Code를 한 번 실행하면 갱신됨), 그 외는 일시 장애일
|
|
304
|
+
// 401 = 토큰 만료 (Claude Code를 한 번 실행하면 갱신됨), 그 외는 일시 장애일 가능성.
|
|
305
|
+
// 이 구분이 서버 collect_status로 올라가 앱이 "재로그인 필요"를 안내할 수 있다.
|
|
283
306
|
console.error(`claude usage API ${res.status}${res.status === 401 ? " — 토큰 만료, Claude Code를 실행하면 갱신됩니다" : ""}`);
|
|
284
|
-
return null;
|
|
307
|
+
return { provider: null, status: res.status === 401 ? "auth_expired" : "error" };
|
|
285
308
|
}
|
|
286
309
|
const d = await res.json();
|
|
287
310
|
// 계정 UUID → 해시 (프로필 조회 실패 시 null — 캐시 폴백이 채워준다)
|
|
288
311
|
let account = null;
|
|
289
312
|
try {
|
|
290
|
-
const pr = await
|
|
313
|
+
const pr = await fetchFn("https://api.anthropic.com/api/oauth/profile", {
|
|
291
314
|
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
292
315
|
signal: AbortSignal.timeout(10_000),
|
|
293
316
|
});
|
|
@@ -308,17 +331,21 @@ async function claudeProvider() {
|
|
|
308
331
|
},
|
|
309
332
|
}));
|
|
310
333
|
return {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
334
|
+
provider: {
|
|
335
|
+
id: "claude",
|
|
336
|
+
name: "Claude",
|
|
337
|
+
plan,
|
|
338
|
+
account,
|
|
339
|
+
session: dropExpired(win(d.five_hour, 300)),
|
|
340
|
+
weekly: dropExpired(win(d.seven_day, 10080)),
|
|
341
|
+
extras: extras.length ? extras : null,
|
|
342
|
+
collected_at: new Date().toISOString(), // usage API 성공 시각
|
|
343
|
+
},
|
|
344
|
+
status: "ok",
|
|
318
345
|
};
|
|
319
346
|
} catch (e) {
|
|
320
347
|
console.error(`claude 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
321
|
-
return null;
|
|
348
|
+
return { provider: null, status: "error" };
|
|
322
349
|
}
|
|
323
350
|
}
|
|
324
351
|
|
|
@@ -427,26 +454,38 @@ function codexSnapshotWindows() {
|
|
|
427
454
|
window_minutes: w.window_minutes ?? null,
|
|
428
455
|
}
|
|
429
456
|
: null;
|
|
430
|
-
|
|
457
|
+
// collected_at = 로그 파일 mtime — 스냅샷은 "지금"이 아니라 마지막 사용 시점의 값이므로
|
|
458
|
+
// 수집 시각을 넣으면 다른 기기의 진짜 최신 데이터를 신선도 규칙에서 이겨버린다.
|
|
459
|
+
return { session: win(rl.primary), weekly: win(rl.secondary), collected_at: new Date(latest.m).toISOString() };
|
|
431
460
|
}
|
|
432
461
|
|
|
462
|
+
// 반환: { provider, status } — live 성공 "ok", 스냅샷 폴백 "stale",
|
|
463
|
+
// ~/.codex는 있는데 둘 다 실패면 "error", 미설치면 status null(항목 없음).
|
|
433
464
|
async function codexProvider() {
|
|
465
|
+
if (!fs.existsSync(path.join(HOME, ".codex"))) return { provider: null, status: null }; // Codex 미설치
|
|
434
466
|
try {
|
|
435
467
|
const auth = codexAuth();
|
|
436
|
-
const
|
|
437
|
-
|
|
468
|
+
const live = await codexLiveWindows(auth);
|
|
469
|
+
const snapshot = live ? null : codexSnapshotWindows();
|
|
470
|
+
const windows = live ?? snapshot;
|
|
471
|
+
if (!windows) return { provider: null, status: "error" };
|
|
438
472
|
return {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
473
|
+
provider: {
|
|
474
|
+
id: "codex",
|
|
475
|
+
name: "Codex",
|
|
476
|
+
plan: windows.plan ?? auth?.plan ?? null,
|
|
477
|
+
account: auth?.account ?? null,
|
|
478
|
+
session: dropExpired(windows.session),
|
|
479
|
+
weekly: dropExpired(windows.weekly),
|
|
480
|
+
extras: null,
|
|
481
|
+
// live면 지금이 관측 시각, 스냅샷 폴백이면 .jsonl mtime이 실제 관측 시각
|
|
482
|
+
collected_at: live ? new Date().toISOString() : snapshot.collected_at,
|
|
483
|
+
},
|
|
484
|
+
status: live ? "ok" : "stale",
|
|
446
485
|
};
|
|
447
486
|
} catch (e) {
|
|
448
487
|
console.error(`codex 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
449
|
-
return null;
|
|
488
|
+
return { provider: null, status: "error" };
|
|
450
489
|
}
|
|
451
490
|
}
|
|
452
491
|
|
|
@@ -533,6 +572,7 @@ function codexBarEntryToProvider(entry) {
|
|
|
533
572
|
extras: extras.length ? extras : null,
|
|
534
573
|
status: null,
|
|
535
574
|
collector_source: "codexbar",
|
|
575
|
+
collected_at: new Date().toISOString(), // CodexBar CLI 성공 시각
|
|
536
576
|
};
|
|
537
577
|
}
|
|
538
578
|
|
|
@@ -555,11 +595,11 @@ function parseCodexBarJSON(raw) {
|
|
|
555
595
|
}
|
|
556
596
|
|
|
557
597
|
async function collectCodexBarProviders() {
|
|
558
|
-
if (process.env.CHARGE_DISABLE_CODEXBAR === "1") return { providers: [], complete: false };
|
|
598
|
+
if (process.env.CHARGE_DISABLE_CODEXBAR === "1") return { providers: [], complete: false, statuses: {} };
|
|
559
599
|
// 따옴표로 감싼 경로(Windows에서 흔한 습관)는 벗겨서 execFile에 그대로 넘긴다
|
|
560
600
|
const cli = process.env.CHARGE_CODEXBAR_CLI?.replace(/^"(.*)"$/s, "$1")
|
|
561
601
|
?? (!WIN && fs.existsSync(DEFAULT_CODEXBAR_CLI) ? DEFAULT_CODEXBAR_CLI : null);
|
|
562
|
-
if (!cli) return { providers: [], complete: false };
|
|
602
|
+
if (!cli) return { providers: [], complete: false, statuses: {} };
|
|
563
603
|
|
|
564
604
|
let raw = "";
|
|
565
605
|
let commandSucceeded = true;
|
|
@@ -572,22 +612,33 @@ async function collectCodexBarProviders() {
|
|
|
572
612
|
raw = e.stdout?.toString?.() ?? "";
|
|
573
613
|
if (!raw.trim()) {
|
|
574
614
|
console.error(`CodexBar 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
575
|
-
return { providers: [], complete: false };
|
|
615
|
+
return { providers: [], complete: false, statuses: {} };
|
|
576
616
|
}
|
|
577
617
|
}
|
|
578
618
|
|
|
579
619
|
const entries = parseCodexBarJSON(raw);
|
|
580
620
|
if (!entries) {
|
|
581
621
|
console.error("CodexBar 프로바이더 수집 실패: JSON 출력을 해석할 수 없습니다.");
|
|
582
|
-
return { providers: [], complete: false };
|
|
622
|
+
return { providers: [], complete: false, statuses: {} };
|
|
583
623
|
}
|
|
584
624
|
const bridgeEntries = entries.filter((entry) => !["claude", "codex"].includes(entry?.provider));
|
|
585
|
-
const providers =
|
|
586
|
-
const
|
|
587
|
-
for (const entry of bridgeEntries
|
|
588
|
-
|
|
625
|
+
const providers = [];
|
|
626
|
+
const statuses = {};
|
|
627
|
+
for (const entry of bridgeEntries) {
|
|
628
|
+
if (entry?.error) {
|
|
629
|
+
console.error(`CodexBar ${entry.provider ?? "프로바이더"} 수집 실패: ${entry.error.message ?? "알 수 없는 오류"}`);
|
|
630
|
+
// claude/codex 상태는 자체 수집기가 판정한다 — entry.id로 새어 들어와도 덮지 않는다
|
|
631
|
+
const id = textValue(entry.provider, entry.id)?.toLowerCase();
|
|
632
|
+
if (id && id !== "claude" && id !== "codex") statuses[id] = "error";
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const provider = codexBarEntryToProvider(entry);
|
|
636
|
+
if (!provider) continue; // 창이 하나도 없는 엔트리 — 성공도 실패도 아니라 상태 미기록
|
|
637
|
+
providers.push(provider);
|
|
638
|
+
statuses[provider.id] = "ok";
|
|
589
639
|
}
|
|
590
|
-
|
|
640
|
+
const complete = commandSucceeded && !bridgeEntries.some((entry) => entry?.error);
|
|
641
|
+
return { providers, complete, statuses };
|
|
591
642
|
}
|
|
592
643
|
|
|
593
644
|
// 프로바이더 상태 페이지 (Statuspage 공용 API)
|
|
@@ -610,11 +661,16 @@ async function collectProviders() {
|
|
|
610
661
|
statusOf("https://status.openai.com/api/v2/status.json"),
|
|
611
662
|
collectCodexBarProviders(),
|
|
612
663
|
]);
|
|
613
|
-
if (claude) claude.status = claudeStatus;
|
|
614
|
-
if (codex) codex.status = codexStatus;
|
|
664
|
+
if (claude.provider) claude.provider.status = claudeStatus;
|
|
665
|
+
if (codex.provider) codex.provider.status = codexStatus;
|
|
666
|
+
// collect_status: 미설치(status null)는 항목을 만들지 않는다 — 설치된 소스만 정직하게 보고
|
|
667
|
+
const statuses = { ...codexBar.statuses };
|
|
668
|
+
if (claude.status) statuses.claude = claude.status;
|
|
669
|
+
if (codex.status) statuses.codex = codex.status;
|
|
615
670
|
return {
|
|
616
|
-
providers: [claude, codex, ...codexBar.providers].filter(Boolean),
|
|
671
|
+
providers: [claude.provider, codex.provider, ...codexBar.providers].filter(Boolean),
|
|
617
672
|
codexBarComplete: codexBar.complete,
|
|
673
|
+
statuses,
|
|
618
674
|
};
|
|
619
675
|
}
|
|
620
676
|
|
|
@@ -632,18 +688,33 @@ function resolveMode() {
|
|
|
632
688
|
}
|
|
633
689
|
|
|
634
690
|
// 디바이스 토큰으로 charge_upload RPC 호출 (서버가 본인 행에만 기록)
|
|
635
|
-
async function pairedUpload(mode, daily, live, providers) {
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
691
|
+
async function pairedUpload(mode, daily, live, providers, collectStatus = null) {
|
|
692
|
+
const post = (body) =>
|
|
693
|
+
fetch(`${mode.url}/rest/v1/rpc/charge_upload`, {
|
|
694
|
+
method: "POST",
|
|
695
|
+
headers: {
|
|
696
|
+
apikey: mode.anon,
|
|
697
|
+
Authorization: `Bearer ${mode.anon}`,
|
|
698
|
+
"Content-Type": "application/json",
|
|
699
|
+
},
|
|
700
|
+
body: JSON.stringify(body),
|
|
701
|
+
signal: AbortSignal.timeout(30_000),
|
|
702
|
+
});
|
|
703
|
+
const body = { p_token: mode.token, p_daily: daily, p_live: live, p_providers: providers, p_collect_status: collectStatus };
|
|
704
|
+
let res = await post(body);
|
|
705
|
+
if (res.ok) return;
|
|
706
|
+
const text = await res.text();
|
|
707
|
+
// 서버에 5번째 파라미터(p_collect_status)가 아직 배포 전이면 PostgREST가 함수
|
|
708
|
+
// 시그니처를 못 찾아 404/PGRST202를 낸다 — 수집기가 먼저 업데이트된 배포 순서
|
|
709
|
+
// 역전 대비로, 구버전 시그니처(파라미터 제외)로 1회만 재시도한다.
|
|
710
|
+
if (res.status === 404 || text.includes("PGRST202")) {
|
|
711
|
+
console.error("charge_upload 구버전 서버 감지(404/PGRST202) — p_collect_status 없이 재시도합니다");
|
|
712
|
+
const { p_collect_status: _omitted, ...legacyBody } = body;
|
|
713
|
+
res = await post(legacyBody);
|
|
714
|
+
if (res.ok) return;
|
|
715
|
+
throw new Error(`업로드 실패 (${res.status}): ${await res.text()}`);
|
|
716
|
+
}
|
|
717
|
+
throw new Error(`업로드 실패 (${res.status}): ${text}`);
|
|
647
718
|
}
|
|
648
719
|
|
|
649
720
|
async function main() {
|
|
@@ -679,16 +750,25 @@ async function main() {
|
|
|
679
750
|
providerCollection = providersR.value;
|
|
680
751
|
} else {
|
|
681
752
|
console.error(`프로바이더 수집 실패, 이전 값 유지: ${providersR.reason?.message ?? providersR.reason}`);
|
|
682
|
-
|
|
753
|
+
// 수집이 통째로 죽으면 상태를 알 수 없다 — null로 보내 정직한 미상 처리 (서버도 null로 덮음)
|
|
754
|
+
providerCollection = { providers: [], codexBarComplete: false, statuses: null };
|
|
683
755
|
}
|
|
684
756
|
providers = providerCollection.providers;
|
|
685
|
-
// 일부 프로바이더만 실패해도 목록에서 사라지지 않게 이전 값으로 채운다 (앱 설정 토글 유지)
|
|
757
|
+
// 일부 프로바이더만 실패해도 목록에서 사라지지 않게 이전 값으로 채운다 (앱 설정 토글 유지).
|
|
758
|
+
// 이렇게 캐시로 채운 프로바이더의 상태는 statuses에 이미 실패(auth_expired/error 등)로
|
|
759
|
+
// 기록돼 있다 — 캐시 복원이 성공처럼 보이면 안 되므로 여기서 덮지 않는다.
|
|
686
760
|
for (const prev of cache.providers ?? []) {
|
|
687
761
|
if (providers.some((p) => p.id === prev.id)) continue;
|
|
688
762
|
// CodexBar가 정상 완료된 경우, 거기서 더 이상 반환하지 않는 항목은 사용자가 끈 것으로 본다.
|
|
689
763
|
if (providerCollection.codexBarComplete && prev.collector_source === "codexbar") continue;
|
|
690
764
|
const sanitized = sanitizeCachedProvider(prev);
|
|
691
|
-
if (sanitized)
|
|
765
|
+
if (sanitized) {
|
|
766
|
+
// 0.1.4 이전 캐시엔 collected_at이 없다. 그대로 보내면 서버가 now()로 취급해
|
|
767
|
+
// 나이 미상의 캐시가 건강한 기기의 진짜 최신 데이터를 이겨버린다 — epoch로 스탬프해
|
|
768
|
+
// "언제 것인지 모르는 데이터는 항상 진다"로 만든다 (행 유지에는 지장 없음).
|
|
769
|
+
if (!sanitized.collected_at) sanitized.collected_at = new Date(0).toISOString();
|
|
770
|
+
providers.push(sanitized);
|
|
771
|
+
}
|
|
692
772
|
}
|
|
693
773
|
// 계정 식별 실패 시 마지막으로 알려진 계정 해시 유지 (계정 미상('')과 실제 계정 행이 갈라지는 것 방지)
|
|
694
774
|
for (const p of providers) {
|
|
@@ -696,7 +776,7 @@ async function main() {
|
|
|
696
776
|
}
|
|
697
777
|
|
|
698
778
|
if (DRY_RUN) {
|
|
699
|
-
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers }, null, 2));
|
|
779
|
+
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers, collect_status: providerCollection.statuses }, null, 2));
|
|
700
780
|
console.log(`\n[dry-run] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 예정`);
|
|
701
781
|
return;
|
|
702
782
|
}
|
|
@@ -708,7 +788,7 @@ async function main() {
|
|
|
708
788
|
}
|
|
709
789
|
|
|
710
790
|
const now = new Date().toISOString();
|
|
711
|
-
await pairedUpload(mode, daily, live, providers);
|
|
791
|
+
await pairedUpload(mode, daily, live, providers, providerCollection.statuses);
|
|
712
792
|
|
|
713
793
|
fs.writeFileSync(CACHE_FILE, JSON.stringify({ daily, live, providers }));
|
|
714
794
|
console.log(`[${now}] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 완료`);
|
|
@@ -724,10 +804,12 @@ if (require.main === module) {
|
|
|
724
804
|
module.exports = {
|
|
725
805
|
CCUSAGE_PKG,
|
|
726
806
|
accountHash,
|
|
807
|
+
claudeProvider,
|
|
727
808
|
codexBarEntryToProvider,
|
|
728
809
|
collectCodexBarProviders,
|
|
729
810
|
dropExpired,
|
|
730
811
|
durationLabel,
|
|
812
|
+
freshestCredentials,
|
|
731
813
|
normalizeRateWindow,
|
|
732
814
|
normalizeResetAt,
|
|
733
815
|
parseCodexBarJSON,
|