charge-connect 0.1.0 → 0.1.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/collect.js +83 -47
- package/package.json +1 -1
package/collect.js
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
// 사용법: node collect.js [--dry-run] [--days N]
|
|
4
4
|
// 인증: `npx charge-connect <페어링코드>`가 저장한 ~/.charge/config.json의 디바이스 토큰
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const { execFile } = require("node:child_process");
|
|
7
|
+
const { promisify } = require("node:util");
|
|
7
8
|
const fs = require("node:fs");
|
|
8
9
|
const path = require("node:path");
|
|
9
10
|
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
10
13
|
const DRY_RUN = process.argv.includes("--dry-run");
|
|
11
14
|
const daysArg = process.argv.indexOf("--days");
|
|
12
15
|
const parsedDays = daysArg > -1 ? parseInt(process.argv[daysArg + 1], 10) : NaN;
|
|
@@ -17,24 +20,30 @@ const WIN = process.platform === "win32";
|
|
|
17
20
|
const EXTRA_PATH = WIN ? "" : ":/opt/homebrew/bin:/usr/local/bin";
|
|
18
21
|
const DEFAULT_CODEXBAR_CLI = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI";
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
// 모든 외부 명령은 비동기로 실행한다 — 동기(execFileSync)는 이벤트 루프를 세워
|
|
24
|
+
// 진행 중인 fetch(AbortSignal 타이머 포함)를 전부 타임아웃시킨다.
|
|
25
|
+
// Windows에서 npx/ccusage는 .cmd 셔틀이라 셸을 거쳐야 실행된다 (인자는 모두 고정 문자열).
|
|
26
|
+
const execOpts = (timeout) => ({
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
29
|
+
timeout,
|
|
30
|
+
env: { ...process.env, PATH: `${process.env.PATH}${EXTRA_PATH}` },
|
|
31
|
+
shell: WIN,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function runAsync(cmd, args, timeout = 120_000) {
|
|
35
|
+
// promisify된 execFile은 실패 시에도 err.stdout/stderr를 붙여준다
|
|
36
|
+
return (await execFileAsync(cmd, args, execOpts(timeout))).stdout;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
|
-
function runCcusage(args) {
|
|
39
|
+
async function runCcusage(args) {
|
|
32
40
|
// 전역 설치본이 있으면 사용(빠름), 없으면 npx로 대체
|
|
33
41
|
try {
|
|
34
|
-
return JSON.parse(
|
|
42
|
+
return JSON.parse(await runAsync("ccusage", args));
|
|
35
43
|
} catch (e) {
|
|
36
|
-
console.error(`ccusage 직접 실행 실패(${e.code ?? e.message}), npx로
|
|
37
|
-
|
|
44
|
+
console.error(`ccusage 직접 실행 실패(${e.code ?? e.message}), npx로 재시도 — 'npm i -g ccusage'를 해두면 빨라집니다`);
|
|
45
|
+
// --prefer-offline: npx 캐시가 있으면 레지스트리 조회 없이 재사용 (5분마다 재다운로드 방지)
|
|
46
|
+
return JSON.parse(await runAsync("npx", ["-y", "--prefer-offline", "ccusage@latest", ...args], 300_000));
|
|
38
47
|
}
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -51,6 +60,21 @@ function dropExpired(w) {
|
|
|
51
60
|
return w;
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
// 캐시에서 복원한 프로바이더도 신선 수집과 같은 만료 규칙을 적용한다.
|
|
64
|
+
// 안 하면 수집이 실패하는 동안 리셋이 지난 창이 무한 재업로드된다 (유령 게이지).
|
|
65
|
+
function sanitizeCachedProvider(prev) {
|
|
66
|
+
if (!prev || typeof prev !== "object") return null;
|
|
67
|
+
const extras = (prev.extras ?? [])
|
|
68
|
+
.map((extra) => ({ ...extra, window: dropExpired(extra.window) }))
|
|
69
|
+
.filter((extra) => extra.window);
|
|
70
|
+
return {
|
|
71
|
+
...prev,
|
|
72
|
+
session: dropExpired(prev.session),
|
|
73
|
+
weekly: dropExpired(prev.weekly),
|
|
74
|
+
extras: extras.length ? extras : null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
const PROVIDER_NAMES = {
|
|
55
79
|
"azure-openai": "Azure OpenAI",
|
|
56
80
|
"openai": "OpenAI",
|
|
@@ -137,8 +161,8 @@ function sinceStr(days) {
|
|
|
137
161
|
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
|
|
138
162
|
}
|
|
139
163
|
|
|
140
|
-
function collectDaily() {
|
|
141
|
-
const data = runCcusage(["daily", "--json", "--since", sinceStr(DAYS)]);
|
|
164
|
+
async function collectDaily() {
|
|
165
|
+
const data = await runCcusage(["daily", "--json", "--since", sinceStr(DAYS)]);
|
|
142
166
|
let rows = data.daily ?? [];
|
|
143
167
|
// 에이전트별 행과 "all" 집계 행이 섞여 있으면 "all"만 사용해 중복 합산을 방지
|
|
144
168
|
if (rows.some((r) => r.agent === "all")) rows = rows.filter((r) => r.agent === "all");
|
|
@@ -161,15 +185,15 @@ function collectDaily() {
|
|
|
161
185
|
return [...byDay.values()].sort((a, b) => a.period.localeCompare(b.period));
|
|
162
186
|
}
|
|
163
187
|
|
|
164
|
-
function collectLive() {
|
|
165
|
-
const data = runCcusage(["blocks", "--active", "--json"]);
|
|
188
|
+
async function collectLive() {
|
|
189
|
+
const data = await runCcusage(["blocks", "--active", "--json"]);
|
|
166
190
|
return (data.blocks ?? []).find((b) => b.isActive) ?? null;
|
|
167
191
|
}
|
|
168
192
|
|
|
169
193
|
// Claude Code 자격증명: macOS는 Keychain, Windows/Linux는 ~/.claude/.credentials.json
|
|
170
|
-
function claudeCredentials() {
|
|
194
|
+
async function claudeCredentials() {
|
|
171
195
|
try {
|
|
172
|
-
return JSON.parse(
|
|
196
|
+
return JSON.parse(await runAsync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"]));
|
|
173
197
|
} catch {
|
|
174
198
|
return JSON.parse(fs.readFileSync(path.join(HOME, ".claude", ".credentials.json"), "utf8"));
|
|
175
199
|
}
|
|
@@ -178,7 +202,7 @@ function claudeCredentials() {
|
|
|
178
202
|
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API 조회
|
|
179
203
|
async function claudeProvider() {
|
|
180
204
|
try {
|
|
181
|
-
const cred = claudeCredentials();
|
|
205
|
+
const cred = await claudeCredentials();
|
|
182
206
|
const oauth = cred.claudeAiOauth ?? cred;
|
|
183
207
|
const token = oauth.accessToken;
|
|
184
208
|
// rateLimitTier "default_claude_max_20x" → "Max 20x", 없으면 subscriptionType "max" → "Max"
|
|
@@ -192,7 +216,11 @@ async function claudeProvider() {
|
|
|
192
216
|
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
193
217
|
signal: AbortSignal.timeout(10_000),
|
|
194
218
|
});
|
|
195
|
-
if (!res.ok)
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
// 401 = 토큰 만료 (Claude Code를 한 번 실행하면 갱신됨), 그 외는 일시 장애일 가능성
|
|
221
|
+
console.error(`claude usage API ${res.status}${res.status === 401 ? " — 토큰 만료, Claude Code를 실행하면 갱신됩니다" : ""}`);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
196
224
|
const d = await res.json();
|
|
197
225
|
// 계정 UUID → 해시 (프로필 조회 실패 시 null — 캐시 폴백이 채워준다)
|
|
198
226
|
let account = null;
|
|
@@ -460,7 +488,7 @@ function parseCodexBarJSON(raw) {
|
|
|
460
488
|
}
|
|
461
489
|
}
|
|
462
490
|
|
|
463
|
-
function collectCodexBarProviders() {
|
|
491
|
+
async function collectCodexBarProviders() {
|
|
464
492
|
if (process.env.CHARGE_DISABLE_CODEXBAR === "1") return { providers: [], complete: false };
|
|
465
493
|
const cli = process.env.CHARGE_CODEXBAR_CLI
|
|
466
494
|
?? (!WIN && fs.existsSync(DEFAULT_CODEXBAR_CLI) ? DEFAULT_CODEXBAR_CLI : null);
|
|
@@ -469,7 +497,9 @@ function collectCodexBarProviders() {
|
|
|
469
497
|
let raw = "";
|
|
470
498
|
let commandSucceeded = true;
|
|
471
499
|
try {
|
|
472
|
-
|
|
500
|
+
// CodexBar CLI는 실측 20초 이상 걸릴 수 있다 — 동기로 돌리면 그동안 이벤트 루프가
|
|
501
|
+
// 멈춰 Claude/Codex fetch의 abort 타이머가 전부 발화하므로 반드시 비동기로.
|
|
502
|
+
raw = await runAsync(cli, ["usage", "--format", "json"], 90_000);
|
|
473
503
|
} catch (e) {
|
|
474
504
|
commandSucceeded = false;
|
|
475
505
|
raw = e.stdout?.toString?.() ?? "";
|
|
@@ -506,16 +536,12 @@ async function statusOf(url) {
|
|
|
506
536
|
}
|
|
507
537
|
|
|
508
538
|
async function collectProviders() {
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
claudePromise,
|
|
516
|
-
codexPromise,
|
|
517
|
-
claudeStatusPromise,
|
|
518
|
-
codexStatusPromise,
|
|
539
|
+
const [claude, codex, claudeStatus, codexStatus, codexBar] = await Promise.all([
|
|
540
|
+
claudeProvider(),
|
|
541
|
+
codexProvider(),
|
|
542
|
+
statusOf("https://status.anthropic.com/api/v2/status.json"),
|
|
543
|
+
statusOf("https://status.openai.com/api/v2/status.json"),
|
|
544
|
+
collectCodexBarProviders(),
|
|
519
545
|
]);
|
|
520
546
|
if (claude) claude.status = claudeStatus;
|
|
521
547
|
if (codex) codex.status = codexStatus;
|
|
@@ -560,24 +586,32 @@ async function main() {
|
|
|
560
586
|
cache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
|
|
561
587
|
} catch {}
|
|
562
588
|
|
|
589
|
+
// ccusage 두 갈래와 프로바이더 수집(네트워크+CodexBar CLI)은 서로 독립 —
|
|
590
|
+
// 병렬로 돌려 전체 수집 시간을 max(ccusage, network)로 줄인다.
|
|
591
|
+
// allSettled라 한 갈래가 실패해도 나머지는 살고, 실패분만 이전 값으로 채운다.
|
|
592
|
+
const [dailyR, liveR, providersR] = await Promise.allSettled([
|
|
593
|
+
collectDaily(),
|
|
594
|
+
collectLive(),
|
|
595
|
+
collectProviders(),
|
|
596
|
+
]);
|
|
563
597
|
let daily, live, providers;
|
|
564
|
-
|
|
565
|
-
daily =
|
|
566
|
-
}
|
|
567
|
-
console.error(`daily 수집 실패, 이전 값 유지: ${
|
|
598
|
+
if (dailyR.status === "fulfilled") {
|
|
599
|
+
daily = dailyR.value;
|
|
600
|
+
} else {
|
|
601
|
+
console.error(`daily 수집 실패, 이전 값 유지: ${dailyR.reason?.message ?? dailyR.reason}`);
|
|
568
602
|
daily = cache.daily ?? [];
|
|
569
603
|
}
|
|
570
|
-
|
|
571
|
-
live =
|
|
572
|
-
}
|
|
573
|
-
console.error(`live 수집 실패, 이전 값 유지: ${
|
|
604
|
+
if (liveR.status === "fulfilled") {
|
|
605
|
+
live = liveR.value;
|
|
606
|
+
} else {
|
|
607
|
+
console.error(`live 수집 실패, 이전 값 유지: ${liveR.reason?.message ?? liveR.reason}`);
|
|
574
608
|
live = cache.live ?? null;
|
|
575
609
|
}
|
|
576
610
|
let providerCollection;
|
|
577
|
-
|
|
578
|
-
providerCollection =
|
|
579
|
-
}
|
|
580
|
-
console.error(`프로바이더 수집 실패, 이전 값 유지: ${
|
|
611
|
+
if (providersR.status === "fulfilled") {
|
|
612
|
+
providerCollection = providersR.value;
|
|
613
|
+
} else {
|
|
614
|
+
console.error(`프로바이더 수집 실패, 이전 값 유지: ${providersR.reason?.message ?? providersR.reason}`);
|
|
581
615
|
providerCollection = { providers: [], codexBarComplete: false };
|
|
582
616
|
}
|
|
583
617
|
providers = providerCollection.providers;
|
|
@@ -586,7 +620,8 @@ async function main() {
|
|
|
586
620
|
if (providers.some((p) => p.id === prev.id)) continue;
|
|
587
621
|
// CodexBar가 정상 완료된 경우, 거기서 더 이상 반환하지 않는 항목은 사용자가 끈 것으로 본다.
|
|
588
622
|
if (providerCollection.codexBarComplete && prev.collector_source === "codexbar") continue;
|
|
589
|
-
|
|
623
|
+
const sanitized = sanitizeCachedProvider(prev);
|
|
624
|
+
if (sanitized) providers.push(sanitized);
|
|
590
625
|
}
|
|
591
626
|
// 계정 식별 실패 시 마지막으로 알려진 계정 해시 유지 (계정 미상('')과 실제 계정 행이 갈라지는 것 방지)
|
|
592
627
|
for (const p of providers) {
|
|
@@ -629,6 +664,7 @@ module.exports = {
|
|
|
629
664
|
normalizeResetAt,
|
|
630
665
|
parseCodexBarJSON,
|
|
631
666
|
resolveMode,
|
|
667
|
+
sanitizeCachedProvider,
|
|
632
668
|
titleCaseProvider,
|
|
633
669
|
usefulPlan,
|
|
634
670
|
};
|