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.
Files changed (3) hide show
  1. package/cli.js +6 -1
  2. package/collect.js +102 -11
  3. 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
- fs.writeFileSync(CONF, JSON.stringify({ url, anon, token, install_id: installationID }, null, 2), { mode: 0o600 });
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
- async function claudeCredentials() {
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 runAsync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"])));
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(fs.readFileSync(path.join(HOME, ".claude", ".credentials.json"), "utf8")));
337
+ sources.push(JSON.parse(read(location.file, "utf8")));
324
338
  } catch {}
325
- if (!sources.length) throw new Error("Claude Code 자격증명 없음");
326
- return freshestCredentials(sources);
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 } — status "ok" | "auth_expired" | "error",
331
- // 자격증명 자체가 없으면(미설치) status null이라 collect_status에 항목이 안 생긴다.
346
+ // 반환: { provider, status } — "ok", "auth_expired", or "error[:reason]".
347
+ // 설치 흔적이 없으면 status null; 설치 흔적은 있지만 로그인 정보를 못 읽으면 설정 안내.
332
348
  // fetchFn/loadCredentials는 테스트 주입용 — 기본값이면 기존 동작 그대로.
333
- async function claudeProvider({ fetchFn = fetch, loadCredentials = claudeCredentials } = {}) {
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
- return { provider: null, status: null }; // 자격증명 없음 = Claude Code 미설치
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
- return { provider: null, status: res.status === 401 ? "auth_expired" : "error" };
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "charge-connect",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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"