charge-connect 0.1.8 → 0.1.9
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 +6 -1
- package/collect.js +102 -11
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -189,7 +189,12 @@ async function pair(code) {
|
|
|
189
189
|
// 디바이스 토큰은 본인만 읽을 수 있게 저장 (0700/0600)
|
|
190
190
|
fs.mkdirSync(CONF_DIR, { recursive: true, mode: 0o700 });
|
|
191
191
|
try { fs.chmodSync(CONF_DIR, 0o700); } catch {}
|
|
192
|
-
|
|
192
|
+
const claude_environment = Object.fromEntries(
|
|
193
|
+
["CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR"]
|
|
194
|
+
.filter((key) => process.env[key] !== undefined)
|
|
195
|
+
.map((key) => [key, process.env[key]])
|
|
196
|
+
);
|
|
197
|
+
fs.writeFileSync(CONF, JSON.stringify({ url, anon, token, install_id: installationID, claude_environment }, null, 2), { mode: 0o600 });
|
|
193
198
|
try { fs.chmodSync(CONF, 0o600); } catch {}
|
|
194
199
|
console.log(`✓ 페어링 완료 — 이 컴퓨터가 '${label}'(으)로 등록되었습니다.`);
|
|
195
200
|
}
|
package/collect.js
CHANGED
|
@@ -16,6 +16,7 @@ const daysArg = process.argv.indexOf("--days");
|
|
|
16
16
|
const parsedDays = daysArg > -1 ? parseInt(process.argv[daysArg + 1], 10) : NaN;
|
|
17
17
|
const DAYS = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 60;
|
|
18
18
|
const CACHE_FILE = path.join(__dirname, ".last-payload.json");
|
|
19
|
+
const HEALTH_FILE = path.join(__dirname, ".collection-health.json");
|
|
19
20
|
const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
20
21
|
|
|
21
22
|
// 스케줄러가 부를 때는 볼 콘솔이 없으니 출력을 직접 파일로 받는다.
|
|
@@ -314,28 +315,50 @@ function freshestCredentials(sources) {
|
|
|
314
315
|
return sources.reduce((a, b) => (expiresAt(b) > expiresAt(a) ? b : a));
|
|
315
316
|
}
|
|
316
317
|
|
|
317
|
-
|
|
318
|
+
function claudeCredentialLocation(env = process.env, home = HOME) {
|
|
319
|
+
const configDir = (env.CLAUDE_CONFIG_DIR || path.join(home, ".claude")).normalize("NFC");
|
|
320
|
+
// Claude Code supports a separate credential store; an explicit empty override
|
|
321
|
+
// selects the default store even when CLAUDE_CONFIG_DIR points elsewhere.
|
|
322
|
+
const override = env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
323
|
+
const storageDir = (override !== undefined ? override || path.join(home, ".claude") : configDir).normalize("NFC");
|
|
324
|
+
const scoped = override !== undefined ? !!override : !!env.CLAUDE_CONFIG_DIR;
|
|
325
|
+
const suffix = scoped
|
|
326
|
+
? `-${require("node:crypto").createHash("sha256").update(storageDir).digest("hex").slice(0, 8)}` : "";
|
|
327
|
+
return { configDir, file: path.join(storageDir, ".credentials.json"), service: `Claude Code-credentials${suffix}` };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function claudeCredentials({ env = process.env, home = HOME, run = runAsync, read = fs.readFileSync } = {}) {
|
|
331
|
+
const location = claudeCredentialLocation(env, home);
|
|
318
332
|
const sources = [];
|
|
319
333
|
try {
|
|
320
|
-
sources.push(JSON.parse(await
|
|
334
|
+
sources.push(JSON.parse(await run("security", ["find-generic-password", "-s", location.service, "-w"], 10_000)));
|
|
321
335
|
} catch {}
|
|
322
336
|
try {
|
|
323
|
-
sources.push(JSON.parse(
|
|
337
|
+
sources.push(JSON.parse(read(location.file, "utf8")));
|
|
324
338
|
} catch {}
|
|
325
|
-
|
|
326
|
-
|
|
339
|
+
const usable = sources.filter((source) => typeof (source?.claudeAiOauth ?? source)?.accessToken === "string"
|
|
340
|
+
&& (source.claudeAiOauth ?? source).accessToken.trim());
|
|
341
|
+
if (!usable.length) throw new Error("Claude Code 구독 로그인 정보를 읽을 수 없음");
|
|
342
|
+
return freshestCredentials(usable);
|
|
327
343
|
}
|
|
328
344
|
|
|
329
345
|
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API 조회.
|
|
330
|
-
// 반환: { provider, status } —
|
|
331
|
-
//
|
|
346
|
+
// 반환: { provider, status } — "ok", "auth_expired", or "error[:reason]".
|
|
347
|
+
// 설치 흔적이 없으면 status null; 설치 흔적은 있지만 로그인 정보를 못 읽으면 설정 안내.
|
|
332
348
|
// fetchFn/loadCredentials는 테스트 주입용 — 기본값이면 기존 동작 그대로.
|
|
333
|
-
async function claudeProvider({
|
|
349
|
+
async function claudeProvider({
|
|
350
|
+
fetchFn = fetch, loadCredentials = claudeCredentials,
|
|
351
|
+
hasClaude = () => fs.existsSync(claudeCredentialLocation().configDir),
|
|
352
|
+
} = {}) {
|
|
334
353
|
let cred;
|
|
335
354
|
try {
|
|
336
355
|
cred = await loadCredentials();
|
|
337
356
|
} catch {
|
|
338
|
-
|
|
357
|
+
// A config directory without readable subscription credentials is a setup
|
|
358
|
+
// issue, not proof that Claude Code is absent. Surface it even on first use.
|
|
359
|
+
const detected = hasClaude();
|
|
360
|
+
if (detected) console.error("Claude Code 구독 로그인 정보를 읽을 수 없습니다 — 이 PC의 Claude Code에서 /status와 /login을 확인하세요 (API 키 계정은 구독 한도 조회 미지원)");
|
|
361
|
+
return { provider: null, status: detected ? "error:credentials_missing" : null };
|
|
339
362
|
}
|
|
340
363
|
try {
|
|
341
364
|
const oauth = cred.claudeAiOauth ?? cred;
|
|
@@ -355,7 +378,10 @@ async function claudeProvider({ fetchFn = fetch, loadCredentials = claudeCredent
|
|
|
355
378
|
// 401 = 토큰 만료 (Claude Code를 한 번 실행하면 갱신됨), 그 외는 일시 장애일 가능성.
|
|
356
379
|
// 이 구분이 서버 collect_status로 올라가 앱이 "재로그인 필요"를 안내할 수 있다.
|
|
357
380
|
console.error(`claude usage API ${res.status}${res.status === 401 ? " — 토큰 만료, Claude Code를 실행하면 갱신됩니다" : ""}`);
|
|
358
|
-
|
|
381
|
+
const status = res.status === 401 ? "auth_expired"
|
|
382
|
+
: res.status === 403 ? "error:access_denied"
|
|
383
|
+
: res.status === 429 ? "error:rate_limited" : "error";
|
|
384
|
+
return { provider: null, status };
|
|
359
385
|
}
|
|
360
386
|
const d = await res.json();
|
|
361
387
|
// 계정 UUID → 해시 (프로필 조회 실패 시 null — 캐시 폴백이 채워준다)
|
|
@@ -857,6 +883,56 @@ function namespaceUnknownAccounts(providers, installationID) {
|
|
|
857
883
|
: { ...p, account: unknownAccountKey(installationID, p.id) }));
|
|
858
884
|
}
|
|
859
885
|
|
|
886
|
+
// Count actual collection cycles, not app refreshes or retries within one request.
|
|
887
|
+
// A long gap (sleep/offline), recovery, or missing observation breaks the streak.
|
|
888
|
+
// Keep the status a string with its original prefix so existing apps/servers can read it.
|
|
889
|
+
function advanceCollectionHealth(statuses, previous = {}, now = Date.now()) {
|
|
890
|
+
if (statuses === null) return { statuses: null, failures: {} };
|
|
891
|
+
const failures = {};
|
|
892
|
+
const annotated = {};
|
|
893
|
+
for (const [id, value] of Object.entries(statuses)) {
|
|
894
|
+
const status = value.split(";")[0];
|
|
895
|
+
if (!status.startsWith("error") && !status.startsWith("auth_expired")) {
|
|
896
|
+
annotated[id] = status;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const prev = previous?.[id];
|
|
900
|
+
const continues = Number.isSafeInteger(prev?.count) && prev.count > 0
|
|
901
|
+
&& Number.isFinite(prev.since) && prev.since <= prev.lastAttempt
|
|
902
|
+
&& Number.isFinite(prev.lastAttempt) && now >= prev.lastAttempt
|
|
903
|
+
&& now - prev.lastAttempt <= 12 * 60_000;
|
|
904
|
+
const count = continues ? Math.min(prev.count + 1, 9999) : 1;
|
|
905
|
+
const since = continues ? prev.since : now;
|
|
906
|
+
failures[id] = { count, since, lastAttempt: now };
|
|
907
|
+
annotated[id] = `${status};failures=${count};since=${Math.floor(since / 1000)}`;
|
|
908
|
+
}
|
|
909
|
+
return { statuses: annotated, failures };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function recordCollectionHealth(statuses, {
|
|
913
|
+
file = HEALTH_FILE, scope = null, now = Date.now(), persist = true,
|
|
914
|
+
} = {}) {
|
|
915
|
+
let previous = {};
|
|
916
|
+
try {
|
|
917
|
+
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
918
|
+
if (saved.scope === scope) previous = saved.failures;
|
|
919
|
+
} catch {}
|
|
920
|
+
const next = advanceCollectionHealth(statuses, previous, now);
|
|
921
|
+
if (persist) {
|
|
922
|
+
// Save independently of the payload: an upload failure must not erase attempts.
|
|
923
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
924
|
+
try {
|
|
925
|
+
fs.writeFileSync(temporary, JSON.stringify({ scope, failures: next.failures }), { mode: 0o600 });
|
|
926
|
+
fs.renameSync(temporary, file);
|
|
927
|
+
} catch {
|
|
928
|
+
console.error("수집 실패 횟수를 저장하지 못했습니다");
|
|
929
|
+
} finally {
|
|
930
|
+
try { fs.rmSync(temporary, { force: true }); } catch {}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return next.statuses;
|
|
934
|
+
}
|
|
935
|
+
|
|
860
936
|
// 업로드 설정 — 우선순위: CHARGE_* 환경변수(테스트용) → ~/.charge/config.json(페어링)
|
|
861
937
|
function resolveMode() {
|
|
862
938
|
if (process.env.CHARGE_TOKEN && process.env.CHARGE_URL && process.env.CHARGE_ANON) {
|
|
@@ -913,6 +989,14 @@ async function pairedUpload(mode, daily, live, providers, collectStatus = null,
|
|
|
913
989
|
}
|
|
914
990
|
|
|
915
991
|
async function main() {
|
|
992
|
+
const mode = resolveMode();
|
|
993
|
+
// Scheduler environments do not inherit the shell used for pairing. Restore
|
|
994
|
+
// only the two non-secret location settings, never login tokens or API keys.
|
|
995
|
+
for (const key of ["CLAUDE_CONFIG_DIR", "CLAUDE_SECURESTORAGE_CONFIG_DIR"]) {
|
|
996
|
+
if (process.env[key] === undefined && typeof mode?.claude_environment?.[key] === "string") {
|
|
997
|
+
process.env[key] = mode.claude_environment[key];
|
|
998
|
+
}
|
|
999
|
+
}
|
|
916
1000
|
// 이전 성공 페이로드 캐시 — 일부 수집이 실패해도 그 부분만 이전 값으로 유지
|
|
917
1001
|
let cache = {};
|
|
918
1002
|
try {
|
|
@@ -959,8 +1043,11 @@ async function main() {
|
|
|
959
1043
|
statuses: providerCollection.statuses,
|
|
960
1044
|
}));
|
|
961
1045
|
|
|
962
|
-
const mode = resolveMode();
|
|
963
1046
|
providers = namespaceUnknownAccounts(providers, mode?.install_id);
|
|
1047
|
+
statuses = recordCollectionHealth(statuses, {
|
|
1048
|
+
scope: accountHash(mode?.token),
|
|
1049
|
+
persist: !DRY_RUN && !!mode,
|
|
1050
|
+
});
|
|
964
1051
|
|
|
965
1052
|
if (DRY_RUN) {
|
|
966
1053
|
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers, collect_status: statuses }, null, 2));
|
|
@@ -989,7 +1076,10 @@ if (require.main === module) {
|
|
|
989
1076
|
|
|
990
1077
|
module.exports = {
|
|
991
1078
|
CCUSAGE_PKG,
|
|
1079
|
+
advanceCollectionHealth,
|
|
992
1080
|
accountHash,
|
|
1081
|
+
claudeCredentialLocation,
|
|
1082
|
+
claudeCredentials,
|
|
993
1083
|
claudeProvider,
|
|
994
1084
|
codexBarEntryToProvider,
|
|
995
1085
|
codexLiveWindows,
|
|
@@ -1009,6 +1099,7 @@ module.exports = {
|
|
|
1009
1099
|
pairedUpload,
|
|
1010
1100
|
parseCodexBarJSON,
|
|
1011
1101
|
percentValue,
|
|
1102
|
+
recordCollectionHealth,
|
|
1012
1103
|
resolveMode,
|
|
1013
1104
|
runAsync,
|
|
1014
1105
|
sanitizeCachedProvider,
|