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.
- package/collect.js +140 -37
- 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
|
|
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
|
-
//
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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 {
|
|
1339
|
-
|
|
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
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
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
|
-
|
|
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
|
|
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,
|