charge-connect 0.2.1 → 0.2.4

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 +140 -37
  2. package/package.json +1 -1
package/collect.js CHANGED
@@ -21,6 +21,11 @@ const HEALTH_FILE = path.join(__dirname, ".collection-health.json");
21
21
  // Claude usage API 429 게이트와 마지막 usage 요청 시각, 결과 (C1, D12, D13). 프로필 조회로 얻은 계정 해시 (D3)
22
22
  const RATE_LIMIT_FILE = path.join(__dirname, ".claude-rate-limit.json");
23
23
  const CLAUDE_ACCOUNT_FILE = path.join(__dirname, ".claude-account.json");
24
+ // 서버가 이 기기의 토큰을 모른다고 답한 기록(앱에서 기기 삭제, 계정 삭제). 토큰 해시와 다음 재시도 시각.
25
+ const REVOKED_FILE = path.join(__dirname, ".device-revoked.json");
26
+ // 폐기된 토큰으로는 하루 한 번만 업로드를 시도한다. 5분마다 400을 받아 서버 에러 로그를 채우지 않으면서도,
27
+ // 서버 쪽 복구(백업 복원 등)로 토큰이 되살아나면 늦어도 하루 안에 저절로 재개된다.
28
+ const REVOKED_RETRY_MS = 24 * 3600_000;
24
29
  const HOME = process.env.HOME ?? process.env.USERPROFILE;
25
30
 
26
31
  // 스케줄러가 부를 때는 볼 콘솔이 없으니 출력을 직접 파일로 받는다.
@@ -274,6 +279,14 @@ function durationLabel(minutes) {
274
279
  return null;
275
280
  }
276
281
 
282
+ // CodexBar 창 길이(분). 이 한 값이 저장되는 window_minutes이자 같은 길이 라벨 중복 판정의 키다.
283
+ // percent와 달리 평범한 ?? 체인이다: windowMinutes가 ""면 뒤 필드로 넘어가지 않고 미상(null)이 된다.
284
+ function windowMinutesOf(window) {
285
+ const raw = window.windowMinutes ?? window.window_minutes ?? window.durationMinutes;
286
+ const parsed = raw == null ? null : Number(raw);
287
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
288
+ }
289
+
277
290
  function explicitWindowLabel(window) {
278
291
  const value = window?.label ?? window?.title ?? window?.displayName ?? window?.name ?? window?.limitName;
279
292
  return typeof value === "string" && value.trim() ? value.trim() : null;
@@ -289,9 +302,7 @@ function normalizeRateWindow(window, slot, duplicateDuration = false, forcedLabe
289
302
  .map(percentValue)
290
303
  .find((value) => value !== null);
291
304
  if (percent === undefined) return null;
292
- const rawMinutes = window.windowMinutes ?? window.window_minutes ?? window.durationMinutes;
293
- const parsedMinutes = rawMinutes == null ? null : Number(rawMinutes);
294
- const minutes = Number.isFinite(parsedMinutes) && parsedMinutes > 0 ? parsedMinutes : null;
305
+ const minutes = windowMinutesOf(window);
295
306
  const baseLabel = durationLabel(minutes);
296
307
  const slotLabel = slot[0].toUpperCase() + slot.slice(1);
297
308
  const label = forcedLabel
@@ -917,13 +928,18 @@ async function claudeProvider({
917
928
  try {
918
929
  const oauth = cred.claudeAiOauth ?? cred;
919
930
  const token = oauth.accessToken;
920
- // rateLimitTier "default_claude_max_20x" → "Max 20x", 없으면 subscriptionType "max" → "Max"
921
- const tier = /max_(\d+)x/.exec(oauth.rateLimitTier ?? "");
922
- const plan = tier
923
- ? `Max ${tier[1]}x`
924
- : oauth.subscriptionType
925
- ? oauth.subscriptionType[0].toUpperCase() + oauth.subscriptionType.slice(1)
926
- : null;
931
+ // Anthropic이 요금제 이름을 바꿨다: 옛 Max 20x는 이제 Ultra, Max 5x는 그냥 Max다.
932
+ // rateLimitTier "default_claude_max_20x" → "Ultra", "..._max_5x" → "Max", "ultra"가 들어 있으면 "Ultra",
933
+ // 모르는 max_<n>x는 "Max <n>x" 그대로 둔다. 티어가 없으면 subscriptionType "max" → "Max"
934
+ const rateLimitTier = oauth.rateLimitTier ?? "";
935
+ const tier = /max_(\d+)x/.exec(rateLimitTier);
936
+ const plan = /ultra/i.test(rateLimitTier) || tier?.[1] === "20"
937
+ ? "Ultra"
938
+ : tier
939
+ ? tier[1] === "5" ? "Max" : `Max ${tier[1]}x`
940
+ : oauth.subscriptionType
941
+ ? oauth.subscriptionType[0].toUpperCase() + oauth.subscriptionType.slice(1)
942
+ : null;
927
943
  // 자격증명 키: 리프레시 토큰(없으면 액세스 토큰)의 해시. 계정 캐시와 429 게이트가 쓰고, 앞 8자는 로그의 세대 표시다.
928
944
  const credential = credentialKey(typeof oauth.refreshToken === "string" && oauth.refreshToken ? oauth.refreshToken : token);
929
945
  const generation = credential ? credential.slice(0, 8) : null;
@@ -1333,20 +1349,18 @@ function codexBarEntryToProvider(entry) {
1333
1349
  : entry.identity && typeof entry.identity === "object" ? entry.identity : {};
1334
1350
  const slots = ["primary", "secondary", "tertiary"]
1335
1351
  .map((slot) => ({ slot, value: usage[slot] ?? entry[slot] }))
1336
- .filter(({ value }) => value && typeof value === "object");
1352
+ .filter(({ value }) => value && typeof value === "object")
1353
+ .map((item) => ({ ...item, minutes: windowMinutesOf(item.value) }));
1354
+ // 중복 길이는 나중에 percent가 없어 버려질 칸까지 모든 칸에서 센다
1337
1355
  const durationCounts = new Map();
1338
- for (const { value } of slots) {
1339
- const minutes = Number(value.windowMinutes ?? value.window_minutes ?? value.durationMinutes);
1340
- if (Number.isFinite(minutes)) durationCounts.set(minutes, (durationCounts.get(minutes) ?? 0) + 1);
1356
+ for (const { minutes } of slots) {
1357
+ if (minutes !== null) durationCounts.set(minutes, (durationCounts.get(minutes) ?? 0) + 1);
1341
1358
  }
1342
1359
  const normalizedWindows = slots
1343
- .map(({ slot, value }) => {
1344
- const minutes = Number(value.windowMinutes ?? value.window_minutes ?? value.durationMinutes);
1345
- return {
1346
- slot,
1347
- window: normalizeRateWindow(value, slot, (durationCounts.get(minutes) ?? 0) > 1),
1348
- };
1349
- })
1360
+ .map(({ slot, value, minutes }) => ({
1361
+ slot,
1362
+ window: normalizeRateWindow(value, slot, minutes !== null && durationCounts.get(minutes) > 1),
1363
+ }))
1350
1364
  .filter(({ window }) => window);
1351
1365
  const primary = normalizedWindows.find(({ slot }) => slot === "primary")?.window ?? null;
1352
1366
  const secondary = normalizedWindows.find(({ slot }) => slot === "secondary")?.window ?? null;
@@ -1462,6 +1476,35 @@ async function collectCodexBarProviders() {
1462
1476
  providers.push(provider);
1463
1477
  statuses[provider.id] = "ok";
1464
1478
  }
1479
+
1480
+ // CodexBar's automatic source can select Antigravity OAuth even when that
1481
+ // source returns identity but no quota windows. Antigravity's CLI source may
1482
+ // still have the usable limits, so retry that provider explicitly before
1483
+ // leaving an older server snapshot as the only visible data.
1484
+ const sawAntigravity = bridgeEntries.some(
1485
+ (entry) => textValue(entry?.provider, entry?.id)?.toLowerCase() === "antigravity",
1486
+ );
1487
+ if (sawAntigravity && !providers.some((provider) => provider.id === "antigravity")) {
1488
+ try {
1489
+ const fallbackRaw = await runAsync(cli, [
1490
+ "usage", "--provider", "antigravity", "--source", "cli", "--format", "json",
1491
+ ], 90_000);
1492
+ const fallbackEntries = parseCodexBarJSON(fallbackRaw);
1493
+ const fallback = fallbackEntries
1494
+ ?.find((entry) => textValue(entry?.provider, entry?.id)?.toLowerCase() === "antigravity");
1495
+ const provider = codexBarEntryToProvider(fallback);
1496
+ if (provider) {
1497
+ providers.push(provider);
1498
+ statuses.antigravity = "ok";
1499
+ } else if (fallback?.error) {
1500
+ console.error(`CodexBar Antigravity CLI 폴백 실패: ${fallback.error.message ?? "알 수 없는 오류"}`);
1501
+ statuses.antigravity ??= "error";
1502
+ }
1503
+ } catch (e) {
1504
+ console.error(`CodexBar Antigravity CLI 폴백 실패: ${e.message ?? e}`);
1505
+ statuses.antigravity ??= "error";
1506
+ }
1507
+ }
1465
1508
  const complete = commandSucceeded && !bridgeEntries.some((entry) => entry?.error);
1466
1509
  return { providers, complete, statuses };
1467
1510
  }
@@ -1617,23 +1660,13 @@ function advanceCollectionHealth(statuses, previous = {}, now = Date.now(), held
1617
1660
  function recordCollectionHealth(statuses, {
1618
1661
  file = HEALTH_FILE, scope = null, now = Date.now(), persist = true, held = [],
1619
1662
  } = {}) {
1620
- let previous = {};
1621
- try {
1622
- const saved = JSON.parse(fs.readFileSync(file, "utf8"));
1623
- if (saved.scope === scope) previous = saved.failures;
1624
- } catch {}
1663
+ // readStateFile은 JSON null·숫자도 null로 돌려주므로 ?.가 꼭 필요하다 (예전엔 catch가 삼켰다)
1664
+ const saved = readStateFile(file);
1665
+ const previous = saved?.scope === scope ? saved.failures : {};
1625
1666
  const next = advanceCollectionHealth(statuses, previous, now, held);
1626
1667
  if (persist) {
1627
1668
  // Save independently of the payload: an upload failure must not erase attempts.
1628
- const temporary = `${file}.${process.pid}.tmp`;
1629
- try {
1630
- fs.writeFileSync(temporary, JSON.stringify({ scope, failures: next.failures }), { mode: 0o600 });
1631
- fs.renameSync(temporary, file);
1632
- } catch {
1633
- console.error("수집 실패 횟수를 저장하지 못했습니다");
1634
- } finally {
1635
- try { fs.rmSync(temporary, { force: true }); } catch {}
1636
- }
1669
+ if (!writeStateFile(file, { scope, failures: next.failures })) console.error("수집 실패 횟수를 저장하지 못했습니다");
1637
1670
  }
1638
1671
  return next.statuses;
1639
1672
  }
@@ -1739,11 +1772,60 @@ async function pairedUpload(mode, daily, live, providers, collectStatus = null,
1739
1772
  console.error("구버전 시그니처로 업로드 성공, collect_status는 이번 사이클 미반영(다음 사이클에 복구)");
1740
1773
  return;
1741
1774
  }
1742
- throw new Error(`업로드 실패 (${res.status}): ${await res.text()}`);
1775
+ throw uploadFailure(res.status, await res.text());
1776
+ }
1777
+ throw uploadFailure(res.status, text);
1778
+ }
1779
+
1780
+ // charge_upload는 토큰 해시가 charge_devices에 없을 때만 'invalid device token'(P0001, 400)을 낸다.
1781
+ // 그 행은 앱의 기기 삭제, 계정 삭제, 재페어링으로만 사라지므로 이 응답은 "이 토큰은 끝났다"는 뜻이다.
1782
+ // 일시 장애(5xx, 네트워크)와 구분하려고 예외에 표시만 남기고, 처리는 main이 한다.
1783
+ function uploadFailure(status, text) {
1784
+ const err = new Error(`업로드 실패 (${status}): ${text}`);
1785
+ if (status === 400 && /invalid device token/i.test(String(text ?? ""))) err.deviceRevoked = true;
1786
+ return err;
1787
+ }
1788
+
1789
+ // 폐기 기록은 토큰 해시에 묶는다. 앱에서 다시 페어링하면 config.json의 토큰이 바뀌어 기록이 저절로 무효가 되고,
1790
+ // 스케줄(launchd, systemd, 작업 스케줄러)과 페어링 파일은 건드리지 않는다. 서버 쪽 사정(복원, 이전)으로
1791
+ // 잠시 행이 비었던 경우에 수집기가 스스로를 지워 버리면 사용자가 손으로 되살려야 하기 때문이다.
1792
+ function readRevocation(file = REVOKED_FILE) {
1793
+ try {
1794
+ const r = JSON.parse(fs.readFileSync(file, "utf8"));
1795
+ if (typeof r?.token_hash !== "string" || !Number.isFinite(Date.parse(r?.retry_at))) return null;
1796
+ return r;
1797
+ } catch {
1798
+ return null;
1743
1799
  }
1744
- throw new Error(`업로드 실패 (${res.status}): ${text}`);
1745
1800
  }
1746
1801
 
1802
+ // 반환: { parked: boolean, retryAt?: string }. parked면 이번 실행은 수집, 업로드를 건너뛴다.
1803
+ function revocationState({ token, file = REVOKED_FILE, now = Date.now() } = {}) {
1804
+ const r = readRevocation(file);
1805
+ if (!r || !token || r.token_hash !== accountHash(token)) return { parked: false };
1806
+ if (Date.parse(r.retry_at) <= now) return { parked: false, retryAt: r.retry_at };
1807
+ return { parked: true, retryAt: r.retry_at };
1808
+ }
1809
+
1810
+ function recordRevocation({ token, file = REVOKED_FILE, now = Date.now() } = {}) {
1811
+ const record = {
1812
+ token_hash: accountHash(token),
1813
+ revoked_at: new Date(now).toISOString(),
1814
+ retry_at: new Date(now + REVOKED_RETRY_MS).toISOString(),
1815
+ };
1816
+ fs.writeFileSync(file, JSON.stringify(record));
1817
+ return record;
1818
+ }
1819
+
1820
+ function clearRevocation(file = REVOKED_FILE) {
1821
+ fs.rmSync(file, { force: true });
1822
+ }
1823
+
1824
+ const REVOKED_GUIDANCE =
1825
+ "앱에서 이 컴퓨터가 삭제됐습니다(또는 계정이 삭제됐습니다). 다시 연결하려면 앱에서 코드를 발급받아 " +
1826
+ "`npx charge-connect <코드>`를 실행하세요. 수집을 완전히 끄려면 `npx charge-connect unpair` 뒤 " +
1827
+ "install.sh/install.ps1의 스케줄 해제 안내를 따르세요.";
1828
+
1747
1829
  async function main() {
1748
1830
  const mode = resolveMode();
1749
1831
  // Scheduler environments do not inherit the shell used for pairing. Restore
@@ -1753,6 +1835,17 @@ async function main() {
1753
1835
  process.env[key] = mode.claude_environment[key];
1754
1836
  }
1755
1837
  }
1838
+ // 서버가 토큰을 폐기했다고 답한 뒤 하루 동안은 수집도 업로드도 하지 않는다. 수집은 Claude 사용량 API 호출과
1839
+ // 폴링 리스(charge_claim_poll, 같은 토큰이라 역시 거부된다)를 포함하므로 업로드만 거를 수는 없다.
1840
+ // 업데이트 확인은 그대로 한다: 이 상태를 다루는 방식이 바뀐 릴리스도 받아야 한다. dry-run은 건너뛰지 않는다.
1841
+ if (!DRY_RUN && mode) {
1842
+ const revoked = revocationState({ token: mode.token });
1843
+ if (revoked.parked) {
1844
+ console.error(`[${new Date().toISOString()}] 폐기된 기기 토큰이라 수집을 쉽니다 (다음 시도 ${revoked.retryAt}). ${REVOKED_GUIDANCE}`);
1845
+ await runAutoUpdate(mode);
1846
+ return;
1847
+ }
1848
+ }
1756
1849
  // 이전 성공 페이로드 캐시 — 일부 수집이 실패해도 그 부분만 이전 값으로 유지
1757
1850
  let cache = {};
1758
1851
  try {
@@ -1826,8 +1919,13 @@ async function main() {
1826
1919
  // 캐시에는 업로드 목록이 아니라 캐시 목록을 쓴다 (shared 사이클에도 마지막 정상 Claude 값을 지우지 않는다)
1827
1920
  fs.writeFileSync(CACHE_FILE, JSON.stringify({ daily, live, providers: cacheProviders }));
1828
1921
  console.log(`[${now}] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 완료`);
1922
+ clearRevocation();
1829
1923
  } catch (e) {
1830
1924
  uploadError = e;
1925
+ if (e?.deviceRevoked) {
1926
+ const record = recordRevocation({ token: mode.token });
1927
+ console.error(`[${now}] 서버가 이 기기의 토큰을 폐기했습니다. ${record.retry_at}까지 수집을 쉽니다. ${REVOKED_GUIDANCE}`);
1928
+ }
1831
1929
  }
1832
1930
  // 업로드가 끝난 뒤(실패했더라도) 업데이트를 확인한다. 서버 계약이 바뀌어 업로드가 계속 실패하는
1833
1931
  // 구버전 수집기도 새 버전을 받아 스스로 복구할 수 있어야 한다.
@@ -1879,6 +1977,11 @@ module.exports = {
1879
1977
  readClaudeAccountUuid,
1880
1978
  readClaudeRequestState,
1881
1979
  recordCollectionHealth,
1980
+ recordRevocation,
1981
+ revocationState,
1982
+ clearRevocation,
1983
+ uploadFailure,
1984
+ REVOKED_RETRY_MS,
1882
1985
  resolveMode,
1883
1986
  runAsync,
1884
1987
  sanitizeCachedProvider,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "charge-connect",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
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"