charge-connect 0.1.3 → 0.1.5
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 +44 -8
- package/collect.js +148 -64
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
// charge-connect CLI — 앱에서 받은 페어링 코드로 연결하고 자동 수집을 등록한다.
|
|
3
3
|
//
|
|
4
4
|
// 사용법:
|
|
5
|
-
// npx charge-connect <페어링코드>
|
|
5
|
+
// npx charge-connect <페어링코드> [--label 이름] # 페어링 + 첫 수집 + 스케줄 등록 (원라이너)
|
|
6
|
+
// # --label(또는 CHARGE_LABEL)로 앱에 표시될 기기명 지정, 기본은 호스트명
|
|
6
7
|
// charge-connect run # 수집 1회 (스케줄러가 호출)
|
|
7
|
-
// charge-connect unpair # 페어링 해제
|
|
8
|
+
// charge-connect unpair # 페어링 해제 (서버 디바이스 토큰도 폐기)
|
|
8
9
|
//
|
|
9
10
|
// 설정 파일: ~/.charge/config.json (CHARGE_HOME으로 위치 변경 가능)
|
|
10
11
|
|
|
11
12
|
const fs = require("node:fs");
|
|
12
13
|
const path = require("node:path");
|
|
13
14
|
const { execFileSync } = require("node:child_process");
|
|
15
|
+
const { CCUSAGE_PKG } = require("./collect.js"); // 버전 고정을 한 곳에서 관리
|
|
14
16
|
|
|
15
17
|
const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
16
18
|
const CONF_DIR = process.env.CHARGE_HOME ?? path.join(HOME, ".charge");
|
|
@@ -28,6 +30,17 @@ const CLOUD = (() => {
|
|
|
28
30
|
}
|
|
29
31
|
})();
|
|
30
32
|
|
|
33
|
+
// 앱 기기 목록에 표시될 이름 — 기본은 호스트명이지만 실명이 든 경우가 흔하므로
|
|
34
|
+
// 최초 페어링 때 --label 또는 CHARGE_LABEL로 덮어쓸 수 있게 하고, 무엇으로 등록되는지 알려준다.
|
|
35
|
+
// 주의: 라벨은 서버가 같은 머신을 알아보는 유일한 키다(중복제거). 이미 페어링된 머신을
|
|
36
|
+
// 다른 라벨로 재페어링하면 새 디바이스로 잡혀 일별 사용량이 이중 집계되므로, 사후 변경은 권하지 않는다.
|
|
37
|
+
function resolveLabel() {
|
|
38
|
+
const i = process.argv.indexOf("--label");
|
|
39
|
+
const flag = i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith("-") ? process.argv[i + 1] : null;
|
|
40
|
+
const raw = flag ?? process.env.CHARGE_LABEL ?? require("node:os").hostname();
|
|
41
|
+
return String(raw).trim().slice(0, 64) || require("node:os").hostname();
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
async function pair(code) {
|
|
32
45
|
const url = process.env.CHARGE_URL ?? CLOUD.url;
|
|
33
46
|
const anon = process.env.CHARGE_ANON ?? CLOUD.anon;
|
|
@@ -35,10 +48,11 @@ async function pair(code) {
|
|
|
35
48
|
console.error("백엔드 주소가 없습니다 (collector/cloud.json 또는 CHARGE_URL/CHARGE_ANON).");
|
|
36
49
|
process.exit(1);
|
|
37
50
|
}
|
|
51
|
+
const label = resolveLabel();
|
|
38
52
|
const res = await fetch(`${url}/rest/v1/rpc/charge_claim_pairing_code`, {
|
|
39
53
|
method: "POST",
|
|
40
54
|
headers: { apikey: anon, Authorization: `Bearer ${anon}`, "Content-Type": "application/json" },
|
|
41
|
-
body: JSON.stringify({ p_code: code, p_label:
|
|
55
|
+
body: JSON.stringify({ p_code: code, p_label: label }),
|
|
42
56
|
signal: AbortSignal.timeout(15_000),
|
|
43
57
|
});
|
|
44
58
|
const token = res.ok ? await res.json() : null;
|
|
@@ -52,7 +66,29 @@ async function pair(code) {
|
|
|
52
66
|
try { fs.chmodSync(CONF_DIR, 0o700); } catch {}
|
|
53
67
|
fs.writeFileSync(CONF, JSON.stringify({ url, anon, token }, null, 2), { mode: 0o600 });
|
|
54
68
|
try { fs.chmodSync(CONF, 0o600); } catch {}
|
|
55
|
-
console.log(
|
|
69
|
+
console.log(`✓ 페어링 완료 — 이 컴퓨터가 '${label}'(으)로 등록되었습니다.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 페어링 해제: 서버의 디바이스 토큰까지 폐기한다.
|
|
73
|
+
// 로컬 config만 지우면 그 토큰은 서버에서 계속 유효해, 유출됐을 경우 남이 업로드에 쓸 수 있다.
|
|
74
|
+
async function unpair() {
|
|
75
|
+
let conf = null;
|
|
76
|
+
try { conf = JSON.parse(fs.readFileSync(CONF, "utf8")); } catch {}
|
|
77
|
+
if (conf?.url && conf?.anon && conf?.token) {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`${conf.url}/rest/v1/rpc/charge_revoke_device`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { apikey: conf.anon, Authorization: `Bearer ${conf.anon}`, "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ p_token: conf.token }),
|
|
83
|
+
signal: AbortSignal.timeout(15_000),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) console.error(`서버 디바이스 폐기 실패 (${res.status}) — 앱 설정의 기기 목록에서 직접 제거하세요.`);
|
|
86
|
+
} catch {
|
|
87
|
+
console.error("서버 디바이스 폐기 요청에 실패했습니다 — 앱 설정의 기기 목록에서 직접 제거하세요.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
fs.rmSync(CONF, { force: true });
|
|
91
|
+
console.log("✓ 페어링 해제 (스케줄 해제는 install.sh/install.ps1 안내 참고)");
|
|
56
92
|
}
|
|
57
93
|
|
|
58
94
|
// npx 캐시는 언제든 청소될 수 있으므로, 스케줄 등록 전에 런타임을 ~/.charge/app 으로 복사한다
|
|
@@ -96,7 +132,7 @@ async function offerCcusageInstall() {
|
|
|
96
132
|
run("ccusage", ["--version"], { stdio: "ignore", timeout: 15_000 });
|
|
97
133
|
return; // 이미 설치돼 있다
|
|
98
134
|
} catch {}
|
|
99
|
-
const hint =
|
|
135
|
+
const hint = `'npm i -g ${CCUSAGE_PKG}'를 해두면 수집이 빨라집니다`;
|
|
100
136
|
// 파이프/CI처럼 물어볼 콘솔이 없으면 안내만 하고 넘어간다
|
|
101
137
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
102
138
|
console.log(`참고: ${hint} (지금은 npx 대체 실행으로 동작).`);
|
|
@@ -110,7 +146,8 @@ async function offerCcusageInstall() {
|
|
|
110
146
|
return;
|
|
111
147
|
}
|
|
112
148
|
try {
|
|
113
|
-
|
|
149
|
+
// 전역 설치도 버전을 고정한다 — @latest면 설치 시점 최신본의 라이프사이클 스크립트를 그대로 실행한다
|
|
150
|
+
run("npm", ["install", "-g", CCUSAGE_PKG], { stdio: "inherit", timeout: 300_000 });
|
|
114
151
|
console.log("✓ ccusage 설치 완료");
|
|
115
152
|
} catch {
|
|
116
153
|
console.error(
|
|
@@ -131,8 +168,7 @@ async function offerCcusageInstall() {
|
|
|
131
168
|
return;
|
|
132
169
|
}
|
|
133
170
|
if (arg === "unpair") {
|
|
134
|
-
|
|
135
|
-
console.log("✓ 페어링 해제 (스케줄 해제는 install.sh/install.ps1 안내 참고)");
|
|
171
|
+
await unpair();
|
|
136
172
|
return;
|
|
137
173
|
}
|
|
138
174
|
// 페어링 코드로 간주: 페어링 → 스케줄 등록 → 첫 수집.
|
package/collect.js
CHANGED
|
@@ -22,12 +22,14 @@ const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
|
22
22
|
// 5분마다 도는 작업이라 그냥 이어 붙이면 끝없이 자라므로 5MB에서 .old로 민다.
|
|
23
23
|
const logArg = process.argv.indexOf("--log");
|
|
24
24
|
const LOG_FILE = logArg > -1 ? process.argv[logArg + 1] : null;
|
|
25
|
-
|
|
25
|
+
// 아래 로그 리다이렉션·핸들러 등록·process.exit은 스크립트로 직접 실행될 때만 해야 한다.
|
|
26
|
+
// cli.js가 버전 상수를 require할 때 이 부작용이 딸려오면 CLI 콘솔이 파일로 새거나 조기 종료된다.
|
|
27
|
+
if (require.main === module && logArg > -1 && (!LOG_FILE || LOG_FILE.startsWith("-"))) {
|
|
26
28
|
// 값을 안 주면 조용히 로그가 꺼지고, 다음 옵션을 삼키면 "--dry-run"이라는 파일에 쓰게 된다
|
|
27
29
|
console.error("--log 뒤에는 파일 경로가 필요합니다.");
|
|
28
30
|
process.exit(1);
|
|
29
31
|
}
|
|
30
|
-
if (LOG_FILE) {
|
|
32
|
+
if (require.main === module && LOG_FILE) {
|
|
31
33
|
try {
|
|
32
34
|
if (fs.statSync(LOG_FILE).size > 5 * 1024 * 1024) fs.renameSync(LOG_FILE, `${LOG_FILE}.old`);
|
|
33
35
|
} catch {}
|
|
@@ -63,6 +65,13 @@ const EXTRA_PATH = WIN
|
|
|
63
65
|
: ":/opt/homebrew/bin:/usr/local/bin";
|
|
64
66
|
const DEFAULT_CODEXBAR_CLI = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI";
|
|
65
67
|
|
|
68
|
+
// ccusage는 정확한 버전으로 고정한다 — @latest로 5분마다 무인 실행하면 ccusage npm
|
|
69
|
+
// 계정/배포가 탈취됐을 때 별도 조작 없이 악성 코드가 모든 사용자 PC에서 돌게 된다.
|
|
70
|
+
// 이 프로세스는 Claude/Codex 자격증명이 있는 홈에서 사용자 권한으로 실행되므로 위험이 크다.
|
|
71
|
+
// 갱신은 릴리스마다 새 버전을 검토한 뒤 의도적으로만 올린다.
|
|
72
|
+
const CCUSAGE_VERSION = "20.0.19";
|
|
73
|
+
const CCUSAGE_PKG = `ccusage@${CCUSAGE_VERSION}`;
|
|
74
|
+
|
|
66
75
|
// 모든 외부 명령은 비동기로 실행한다 — 동기(execFileSync)는 이벤트 루프를 세워
|
|
67
76
|
// 진행 중인 fetch(AbortSignal 타이머 포함)를 전부 타임아웃시킨다.
|
|
68
77
|
const execOpts = (timeout) => ({
|
|
@@ -93,9 +102,10 @@ async function runCcusage(args) {
|
|
|
93
102
|
try {
|
|
94
103
|
return JSON.parse(await runAsync("ccusage", args));
|
|
95
104
|
} catch (e) {
|
|
96
|
-
console.error(`ccusage 직접 실행 실패(${e.code ?? e.message}), npx로 재시도 — 'npm i -g
|
|
105
|
+
console.error(`ccusage 직접 실행 실패(${e.code ?? e.message}), npx로 재시도 — 'npm i -g ${CCUSAGE_PKG}'를 해두면 빨라집니다`);
|
|
97
106
|
// --prefer-offline: npx 캐시가 있으면 레지스트리 조회 없이 재사용 (5분마다 재다운로드 방지)
|
|
98
|
-
|
|
107
|
+
// 버전은 CCUSAGE_PKG로 고정 — @latest 자동 추적은 공급망 탈취를 무인 실행 채널로 만든다.
|
|
108
|
+
return JSON.parse(await runAsync("npx", ["-y", "--prefer-offline", CCUSAGE_PKG, ...args], 300_000));
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
|
|
@@ -114,6 +124,9 @@ function dropExpired(w) {
|
|
|
114
124
|
|
|
115
125
|
// 캐시에서 복원한 프로바이더도 신선 수집과 같은 만료 규칙을 적용한다.
|
|
116
126
|
// 안 하면 수집이 실패하는 동안 리셋이 지난 창이 무한 재업로드된다 (유령 게이지).
|
|
127
|
+
// {...prev} 전개가 collected_at을 그대로 보존하는 것이 신선도 규칙의 핵심 —
|
|
128
|
+
// 서버는 collected_at이 더 신선한 행만 덮어쓰므로, 토큰이 만료된 기기의 캐시 폴백이
|
|
129
|
+
// 건강한 기기가 방금 올린 값을 덮어쓰지 못한다. collected_at을 여기서 갱신하면 안 된다.
|
|
117
130
|
function sanitizeCachedProvider(prev) {
|
|
118
131
|
if (!prev || typeof prev !== "object") return null;
|
|
119
132
|
const extras = (prev.extras ?? [])
|
|
@@ -251,10 +264,18 @@ async function claudeCredentials() {
|
|
|
251
264
|
}
|
|
252
265
|
}
|
|
253
266
|
|
|
254
|
-
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API
|
|
255
|
-
|
|
267
|
+
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API 조회.
|
|
268
|
+
// 반환: { provider, status } — status "ok" | "auth_expired" | "error",
|
|
269
|
+
// 자격증명 자체가 없으면(미설치) status null이라 collect_status에 항목이 안 생긴다.
|
|
270
|
+
// fetchFn/loadCredentials는 테스트 주입용 — 기본값이면 기존 동작 그대로.
|
|
271
|
+
async function claudeProvider({ fetchFn = fetch, loadCredentials = claudeCredentials } = {}) {
|
|
272
|
+
let cred;
|
|
273
|
+
try {
|
|
274
|
+
cred = await loadCredentials();
|
|
275
|
+
} catch {
|
|
276
|
+
return { provider: null, status: null }; // 자격증명 없음 = Claude Code 미설치
|
|
277
|
+
}
|
|
256
278
|
try {
|
|
257
|
-
const cred = await claudeCredentials();
|
|
258
279
|
const oauth = cred.claudeAiOauth ?? cred;
|
|
259
280
|
const token = oauth.accessToken;
|
|
260
281
|
// rateLimitTier "default_claude_max_20x" → "Max 20x", 없으면 subscriptionType "max" → "Max"
|
|
@@ -264,20 +285,21 @@ async function claudeProvider() {
|
|
|
264
285
|
: oauth.subscriptionType
|
|
265
286
|
? oauth.subscriptionType[0].toUpperCase() + oauth.subscriptionType.slice(1)
|
|
266
287
|
: null;
|
|
267
|
-
const res = await
|
|
288
|
+
const res = await fetchFn("https://api.anthropic.com/api/oauth/usage", {
|
|
268
289
|
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
269
290
|
signal: AbortSignal.timeout(10_000),
|
|
270
291
|
});
|
|
271
292
|
if (!res.ok) {
|
|
272
|
-
// 401 = 토큰 만료 (Claude Code를 한 번 실행하면 갱신됨), 그 외는 일시 장애일
|
|
293
|
+
// 401 = 토큰 만료 (Claude Code를 한 번 실행하면 갱신됨), 그 외는 일시 장애일 가능성.
|
|
294
|
+
// 이 구분이 서버 collect_status로 올라가 앱이 "재로그인 필요"를 안내할 수 있다.
|
|
273
295
|
console.error(`claude usage API ${res.status}${res.status === 401 ? " — 토큰 만료, Claude Code를 실행하면 갱신됩니다" : ""}`);
|
|
274
|
-
return null;
|
|
296
|
+
return { provider: null, status: res.status === 401 ? "auth_expired" : "error" };
|
|
275
297
|
}
|
|
276
298
|
const d = await res.json();
|
|
277
299
|
// 계정 UUID → 해시 (프로필 조회 실패 시 null — 캐시 폴백이 채워준다)
|
|
278
300
|
let account = null;
|
|
279
301
|
try {
|
|
280
|
-
const pr = await
|
|
302
|
+
const pr = await fetchFn("https://api.anthropic.com/api/oauth/profile", {
|
|
281
303
|
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
282
304
|
signal: AbortSignal.timeout(10_000),
|
|
283
305
|
});
|
|
@@ -298,17 +320,21 @@ async function claudeProvider() {
|
|
|
298
320
|
},
|
|
299
321
|
}));
|
|
300
322
|
return {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
323
|
+
provider: {
|
|
324
|
+
id: "claude",
|
|
325
|
+
name: "Claude",
|
|
326
|
+
plan,
|
|
327
|
+
account,
|
|
328
|
+
session: dropExpired(win(d.five_hour, 300)),
|
|
329
|
+
weekly: dropExpired(win(d.seven_day, 10080)),
|
|
330
|
+
extras: extras.length ? extras : null,
|
|
331
|
+
collected_at: new Date().toISOString(), // usage API 성공 시각
|
|
332
|
+
},
|
|
333
|
+
status: "ok",
|
|
308
334
|
};
|
|
309
335
|
} catch (e) {
|
|
310
336
|
console.error(`claude 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
311
|
-
return null;
|
|
337
|
+
return { provider: null, status: "error" };
|
|
312
338
|
}
|
|
313
339
|
}
|
|
314
340
|
|
|
@@ -417,26 +443,38 @@ function codexSnapshotWindows() {
|
|
|
417
443
|
window_minutes: w.window_minutes ?? null,
|
|
418
444
|
}
|
|
419
445
|
: null;
|
|
420
|
-
|
|
446
|
+
// collected_at = 로그 파일 mtime — 스냅샷은 "지금"이 아니라 마지막 사용 시점의 값이므로
|
|
447
|
+
// 수집 시각을 넣으면 다른 기기의 진짜 최신 데이터를 신선도 규칙에서 이겨버린다.
|
|
448
|
+
return { session: win(rl.primary), weekly: win(rl.secondary), collected_at: new Date(latest.m).toISOString() };
|
|
421
449
|
}
|
|
422
450
|
|
|
451
|
+
// 반환: { provider, status } — live 성공 "ok", 스냅샷 폴백 "stale",
|
|
452
|
+
// ~/.codex는 있는데 둘 다 실패면 "error", 미설치면 status null(항목 없음).
|
|
423
453
|
async function codexProvider() {
|
|
454
|
+
if (!fs.existsSync(path.join(HOME, ".codex"))) return { provider: null, status: null }; // Codex 미설치
|
|
424
455
|
try {
|
|
425
456
|
const auth = codexAuth();
|
|
426
|
-
const
|
|
427
|
-
|
|
457
|
+
const live = await codexLiveWindows(auth);
|
|
458
|
+
const snapshot = live ? null : codexSnapshotWindows();
|
|
459
|
+
const windows = live ?? snapshot;
|
|
460
|
+
if (!windows) return { provider: null, status: "error" };
|
|
428
461
|
return {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
462
|
+
provider: {
|
|
463
|
+
id: "codex",
|
|
464
|
+
name: "Codex",
|
|
465
|
+
plan: windows.plan ?? auth?.plan ?? null,
|
|
466
|
+
account: auth?.account ?? null,
|
|
467
|
+
session: dropExpired(windows.session),
|
|
468
|
+
weekly: dropExpired(windows.weekly),
|
|
469
|
+
extras: null,
|
|
470
|
+
// live면 지금이 관측 시각, 스냅샷 폴백이면 .jsonl mtime이 실제 관측 시각
|
|
471
|
+
collected_at: live ? new Date().toISOString() : snapshot.collected_at,
|
|
472
|
+
},
|
|
473
|
+
status: live ? "ok" : "stale",
|
|
436
474
|
};
|
|
437
475
|
} catch (e) {
|
|
438
476
|
console.error(`codex 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
439
|
-
return null;
|
|
477
|
+
return { provider: null, status: "error" };
|
|
440
478
|
}
|
|
441
479
|
}
|
|
442
480
|
|
|
@@ -491,15 +529,18 @@ function codexBarEntryToProvider(entry) {
|
|
|
491
529
|
});
|
|
492
530
|
|
|
493
531
|
if (!primary && !secondary && extras.length === 0) return null;
|
|
532
|
+
// 불투명 식별자(계정 ID·조직)를 이메일보다 먼저 쓴다 — account는 salt 없는 sha256의
|
|
533
|
+
// 앞 12자(48비트)라, 이메일을 넣으면 DB 유출 시 사전 대입으로 원문이 역산된다.
|
|
534
|
+
// 고엔트로피 ID는 그 대입이 통하지 않는다. 이메일은 다른 식별자가 전혀 없을 때만 폴백.
|
|
494
535
|
const rawAccount = textValue(
|
|
495
|
-
identity.accountEmail,
|
|
496
|
-
identity.email,
|
|
497
|
-
usage.accountEmail,
|
|
498
|
-
entry.accountEmail,
|
|
499
536
|
identity.accountId,
|
|
500
537
|
usage.accountId,
|
|
501
538
|
identity.accountOrganization,
|
|
502
|
-
usage.accountOrganization
|
|
539
|
+
usage.accountOrganization,
|
|
540
|
+
identity.accountEmail,
|
|
541
|
+
identity.email,
|
|
542
|
+
usage.accountEmail,
|
|
543
|
+
entry.accountEmail
|
|
503
544
|
);
|
|
504
545
|
const plan = usefulPlan(
|
|
505
546
|
usage.plan,
|
|
@@ -520,6 +561,7 @@ function codexBarEntryToProvider(entry) {
|
|
|
520
561
|
extras: extras.length ? extras : null,
|
|
521
562
|
status: null,
|
|
522
563
|
collector_source: "codexbar",
|
|
564
|
+
collected_at: new Date().toISOString(), // CodexBar CLI 성공 시각
|
|
523
565
|
};
|
|
524
566
|
}
|
|
525
567
|
|
|
@@ -542,11 +584,11 @@ function parseCodexBarJSON(raw) {
|
|
|
542
584
|
}
|
|
543
585
|
|
|
544
586
|
async function collectCodexBarProviders() {
|
|
545
|
-
if (process.env.CHARGE_DISABLE_CODEXBAR === "1") return { providers: [], complete: false };
|
|
587
|
+
if (process.env.CHARGE_DISABLE_CODEXBAR === "1") return { providers: [], complete: false, statuses: {} };
|
|
546
588
|
// 따옴표로 감싼 경로(Windows에서 흔한 습관)는 벗겨서 execFile에 그대로 넘긴다
|
|
547
589
|
const cli = process.env.CHARGE_CODEXBAR_CLI?.replace(/^"(.*)"$/s, "$1")
|
|
548
590
|
?? (!WIN && fs.existsSync(DEFAULT_CODEXBAR_CLI) ? DEFAULT_CODEXBAR_CLI : null);
|
|
549
|
-
if (!cli) return { providers: [], complete: false };
|
|
591
|
+
if (!cli) return { providers: [], complete: false, statuses: {} };
|
|
550
592
|
|
|
551
593
|
let raw = "";
|
|
552
594
|
let commandSucceeded = true;
|
|
@@ -559,22 +601,33 @@ async function collectCodexBarProviders() {
|
|
|
559
601
|
raw = e.stdout?.toString?.() ?? "";
|
|
560
602
|
if (!raw.trim()) {
|
|
561
603
|
console.error(`CodexBar 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
562
|
-
return { providers: [], complete: false };
|
|
604
|
+
return { providers: [], complete: false, statuses: {} };
|
|
563
605
|
}
|
|
564
606
|
}
|
|
565
607
|
|
|
566
608
|
const entries = parseCodexBarJSON(raw);
|
|
567
609
|
if (!entries) {
|
|
568
610
|
console.error("CodexBar 프로바이더 수집 실패: JSON 출력을 해석할 수 없습니다.");
|
|
569
|
-
return { providers: [], complete: false };
|
|
611
|
+
return { providers: [], complete: false, statuses: {} };
|
|
570
612
|
}
|
|
571
613
|
const bridgeEntries = entries.filter((entry) => !["claude", "codex"].includes(entry?.provider));
|
|
572
|
-
const providers =
|
|
573
|
-
const
|
|
574
|
-
for (const entry of bridgeEntries
|
|
575
|
-
|
|
614
|
+
const providers = [];
|
|
615
|
+
const statuses = {};
|
|
616
|
+
for (const entry of bridgeEntries) {
|
|
617
|
+
if (entry?.error) {
|
|
618
|
+
console.error(`CodexBar ${entry.provider ?? "프로바이더"} 수집 실패: ${entry.error.message ?? "알 수 없는 오류"}`);
|
|
619
|
+
// claude/codex 상태는 자체 수집기가 판정한다 — entry.id로 새어 들어와도 덮지 않는다
|
|
620
|
+
const id = textValue(entry.provider, entry.id)?.toLowerCase();
|
|
621
|
+
if (id && id !== "claude" && id !== "codex") statuses[id] = "error";
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
const provider = codexBarEntryToProvider(entry);
|
|
625
|
+
if (!provider) continue; // 창이 하나도 없는 엔트리 — 성공도 실패도 아니라 상태 미기록
|
|
626
|
+
providers.push(provider);
|
|
627
|
+
statuses[provider.id] = "ok";
|
|
576
628
|
}
|
|
577
|
-
|
|
629
|
+
const complete = commandSucceeded && !bridgeEntries.some((entry) => entry?.error);
|
|
630
|
+
return { providers, complete, statuses };
|
|
578
631
|
}
|
|
579
632
|
|
|
580
633
|
// 프로바이더 상태 페이지 (Statuspage 공용 API)
|
|
@@ -597,11 +650,16 @@ async function collectProviders() {
|
|
|
597
650
|
statusOf("https://status.openai.com/api/v2/status.json"),
|
|
598
651
|
collectCodexBarProviders(),
|
|
599
652
|
]);
|
|
600
|
-
if (claude) claude.status = claudeStatus;
|
|
601
|
-
if (codex) codex.status = codexStatus;
|
|
653
|
+
if (claude.provider) claude.provider.status = claudeStatus;
|
|
654
|
+
if (codex.provider) codex.provider.status = codexStatus;
|
|
655
|
+
// collect_status: 미설치(status null)는 항목을 만들지 않는다 — 설치된 소스만 정직하게 보고
|
|
656
|
+
const statuses = { ...codexBar.statuses };
|
|
657
|
+
if (claude.status) statuses.claude = claude.status;
|
|
658
|
+
if (codex.status) statuses.codex = codex.status;
|
|
602
659
|
return {
|
|
603
|
-
providers: [claude, codex, ...codexBar.providers].filter(Boolean),
|
|
660
|
+
providers: [claude.provider, codex.provider, ...codexBar.providers].filter(Boolean),
|
|
604
661
|
codexBarComplete: codexBar.complete,
|
|
662
|
+
statuses,
|
|
605
663
|
};
|
|
606
664
|
}
|
|
607
665
|
|
|
@@ -619,18 +677,33 @@ function resolveMode() {
|
|
|
619
677
|
}
|
|
620
678
|
|
|
621
679
|
// 디바이스 토큰으로 charge_upload RPC 호출 (서버가 본인 행에만 기록)
|
|
622
|
-
async function pairedUpload(mode, daily, live, providers) {
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
680
|
+
async function pairedUpload(mode, daily, live, providers, collectStatus = null) {
|
|
681
|
+
const post = (body) =>
|
|
682
|
+
fetch(`${mode.url}/rest/v1/rpc/charge_upload`, {
|
|
683
|
+
method: "POST",
|
|
684
|
+
headers: {
|
|
685
|
+
apikey: mode.anon,
|
|
686
|
+
Authorization: `Bearer ${mode.anon}`,
|
|
687
|
+
"Content-Type": "application/json",
|
|
688
|
+
},
|
|
689
|
+
body: JSON.stringify(body),
|
|
690
|
+
signal: AbortSignal.timeout(30_000),
|
|
691
|
+
});
|
|
692
|
+
const body = { p_token: mode.token, p_daily: daily, p_live: live, p_providers: providers, p_collect_status: collectStatus };
|
|
693
|
+
let res = await post(body);
|
|
694
|
+
if (res.ok) return;
|
|
695
|
+
const text = await res.text();
|
|
696
|
+
// 서버에 5번째 파라미터(p_collect_status)가 아직 배포 전이면 PostgREST가 함수
|
|
697
|
+
// 시그니처를 못 찾아 404/PGRST202를 낸다 — 수집기가 먼저 업데이트된 배포 순서
|
|
698
|
+
// 역전 대비로, 구버전 시그니처(파라미터 제외)로 1회만 재시도한다.
|
|
699
|
+
if (res.status === 404 || text.includes("PGRST202")) {
|
|
700
|
+
console.error("charge_upload 구버전 서버 감지(404/PGRST202) — p_collect_status 없이 재시도합니다");
|
|
701
|
+
const { p_collect_status: _omitted, ...legacyBody } = body;
|
|
702
|
+
res = await post(legacyBody);
|
|
703
|
+
if (res.ok) return;
|
|
704
|
+
throw new Error(`업로드 실패 (${res.status}): ${await res.text()}`);
|
|
705
|
+
}
|
|
706
|
+
throw new Error(`업로드 실패 (${res.status}): ${text}`);
|
|
634
707
|
}
|
|
635
708
|
|
|
636
709
|
async function main() {
|
|
@@ -666,16 +739,25 @@ async function main() {
|
|
|
666
739
|
providerCollection = providersR.value;
|
|
667
740
|
} else {
|
|
668
741
|
console.error(`프로바이더 수집 실패, 이전 값 유지: ${providersR.reason?.message ?? providersR.reason}`);
|
|
669
|
-
|
|
742
|
+
// 수집이 통째로 죽으면 상태를 알 수 없다 — null로 보내 정직한 미상 처리 (서버도 null로 덮음)
|
|
743
|
+
providerCollection = { providers: [], codexBarComplete: false, statuses: null };
|
|
670
744
|
}
|
|
671
745
|
providers = providerCollection.providers;
|
|
672
|
-
// 일부 프로바이더만 실패해도 목록에서 사라지지 않게 이전 값으로 채운다 (앱 설정 토글 유지)
|
|
746
|
+
// 일부 프로바이더만 실패해도 목록에서 사라지지 않게 이전 값으로 채운다 (앱 설정 토글 유지).
|
|
747
|
+
// 이렇게 캐시로 채운 프로바이더의 상태는 statuses에 이미 실패(auth_expired/error 등)로
|
|
748
|
+
// 기록돼 있다 — 캐시 복원이 성공처럼 보이면 안 되므로 여기서 덮지 않는다.
|
|
673
749
|
for (const prev of cache.providers ?? []) {
|
|
674
750
|
if (providers.some((p) => p.id === prev.id)) continue;
|
|
675
751
|
// CodexBar가 정상 완료된 경우, 거기서 더 이상 반환하지 않는 항목은 사용자가 끈 것으로 본다.
|
|
676
752
|
if (providerCollection.codexBarComplete && prev.collector_source === "codexbar") continue;
|
|
677
753
|
const sanitized = sanitizeCachedProvider(prev);
|
|
678
|
-
if (sanitized)
|
|
754
|
+
if (sanitized) {
|
|
755
|
+
// 0.1.4 이전 캐시엔 collected_at이 없다. 그대로 보내면 서버가 now()로 취급해
|
|
756
|
+
// 나이 미상의 캐시가 건강한 기기의 진짜 최신 데이터를 이겨버린다 — epoch로 스탬프해
|
|
757
|
+
// "언제 것인지 모르는 데이터는 항상 진다"로 만든다 (행 유지에는 지장 없음).
|
|
758
|
+
if (!sanitized.collected_at) sanitized.collected_at = new Date(0).toISOString();
|
|
759
|
+
providers.push(sanitized);
|
|
760
|
+
}
|
|
679
761
|
}
|
|
680
762
|
// 계정 식별 실패 시 마지막으로 알려진 계정 해시 유지 (계정 미상('')과 실제 계정 행이 갈라지는 것 방지)
|
|
681
763
|
for (const p of providers) {
|
|
@@ -683,7 +765,7 @@ async function main() {
|
|
|
683
765
|
}
|
|
684
766
|
|
|
685
767
|
if (DRY_RUN) {
|
|
686
|
-
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers }, null, 2));
|
|
768
|
+
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers, collect_status: providerCollection.statuses }, null, 2));
|
|
687
769
|
console.log(`\n[dry-run] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 예정`);
|
|
688
770
|
return;
|
|
689
771
|
}
|
|
@@ -695,7 +777,7 @@ async function main() {
|
|
|
695
777
|
}
|
|
696
778
|
|
|
697
779
|
const now = new Date().toISOString();
|
|
698
|
-
await pairedUpload(mode, daily, live, providers);
|
|
780
|
+
await pairedUpload(mode, daily, live, providers, providerCollection.statuses);
|
|
699
781
|
|
|
700
782
|
fs.writeFileSync(CACHE_FILE, JSON.stringify({ daily, live, providers }));
|
|
701
783
|
console.log(`[${now}] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 완료`);
|
|
@@ -709,7 +791,9 @@ if (require.main === module) {
|
|
|
709
791
|
}
|
|
710
792
|
|
|
711
793
|
module.exports = {
|
|
794
|
+
CCUSAGE_PKG,
|
|
712
795
|
accountHash,
|
|
796
|
+
claudeProvider,
|
|
713
797
|
codexBarEntryToProvider,
|
|
714
798
|
collectCodexBarProviders,
|
|
715
799
|
dropExpired,
|