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.
Files changed (2) hide show
  1. package/collect.js +83 -47
  2. 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 { execFileSync } = require("node:child_process");
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
- function run(cmd, args, timeout = 120_000) {
21
- return execFileSync(cmd, args, {
22
- encoding: "utf8",
23
- maxBuffer: 64 * 1024 * 1024,
24
- timeout,
25
- env: { ...process.env, PATH: `${process.env.PATH}${EXTRA_PATH}` },
26
- // Windows에서 npx/ccusage는 .cmd 셔틀이라 셸을 거쳐야 실행된다 (인자는 모두 고정 문자열)
27
- shell: WIN,
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(run("ccusage", args));
42
+ return JSON.parse(await runAsync("ccusage", args));
35
43
  } catch (e) {
36
- console.error(`ccusage 직접 실행 실패(${e.code ?? e.message}), npx로 재시도`);
37
- return JSON.parse(run("npx", ["-y", "ccusage@latest", ...args], 300_000));
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(run("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"]));
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) return null;
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
- raw = run(cli, ["usage", "--format", "json"], 90_000);
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 claudePromise = claudeProvider();
510
- const claudeStatusPromise = statusOf("https://status.anthropic.com/api/v2/status.json");
511
- const codexStatusPromise = statusOf("https://status.openai.com/api/v2/status.json");
512
- const codexPromise = codexProvider();
513
- const codexBar = collectCodexBarProviders();
514
- const [claude, codex, claudeStatus, codexStatus] = await Promise.all([
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
- try {
565
- daily = collectDaily();
566
- } catch (e) {
567
- console.error(`daily 수집 실패, 이전 값 유지: ${e.message ?? e}`);
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
- try {
571
- live = collectLive();
572
- } catch (e) {
573
- console.error(`live 수집 실패, 이전 값 유지: ${e.message ?? e}`);
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
- try {
578
- providerCollection = await collectProviders();
579
- } catch (e) {
580
- console.error(`프로바이더 수집 실패, 이전 값 유지: ${e.message ?? e}`);
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
- providers.push(prev);
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "charge-connect",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Connects your computer to the Charge iPhone app and uploads AI coding usage",
5
5
  "bin": {
6
6
  "charge-connect": "cli.js"