charge-connect 0.2.2 → 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 +111 -37
  2. package/package.json +2 -2
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;
@@ -1646,23 +1660,13 @@ function advanceCollectionHealth(statuses, previous = {}, now = Date.now(), held
1646
1660
  function recordCollectionHealth(statuses, {
1647
1661
  file = HEALTH_FILE, scope = null, now = Date.now(), persist = true, held = [],
1648
1662
  } = {}) {
1649
- let previous = {};
1650
- try {
1651
- const saved = JSON.parse(fs.readFileSync(file, "utf8"));
1652
- if (saved.scope === scope) previous = saved.failures;
1653
- } catch {}
1663
+ // readStateFile은 JSON null·숫자도 null로 돌려주므로 ?.가 꼭 필요하다 (예전엔 catch가 삼켰다)
1664
+ const saved = readStateFile(file);
1665
+ const previous = saved?.scope === scope ? saved.failures : {};
1654
1666
  const next = advanceCollectionHealth(statuses, previous, now, held);
1655
1667
  if (persist) {
1656
1668
  // Save independently of the payload: an upload failure must not erase attempts.
1657
- const temporary = `${file}.${process.pid}.tmp`;
1658
- try {
1659
- fs.writeFileSync(temporary, JSON.stringify({ scope, failures: next.failures }), { mode: 0o600 });
1660
- fs.renameSync(temporary, file);
1661
- } catch {
1662
- console.error("수집 실패 횟수를 저장하지 못했습니다");
1663
- } finally {
1664
- try { fs.rmSync(temporary, { force: true }); } catch {}
1665
- }
1669
+ if (!writeStateFile(file, { scope, failures: next.failures })) console.error("수집 실패 횟수를 저장하지 못했습니다");
1666
1670
  }
1667
1671
  return next.statuses;
1668
1672
  }
@@ -1768,11 +1772,60 @@ async function pairedUpload(mode, daily, live, providers, collectStatus = null,
1768
1772
  console.error("구버전 시그니처로 업로드 성공, collect_status는 이번 사이클 미반영(다음 사이클에 복구)");
1769
1773
  return;
1770
1774
  }
1771
- throw new Error(`업로드 실패 (${res.status}): ${await res.text()}`);
1775
+ throw uploadFailure(res.status, await res.text());
1772
1776
  }
1773
- throw new Error(`업로드 실패 (${res.status}): ${text}`);
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;
1774
1787
  }
1775
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;
1799
+ }
1800
+ }
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
+
1776
1829
  async function main() {
1777
1830
  const mode = resolveMode();
1778
1831
  // Scheduler environments do not inherit the shell used for pairing. Restore
@@ -1782,6 +1835,17 @@ async function main() {
1782
1835
  process.env[key] = mode.claude_environment[key];
1783
1836
  }
1784
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
+ }
1785
1849
  // 이전 성공 페이로드 캐시 — 일부 수집이 실패해도 그 부분만 이전 값으로 유지
1786
1850
  let cache = {};
1787
1851
  try {
@@ -1855,8 +1919,13 @@ async function main() {
1855
1919
  // 캐시에는 업로드 목록이 아니라 캐시 목록을 쓴다 (shared 사이클에도 마지막 정상 Claude 값을 지우지 않는다)
1856
1920
  fs.writeFileSync(CACHE_FILE, JSON.stringify({ daily, live, providers: cacheProviders }));
1857
1921
  console.log(`[${now}] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 완료`);
1922
+ clearRevocation();
1858
1923
  } catch (e) {
1859
1924
  uploadError = e;
1925
+ if (e?.deviceRevoked) {
1926
+ const record = recordRevocation({ token: mode.token });
1927
+ console.error(`[${now}] 서버가 이 기기의 토큰을 폐기했습니다. ${record.retry_at}까지 수집을 쉽니다. ${REVOKED_GUIDANCE}`);
1928
+ }
1860
1929
  }
1861
1930
  // 업로드가 끝난 뒤(실패했더라도) 업데이트를 확인한다. 서버 계약이 바뀌어 업로드가 계속 실패하는
1862
1931
  // 구버전 수집기도 새 버전을 받아 스스로 복구할 수 있어야 한다.
@@ -1908,6 +1977,11 @@ module.exports = {
1908
1977
  readClaudeAccountUuid,
1909
1978
  readClaudeRequestState,
1910
1979
  recordCollectionHealth,
1980
+ recordRevocation,
1981
+ revocationState,
1982
+ clearRevocation,
1983
+ uploadFailure,
1984
+ REVOKED_RETRY_MS,
1911
1985
  resolveMode,
1912
1986
  runAsync,
1913
1987
  sanitizeCachedProvider,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "charge-connect",
3
- "version": "0.2.2",
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"
@@ -26,4 +26,4 @@
26
26
  "type": "git",
27
27
  "url": "git+https://github.com/baekdusan/charge.git"
28
28
  }
29
- }
29
+ }