charge-connect 0.1.7 → 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 +162 -15
- package/collect.js +157 -53
- package/identity.js +57 -0
- package/package.json +3 -2
package/cli.js
CHANGED
|
@@ -13,14 +13,122 @@ const fs = require("node:fs");
|
|
|
13
13
|
const path = require("node:path");
|
|
14
14
|
const { execFileSync } = require("node:child_process");
|
|
15
15
|
const { CCUSAGE_PKG } = require("./collect.js"); // 버전 고정을 한 곳에서 관리
|
|
16
|
+
const { resolveInstallationID } = require("./identity.js");
|
|
17
|
+
|
|
18
|
+
const PACKAGE_NAME = "charge-connect";
|
|
19
|
+
const CURRENT_VERSION = (() => {
|
|
20
|
+
try { return require("./package.json").version; } catch { return null; }
|
|
21
|
+
})();
|
|
16
22
|
|
|
17
23
|
const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
18
24
|
const CONF_DIR = process.env.CHARGE_HOME ?? path.join(HOME, ".charge");
|
|
19
25
|
const CONF = path.join(CONF_DIR, "config.json");
|
|
26
|
+
const DEVICE = path.join(CONF_DIR, "device.json");
|
|
20
27
|
const WIN = process.platform === "win32";
|
|
21
28
|
const LINUX = process.platform === "linux";
|
|
22
29
|
const INSTALLER = WIN ? "install.ps1" : LINUX ? "install.linux.sh" : "install.sh";
|
|
23
30
|
|
|
31
|
+
function parseVersion(value) {
|
|
32
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(value ?? ""));
|
|
33
|
+
return match ? { numbers: match.slice(1, 4).map(Number), prerelease: match[4] ?? null } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isNewerVersion(current, candidate) {
|
|
37
|
+
const a = parseVersion(current);
|
|
38
|
+
const b = parseVersion(candidate);
|
|
39
|
+
if (!a || !b) return false;
|
|
40
|
+
for (let i = 0; i < 3; i += 1) {
|
|
41
|
+
if (a.numbers[i] !== b.numbers[i]) return b.numbers[i] > a.numbers[i];
|
|
42
|
+
}
|
|
43
|
+
return a.prerelease !== null && b.prerelease === null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveNpmCLI() {
|
|
47
|
+
const candidates = [
|
|
48
|
+
process.env.npm_execpath,
|
|
49
|
+
path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"),
|
|
50
|
+
path.resolve(path.dirname(process.execPath), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
|
|
51
|
+
].filter(Boolean);
|
|
52
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function launchUpdatedVersion(version) {
|
|
56
|
+
const npmCLI = resolveNpmCLI();
|
|
57
|
+
if (!npmCLI) throw new Error("npm 실행 파일을 찾을 수 없어 자동 업데이트할 수 없습니다.");
|
|
58
|
+
const npmArgs = path.basename(npmCLI) === "npx-cli.js"
|
|
59
|
+
? [npmCLI, "--yes", `${PACKAGE_NAME}@${version}`, ...process.argv.slice(2)]
|
|
60
|
+
: [
|
|
61
|
+
npmCLI,
|
|
62
|
+
"exec",
|
|
63
|
+
"--yes",
|
|
64
|
+
`--package=${PACKAGE_NAME}@${version}`,
|
|
65
|
+
"--",
|
|
66
|
+
PACKAGE_NAME,
|
|
67
|
+
...process.argv.slice(2),
|
|
68
|
+
];
|
|
69
|
+
execFileSync(process.execPath, npmArgs, {
|
|
70
|
+
stdio: "inherit",
|
|
71
|
+
env: { ...process.env, CHARGE_UPDATE_CHECKED: "1" },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function maybeUpdateBeforePairing({
|
|
76
|
+
currentVersion = CURRENT_VERSION,
|
|
77
|
+
fetchFn = globalThis.fetch,
|
|
78
|
+
interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
79
|
+
ask = null,
|
|
80
|
+
launch = launchUpdatedVersion,
|
|
81
|
+
env = process.env,
|
|
82
|
+
log = console.log,
|
|
83
|
+
} = {}) {
|
|
84
|
+
if (!currentVersion || env.CHARGE_UPDATE_CHECKED === "1" || env.CHARGE_SKIP_UPDATE === "1") return false;
|
|
85
|
+
|
|
86
|
+
let latest;
|
|
87
|
+
try {
|
|
88
|
+
const response = await fetchFn(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
|
89
|
+
headers: { Accept: "application/json", "User-Agent": `${PACKAGE_NAME}/${currentVersion}` },
|
|
90
|
+
signal: AbortSignal.timeout(8_000),
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
93
|
+
latest = (await response.json())?.version;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
log(`업데이트 확인을 건너뜁니다 (${error?.message ?? error}). 현재 ${currentVersion}로 계속합니다.`);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!isNewerVersion(currentVersion, latest)) return false;
|
|
100
|
+
log(`charge-connect 새 버전 ${latest}이 있습니다 (현재 ${currentVersion}).`);
|
|
101
|
+
if (!interactive) {
|
|
102
|
+
log(`자동 업데이트하려면 'npx ${PACKAGE_NAME}@${latest} <페어링코드>'로 다시 실행하세요.`);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let answer;
|
|
107
|
+
if (ask) {
|
|
108
|
+
answer = await ask(`먼저 ${latest}(으)로 업데이트한 뒤 연동할까요? (Y/n) `);
|
|
109
|
+
} else {
|
|
110
|
+
const rl = require("node:readline/promises").createInterface({ input: process.stdin, output: process.stdout });
|
|
111
|
+
try {
|
|
112
|
+
answer = await rl.question(`먼저 ${latest}(으)로 업데이트한 뒤 연동할까요? (Y/n) `);
|
|
113
|
+
} finally {
|
|
114
|
+
rl.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const normalized = String(answer ?? "").trim().toLowerCase();
|
|
118
|
+
if (["n", "no", "아니오"].includes(normalized)) {
|
|
119
|
+
log(`업데이트를 건너뛰고 ${currentVersion}로 연동합니다.`);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (!["", "y", "yes", "ok", "네"].includes(normalized)) {
|
|
123
|
+
log(`응답을 확인하지 못해 업데이트를 건너뛰고 ${currentVersion}로 연동합니다.`);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
log(`charge-connect ${latest}(으)로 업데이트한 뒤 연동을 계속합니다…`);
|
|
128
|
+
launch(latest);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
24
132
|
// 백엔드 주소 — collector/cloud.json (출시 시 charge 전용 프로젝트로 교체, npm 패키지에 포함)
|
|
25
133
|
const CLOUD = (() => {
|
|
26
134
|
try {
|
|
@@ -32,8 +140,7 @@ const CLOUD = (() => {
|
|
|
32
140
|
|
|
33
141
|
// 앱 기기 목록에 표시될 이름 — 기본은 호스트명이지만 실명이 든 경우가 흔하므로
|
|
34
142
|
// 최초 페어링 때 --label 또는 CHARGE_LABEL로 덮어쓸 수 있게 하고, 무엇으로 등록되는지 알려준다.
|
|
35
|
-
//
|
|
36
|
-
// 다른 라벨로 재페어링하면 새 디바이스로 잡혀 일별 사용량이 이중 집계되므로, 사후 변경은 권하지 않는다.
|
|
143
|
+
// 기기 식별은 별도 installation id가 담당하므로 라벨이 같거나 나중에 바뀌어도 충돌하지 않는다.
|
|
37
144
|
function resolveLabel() {
|
|
38
145
|
const i = process.argv.indexOf("--label");
|
|
39
146
|
const flag = i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith("-") ? process.argv[i + 1] : null;
|
|
@@ -49,12 +156,30 @@ async function pair(code) {
|
|
|
49
156
|
process.exit(1);
|
|
50
157
|
}
|
|
51
158
|
const label = resolveLabel();
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
159
|
+
const installationID = resolveInstallationID(DEVICE);
|
|
160
|
+
const claim = (includeInstallationID) => fetch(`${url}/rest/v1/rpc/charge_claim_pairing_code`, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { apikey: anon, Authorization: `Bearer ${anon}`, "Content-Type": "application/json" },
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
p_code: code,
|
|
165
|
+
p_label: label,
|
|
166
|
+
...(includeInstallationID ? { p_install_id: installationID } : {}),
|
|
167
|
+
}),
|
|
168
|
+
signal: AbortSignal.timeout(15_000),
|
|
169
|
+
});
|
|
170
|
+
let res = await claim(true);
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
const body = await res.text();
|
|
173
|
+
// 수집기부터 배포된 경우 구버전 DB에는 세 번째 인자가 없다. 함수가 실행되지 않은
|
|
174
|
+
// PGRST202/404에만 두 인자 호출로 재시도하므로 일회용 코드를 두 번 소비하지 않는다.
|
|
175
|
+
if (res.status === 404 || body.includes("PGRST202")) {
|
|
176
|
+
res = await claim(false);
|
|
177
|
+
} else {
|
|
178
|
+
console.error(`페어링 실패 (${res.status}): ${body}`);
|
|
179
|
+
console.error("앱에서 새 코드를 발급받아 다시 시도하세요 (코드는 10분간 유효).");
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
58
183
|
const token = res.ok ? await res.json() : null;
|
|
59
184
|
if (!token || typeof token !== "string") {
|
|
60
185
|
console.error(res.ok ? "잘못됐거나 만료된 페어링 코드입니다." : `페어링 실패 (${res.status}): ${await res.text()}`);
|
|
@@ -64,7 +189,12 @@ async function pair(code) {
|
|
|
64
189
|
// 디바이스 토큰은 본인만 읽을 수 있게 저장 (0700/0600)
|
|
65
190
|
fs.mkdirSync(CONF_DIR, { recursive: true, mode: 0o700 });
|
|
66
191
|
try { fs.chmodSync(CONF_DIR, 0o700); } catch {}
|
|
67
|
-
|
|
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 });
|
|
68
198
|
try { fs.chmodSync(CONF, 0o600); } catch {}
|
|
69
199
|
console.log(`✓ 페어링 완료 — 이 컴퓨터가 '${label}'(으)로 등록되었습니다.`);
|
|
70
200
|
}
|
|
@@ -95,7 +225,7 @@ async function unpair() {
|
|
|
95
225
|
function installRuntime() {
|
|
96
226
|
const dest = path.join(CONF_DIR, "app");
|
|
97
227
|
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
98
|
-
const files = ["cli.js", "collect.js", "install.sh", "install.linux.sh", "install.ps1", "cloud.json", "package.json"];
|
|
228
|
+
const files = ["cli.js", "collect.js", "identity.js", "install.sh", "install.linux.sh", "install.ps1", "cloud.json", "package.json"];
|
|
99
229
|
for (const f of files) {
|
|
100
230
|
const src = path.join(__dirname, f);
|
|
101
231
|
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(dest, f));
|
|
@@ -157,7 +287,7 @@ async function offerCcusageInstall() {
|
|
|
157
287
|
}
|
|
158
288
|
}
|
|
159
289
|
|
|
160
|
-
|
|
290
|
+
async function main() {
|
|
161
291
|
const arg = process.argv[2];
|
|
162
292
|
if (!arg) {
|
|
163
293
|
console.error("사용법: npx charge-connect <페어링코드> (앱 온보딩에서 코드 발급)");
|
|
@@ -171,6 +301,9 @@ async function offerCcusageInstall() {
|
|
|
171
301
|
await unpair();
|
|
172
302
|
return;
|
|
173
303
|
}
|
|
304
|
+
// 페어링 코드를 소비하기 전에 최신 버전으로 넘긴다. 새 프로세스에는 표시를 남겨
|
|
305
|
+
// 재귀 업데이트 확인 없이 곧바로 같은 코드와 옵션으로 페어링하게 한다.
|
|
306
|
+
if (await maybeUpdateBeforePairing()) return;
|
|
174
307
|
// 페어링 코드로 간주: 페어링 → 스케줄 등록 → 첫 수집.
|
|
175
308
|
// 스케줄을 먼저 등록한다 — 첫 수집이 실패해도(네트워크가 잠깐 끊기는 등) 자동 수집은
|
|
176
309
|
// 살아 있어야 한다. 페어링 코드는 이미 소모돼서 같은 코드로 재시도할 수 없기 때문이다.
|
|
@@ -216,7 +349,21 @@ async function offerCcusageInstall() {
|
|
|
216
349
|
return;
|
|
217
350
|
}
|
|
218
351
|
console.log("✓ 설정 끝! 이제 앱에서 데이터가 보입니다.");
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (require.main === module) {
|
|
355
|
+
main().catch((e) => {
|
|
356
|
+
console.error(e.message ?? e);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = {
|
|
362
|
+
installRuntime,
|
|
363
|
+
isNewerVersion,
|
|
364
|
+
main,
|
|
365
|
+
maybeUpdateBeforePairing,
|
|
366
|
+
pair,
|
|
367
|
+
resolveLabel,
|
|
368
|
+
resolveNpmCLI,
|
|
369
|
+
};
|
package/collect.js
CHANGED
|
@@ -7,6 +7,7 @@ const { execFile } = require("node:child_process");
|
|
|
7
7
|
const { promisify } = require("node:util");
|
|
8
8
|
const fs = require("node:fs");
|
|
9
9
|
const path = require("node:path");
|
|
10
|
+
const { unknownAccountKey } = require("./identity.js");
|
|
10
11
|
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
12
13
|
|
|
@@ -15,6 +16,7 @@ const daysArg = process.argv.indexOf("--days");
|
|
|
15
16
|
const parsedDays = daysArg > -1 ? parseInt(process.argv[daysArg + 1], 10) : NaN;
|
|
16
17
|
const DAYS = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 60;
|
|
17
18
|
const CACHE_FILE = path.join(__dirname, ".last-payload.json");
|
|
19
|
+
const HEALTH_FILE = path.join(__dirname, ".collection-health.json");
|
|
18
20
|
const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
19
21
|
|
|
20
22
|
// 스케줄러가 부를 때는 볼 콘솔이 없으니 출력을 직접 파일로 받는다.
|
|
@@ -115,8 +117,8 @@ function accountHash(id) {
|
|
|
115
117
|
return require("node:crypto").createHash("sha256").update(String(id)).digest("hex").slice(0, 12);
|
|
116
118
|
}
|
|
117
119
|
|
|
118
|
-
// 퍼센트 수치
|
|
119
|
-
// Number()에 맡기면 안 된다: Number(null)
|
|
120
|
+
// 퍼센트 수치 파싱, 값이 있을 때만 숫자를 돌려주고, 비었으면 null.
|
|
121
|
+
// Number()에 맡기면 안 된다: Number(null), Number(""), Number(false), Number([])는 전부 0이고
|
|
120
122
|
// finite라, "수치가 비었다"와 "진짜 0%(정상 사용 0)"가 구분되지 않는다. 빈 값이 0%로 올라가면
|
|
121
123
|
// 서버 입장에선 멀쩡한 값이라(빈 창 가드는 JSON null만 보존한다) 다른 기기가 올린 게이지를 0으로 덮는다.
|
|
122
124
|
// 숫자이거나 공백 아닌 숫자 문자열일 때만 값으로 인정한다.
|
|
@@ -149,7 +151,7 @@ function sanitizeCachedProvider(prev) {
|
|
|
149
151
|
// 만료를 걷어내고 표시할 창이 하나도 안 남으면 복원 대상에서 뺀다(null).
|
|
150
152
|
// 전부 null인 껍데기를 며칠 묵은 collected_at과 함께 올리면 앱엔 빈 카드가 뜨고,
|
|
151
153
|
// 서버 행의 나이가 그 옛 시각으로 되감겨 다른 기기의 묵은 업로드까지 신선도 가드를 통과한다.
|
|
152
|
-
// 결과적으로 그 프로바이더는 이번 페이로드에서 통째로
|
|
154
|
+
// 결과적으로 그 프로바이더는 이번 페이로드에서 통째로 빠진다, 서버의 은퇴 delete에는
|
|
153
155
|
// 20분 유예가 있어, 한두 사이클 빠졌다가 다시 관측되면 카드가 사라지지 않는다.
|
|
154
156
|
if (!session && !weekly && !extras.length) return null;
|
|
155
157
|
return {
|
|
@@ -212,7 +214,7 @@ function explicitWindowLabel(window) {
|
|
|
212
214
|
function normalizeRateWindow(window, slot, duplicateDuration = false, forcedLabel = null) {
|
|
213
215
|
if (!window || typeof window !== "object") return null;
|
|
214
216
|
// 후보마다 percentValue를 적용해 '유효한' 첫 값을 고른다. ?? 체인은 null/undefined만
|
|
215
|
-
// 건너뛰므로 ""
|
|
217
|
+
// 건너뛰므로 "", false, []가 앞자리에 있으면 Number()에서 0으로 둔갑해 그대로 굳었다 ,
|
|
216
218
|
// 즉 이 폴백의 의미가 "null/undefined 건너뛰기"에서 "유효하지 않은 값 건너뛰기"로 바뀐다.
|
|
217
219
|
// 빈 수치를 0%로 올리면 서버의 빈 창 가드를 통과해 다른 기기의 멀쩡한 게이지를 덮는다.
|
|
218
220
|
const percent = [window.usedPercent, window.percent, window.utilization]
|
|
@@ -276,11 +278,11 @@ async function collectDaily() {
|
|
|
276
278
|
return [...byDay.values()].sort((a, b) => a.period.localeCompare(b.period));
|
|
277
279
|
}
|
|
278
280
|
|
|
279
|
-
// run은 테스트
|
|
281
|
+
// run은 테스트 주입용, 기본값이면 기존 동작 그대로.
|
|
280
282
|
async function collectLive({ run = runCcusage } = {}) {
|
|
281
283
|
const data = await run(["blocks", "--active", "--json"]);
|
|
282
284
|
const block = (data.blocks ?? []).find((b) => b.isActive) ?? null;
|
|
283
|
-
// collected_at = 이 블록을 실제로 관측한 시각. 서버가 charge_live 신선도 판정에 쓴다
|
|
285
|
+
// collected_at = 이 블록을 실제로 관측한 시각. 서버가 charge_live 신선도 판정에 쓴다 ,
|
|
284
286
|
// 수집이 깨진 기기가 캐시된 옛 블록을 5분마다 재업로드해도 최신 블록을 덮지 못한다.
|
|
285
287
|
// 캐시 폴백은 cache.live를 통째로 재사용하므로 이 값이 갱신되지 않고 나이가 보존된다.
|
|
286
288
|
return block ? { ...block, collected_at: new Date().toISOString() } : null;
|
|
@@ -290,7 +292,7 @@ async function collectLive({ run = runCcusage } = {}) {
|
|
|
290
292
|
// 그 사이클을 통째로 버리면 다음 5분까지 묵은 캐시가 올라가므로, 네트워크 계층 실패(HTTP
|
|
291
293
|
// 응답을 못 받은 경우)만 짧게 한 번 더 시도한다. 상태 코드가 온 요청은 재시도하지 않는다
|
|
292
294
|
// (401을 두 번 물어봐야 답이 같고, 5분마다 도는 작업이라 재시도는 최소로).
|
|
293
|
-
// 타임아웃 시그널은 반드시 시도마다 새로
|
|
295
|
+
// 타임아웃 시그널은 반드시 시도마다 새로 만든다, 호출자가 만든 signal을 두 시도가 나눠 쓰면
|
|
294
296
|
// 1차가 예산 후반에 죽었을 때 2차는 대기 시간만큼 이미 지난 예산을 물려받아 즉시 abort된다.
|
|
295
297
|
// 재시도가 무의미해지는 것도 문제지만, 로그에 남는 원인이 "fetch failed"에서
|
|
296
298
|
// "This operation was aborted"로 바뀌어 진짜 원인(네트워크 미연결)을 가린다.
|
|
@@ -313,28 +315,50 @@ function freshestCredentials(sources) {
|
|
|
313
315
|
return sources.reduce((a, b) => (expiresAt(b) > expiresAt(a) ? b : a));
|
|
314
316
|
}
|
|
315
317
|
|
|
316
|
-
|
|
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);
|
|
317
332
|
const sources = [];
|
|
318
333
|
try {
|
|
319
|
-
sources.push(JSON.parse(await
|
|
334
|
+
sources.push(JSON.parse(await run("security", ["find-generic-password", "-s", location.service, "-w"], 10_000)));
|
|
320
335
|
} catch {}
|
|
321
336
|
try {
|
|
322
|
-
sources.push(JSON.parse(
|
|
337
|
+
sources.push(JSON.parse(read(location.file, "utf8")));
|
|
323
338
|
} catch {}
|
|
324
|
-
|
|
325
|
-
|
|
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);
|
|
326
343
|
}
|
|
327
344
|
|
|
328
345
|
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API 조회.
|
|
329
|
-
// 반환: { provider, status } —
|
|
330
|
-
//
|
|
346
|
+
// 반환: { provider, status } — "ok", "auth_expired", or "error[:reason]".
|
|
347
|
+
// 설치 흔적이 없으면 status null; 설치 흔적은 있지만 로그인 정보를 못 읽으면 설정 안내.
|
|
331
348
|
// fetchFn/loadCredentials는 테스트 주입용 — 기본값이면 기존 동작 그대로.
|
|
332
|
-
async function claudeProvider({
|
|
349
|
+
async function claudeProvider({
|
|
350
|
+
fetchFn = fetch, loadCredentials = claudeCredentials,
|
|
351
|
+
hasClaude = () => fs.existsSync(claudeCredentialLocation().configDir),
|
|
352
|
+
} = {}) {
|
|
333
353
|
let cred;
|
|
334
354
|
try {
|
|
335
355
|
cred = await loadCredentials();
|
|
336
356
|
} catch {
|
|
337
|
-
|
|
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 };
|
|
338
362
|
}
|
|
339
363
|
try {
|
|
340
364
|
const oauth = cred.claudeAiOauth ?? cred;
|
|
@@ -346,7 +370,7 @@ async function claudeProvider({ fetchFn = fetch, loadCredentials = claudeCredent
|
|
|
346
370
|
: oauth.subscriptionType
|
|
347
371
|
? oauth.subscriptionType[0].toUpperCase() + oauth.subscriptionType.slice(1)
|
|
348
372
|
: null;
|
|
349
|
-
// signal은 넘기지
|
|
373
|
+
// signal은 넘기지 않는다, fetchOnceRetried가 시도마다 새 10초 예산을 만들어 붙인다
|
|
350
374
|
const res = await fetchOnceRetried(fetchFn, "https://api.anthropic.com/api/oauth/usage", {
|
|
351
375
|
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
352
376
|
}, { timeoutMs: 10_000 });
|
|
@@ -354,7 +378,10 @@ async function claudeProvider({ fetchFn = fetch, loadCredentials = claudeCredent
|
|
|
354
378
|
// 401 = 토큰 만료 (Claude Code를 한 번 실행하면 갱신됨), 그 외는 일시 장애일 가능성.
|
|
355
379
|
// 이 구분이 서버 collect_status로 올라가 앱이 "재로그인 필요"를 안내할 수 있다.
|
|
356
380
|
console.error(`claude usage API ${res.status}${res.status === 401 ? " — 토큰 만료, Claude Code를 실행하면 갱신됩니다" : ""}`);
|
|
357
|
-
|
|
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 };
|
|
358
385
|
}
|
|
359
386
|
const d = await res.json();
|
|
360
387
|
// 계정 UUID → 해시 (프로필 조회 실패 시 null — 캐시 폴백이 채워준다)
|
|
@@ -441,11 +468,11 @@ function codexAuth() {
|
|
|
441
468
|
// Codex 실시간 조회: Codex CLI 자신이 60초마다 폴링하는 것과 같은 엔드포인트를
|
|
442
469
|
// 로컬 OAuth 토큰으로 호출한다. 토큰 갱신은 하지 않는다 — 만료됐으면 null을 반환하고
|
|
443
470
|
// 스냅샷 폴백에 맡긴다 (CLI가 다음 실행 때 알아서 갱신해 둔다).
|
|
444
|
-
// fetchFn은 테스트
|
|
471
|
+
// fetchFn은 테스트 주입용, 기본값이면 기존 동작 그대로.
|
|
445
472
|
async function codexLiveWindows(auth, { fetchFn = fetch } = {}) {
|
|
446
473
|
if (!auth?.accessToken || !auth?.accountId) return null;
|
|
447
474
|
try {
|
|
448
|
-
// signal은 넘기지
|
|
475
|
+
// signal은 넘기지 않는다, fetchOnceRetried가 시도마다 새 10초 예산을 만들어 붙인다
|
|
449
476
|
const res = await fetchOnceRetried(fetchFn, "https://chatgpt.com/backend-api/wham/usage", {
|
|
450
477
|
headers: {
|
|
451
478
|
Authorization: `Bearer ${auth.accessToken}`,
|
|
@@ -455,7 +482,7 @@ async function codexLiveWindows(auth, { fetchFn = fetch } = {}) {
|
|
|
455
482
|
}, { timeoutMs: 10_000 });
|
|
456
483
|
if (!res.ok) return null;
|
|
457
484
|
const d = await res.json();
|
|
458
|
-
// 수치가 비면 0%가 아니라 창
|
|
485
|
+
// 수치가 비면 0%가 아니라 창 없음, 0%는 서버의 빈 창 가드를 통과해 게이지를 덮는다
|
|
459
486
|
const win = (w) => {
|
|
460
487
|
const percent = percentValue(w?.used_percent);
|
|
461
488
|
return percent === null
|
|
@@ -480,18 +507,18 @@ async function codexLiveWindows(auth, { fetchFn = fetch } = {}) {
|
|
|
480
507
|
|
|
481
508
|
// 최신 .jsonl에 rate_limits가 없는 경우가 흔하다(막 연 세션, 한도 응답 전에 끝난 세션).
|
|
482
509
|
// 그렇다고 전부 훑으면 세션 로그가 수천 개인 사용자에서 5분 주기를 넘기므로 최근 몇 개만 본다.
|
|
483
|
-
// 5개는 너무
|
|
510
|
+
// 5개는 너무 빡빡했다, 짧은 세션이 연달아 다섯 번이면 여섯 번째의 멀쩡한 스냅샷을 놓치고
|
|
484
511
|
// "관측 실패(error)"로 보고해 앱에 경고가 뜬다. 파일당 읽기는 개별 try로 감싸져 있다.
|
|
485
512
|
const SNAPSHOT_SCAN_FILES = 20;
|
|
486
|
-
// 파일당 읽는 꼬리
|
|
513
|
+
// 파일당 읽는 꼬리 크기, 마지막 rate_limits는 파일 끝에 있다 (전체를 읽으면 수십 MB를 파싱하게 된다)
|
|
487
514
|
const SNAPSHOT_TAIL_BYTES = 2 * 1024 * 1024;
|
|
488
515
|
|
|
489
|
-
// Codex 스냅샷 폴백: 최근 세션 로그(.jsonl)에 기록된 마지막 rate_limits
|
|
516
|
+
// Codex 스냅샷 폴백: 최근 세션 로그(.jsonl)에 기록된 마지막 rate_limits ,
|
|
490
517
|
// 마지막으로 Codex를 실제 사용한 시점의 값이라 실시간 조회가 실패했을 때만 쓴다.
|
|
491
|
-
// 반환: null = 볼 스냅샷이 아예 없음(
|
|
518
|
+
// 반환: null = 볼 스냅샷이 아예 없음(미설치, 최근 로그에 rate_limits 없음),
|
|
492
519
|
// { session, weekly, collected_at } = 스냅샷은 있었음. 만료 창은 여기서 이미 걸러지므로
|
|
493
520
|
// 둘 다 null일 수 있고, 그건 "관측은 했는데 리셋이 다 지났다"는 뜻이다(호출자가 stale로 판정).
|
|
494
|
-
// dir은 테스트
|
|
521
|
+
// dir은 테스트 주입용, 기본값이면 기존 동작 그대로.
|
|
495
522
|
async function codexSnapshotWindows(dir = path.join(HOME, ".codex", "sessions")) {
|
|
496
523
|
if (!fs.existsSync(dir)) return null; // Codex 미설치
|
|
497
524
|
const files = [];
|
|
@@ -519,7 +546,7 @@ async function codexSnapshotWindows(dir = path.join(HOME, ".codex", "sessions"))
|
|
|
519
546
|
return null;
|
|
520
547
|
};
|
|
521
548
|
// 세션 로그는 수십 MB까지 자란다(실측 최대 43MB). 통째로 동기 읽기를 하면 그동안 이벤트 루프가
|
|
522
|
-
// 멈춰 진행 중인 fetch의 AbortSignal 타이머가 전부
|
|
549
|
+
// 멈춰 진행 중인 fetch의 AbortSignal 타이머가 전부 터진다, 이 파일 맨 위에서 금지한 바로 그 패턴이다.
|
|
523
550
|
// 그래서 비동기로, 그것도 꼬리 SNAPSHOT_TAIL_BYTES만 읽는다. 우리가 찾는 건 "마지막" rate_limits라
|
|
524
551
|
// 꼬리에 없으면 그 파일의 값은 어차피 낡은 것이고, 더 최근 파일이 우선순위를 갖는다.
|
|
525
552
|
const lastRateLimits = async (file) => {
|
|
@@ -534,7 +561,7 @@ async function codexSnapshotWindows(dir = path.join(HOME, ".codex", "sessions"))
|
|
|
534
561
|
for (const line of buf.toString("utf8").split("\n")) {
|
|
535
562
|
if (!line.includes('"rate_limits"')) continue;
|
|
536
563
|
try {
|
|
537
|
-
// 꼬리를 자른 지점의 첫 줄은 조각날 수
|
|
564
|
+
// 꼬리를 자른 지점의 첫 줄은 조각날 수 있다, 파싱 실패는 그냥 건너뛴다
|
|
538
565
|
rl = findRL(JSON.parse(line)) ?? rl;
|
|
539
566
|
} catch {}
|
|
540
567
|
}
|
|
@@ -554,7 +581,7 @@ async function codexSnapshotWindows(dir = path.join(HOME, ".codex", "sessions"))
|
|
|
554
581
|
}
|
|
555
582
|
}
|
|
556
583
|
if (!found) return null;
|
|
557
|
-
// 수치가 비면 0%가 아니라 창
|
|
584
|
+
// 수치가 비면 0%가 아니라 창 없음, 0%는 서버의 빈 창 가드를 통과해 게이지를 덮는다
|
|
558
585
|
const win = (w) => {
|
|
559
586
|
const percent = percentValue(w?.used_percent);
|
|
560
587
|
return percent === null
|
|
@@ -565,18 +592,18 @@ async function codexSnapshotWindows(dir = path.join(HOME, ".codex", "sessions"))
|
|
|
565
592
|
window_minutes: w.window_minutes ?? null,
|
|
566
593
|
};
|
|
567
594
|
};
|
|
568
|
-
// 만료 판정을 여기서
|
|
595
|
+
// 만료 판정을 여기서 끝낸다, 예전엔 dropExpired가 codexProvider에서야 돌아, 리셋이 다 지난
|
|
569
596
|
// 3일 묵은 스냅샷도 "창이 있다"고 통과한 뒤 그 창이 뒤늦게 전부 null이 됐다.
|
|
570
597
|
const session = dropExpired(win(found.rl.primary));
|
|
571
598
|
const weekly = dropExpired(win(found.rl.secondary));
|
|
572
|
-
// collected_at = 그 스냅샷이 들어 있던 파일의 mtime
|
|
599
|
+
// collected_at = 그 스냅샷이 들어 있던 파일의 mtime, 스냅샷은 "지금"이 아니라 마지막
|
|
573
600
|
// 사용 시점의 값이므로, 수집 시각을 넣으면 다른 기기의 진짜 최신 값을 신선도 규칙에서 이긴다.
|
|
574
601
|
return { session, weekly, collected_at: new Date(found.m).toISOString() };
|
|
575
602
|
}
|
|
576
603
|
|
|
577
604
|
// 반환: { provider, status } — live 성공 "ok", 스냅샷 폴백 "stale",
|
|
578
605
|
// ~/.codex는 있는데 아무것도 관측 못 하면 "error", 미설치면 status null(항목 없음).
|
|
579
|
-
// loadAuth/liveWindows/snapshotWindows/hasCodex는 테스트
|
|
606
|
+
// loadAuth/liveWindows/snapshotWindows/hasCodex는 테스트 주입용, 기본값이면 기존 동작 그대로.
|
|
580
607
|
async function codexProvider({
|
|
581
608
|
loadAuth = codexAuth,
|
|
582
609
|
liveWindows = codexLiveWindows,
|
|
@@ -589,15 +616,15 @@ async function codexProvider({
|
|
|
589
616
|
const live = await liveWindows(auth);
|
|
590
617
|
const snapshot = live ? null : await snapshotWindows();
|
|
591
618
|
const windows = live ?? snapshot;
|
|
592
|
-
if (!windows) return { provider: null, status: "error" }; //
|
|
619
|
+
if (!windows) return { provider: null, status: "error" }; // 실시간, 스냅샷 둘 다 관측 실패
|
|
593
620
|
const session = dropExpired(windows.session);
|
|
594
621
|
const weekly = dropExpired(windows.weekly);
|
|
595
622
|
// 리셋이 다 지나 남는 창이 하나도 없으면 프로바이더 자체를 올리지 않는다. 전부 null인 행을
|
|
596
623
|
// 올리면 앱엔 빈 카드가 뜨고, 서버 행의 collected_at이 스냅샷 mtime(며칠 전)으로 되감겨
|
|
597
624
|
// 다른 기기의 묵은 업로드까지 신선도 가드를 통과한다. 대신 캐시 폴백이 마지막 값을 유지한다.
|
|
598
|
-
// 상태는 "error"가
|
|
599
|
-
// 앱이 "재로그인/수집 실패" 경고를 띄운다(앱은 error
|
|
600
|
-
// live 조회에 성공했더라도 여기까지 왔다면 올릴 창이 없으니 "ok"가 아니라 "stale"이다
|
|
625
|
+
// 상태는 "error"가 아니다, 수집은 정상이고 데이터가 낡았을 뿐인데 error로 보고하면
|
|
626
|
+
// 앱이 "재로그인/수집 실패" 경고를 띄운다(앱은 error, auth_expired만 경고로 친다).
|
|
627
|
+
// live 조회에 성공했더라도 여기까지 왔다면 올릴 창이 없으니 "ok"가 아니라 "stale"이다 ,
|
|
601
628
|
// "ok"는 이번 사이클에 쓸 수 있는 값을 올렸다는 뜻인데, 실제로 올라가는 건 캐시 폴백뿐이다.
|
|
602
629
|
if (!session && !weekly) return { provider: null, status: "stale" };
|
|
603
630
|
return {
|
|
@@ -807,7 +834,7 @@ async function collectProviders() {
|
|
|
807
834
|
|
|
808
835
|
// 서버는 (user_id, id, account) 한 행을 업서트하므로, 같은 키를 한 페이로드에 두 번 실으면
|
|
809
836
|
// 한 문장 안에서 같은 행을 두 번 건드려 어느 쪽이 남을지가 배열 순서에 달린다.
|
|
810
|
-
// 더 신선한 쪽만
|
|
837
|
+
// 더 신선한 쪽만 남긴다, collected_at이 없으면 나이 미상이라 스탬프가 있는 쪽에 진다.
|
|
811
838
|
function dedupeProviders(providers) {
|
|
812
839
|
const stamp = (p) => {
|
|
813
840
|
const t = Date.parse(p?.collected_at ?? "");
|
|
@@ -815,10 +842,10 @@ function dedupeProviders(providers) {
|
|
|
815
842
|
};
|
|
816
843
|
const byKey = new Map();
|
|
817
844
|
for (const p of providers) {
|
|
818
|
-
// 구분자는 NUL
|
|
845
|
+
// 구분자는 NUL 이스케이프, id, account 어디에도 못 들어가는 문자여야 키가 안 겹친다
|
|
819
846
|
const key = `${p.id}\u0000${p.account ?? ""}`;
|
|
820
847
|
const prev = byKey.get(key);
|
|
821
|
-
// 동률(둘 다 미상 포함)이면
|
|
848
|
+
// 동률(둘 다 미상 포함)이면 뒤엣것, 이번 사이클 수집분이 캐시 복원분보다 뒤에 온다
|
|
822
849
|
if (!prev || stamp(p) >= stamp(prev)) byKey.set(key, p);
|
|
823
850
|
}
|
|
824
851
|
return [...byKey.values()];
|
|
@@ -827,7 +854,7 @@ function dedupeProviders(providers) {
|
|
|
827
854
|
// 이번 사이클 수집 결과에 캐시 복원분을 얹고 업로드 계약(키 유일성)에 맞춰 정리한다.
|
|
828
855
|
function mergeCachedProviders({ providers, cached = [], codexBarComplete = false, statuses = null }) {
|
|
829
856
|
const merged = [...providers];
|
|
830
|
-
// statuses가 null이면 수집이 통째로 죽어 아무것도 판정할 수 없는
|
|
857
|
+
// statuses가 null이면 수집이 통째로 죽어 아무것도 판정할 수 없는 경우, 미상 그대로 둔다
|
|
831
858
|
const nextStatuses = statuses ? { ...statuses } : statuses;
|
|
832
859
|
for (const prev of cached) {
|
|
833
860
|
if (merged.some((p) => p.id === prev.id)) continue;
|
|
@@ -835,27 +862,86 @@ function mergeCachedProviders({ providers, cached = [], codexBarComplete = false
|
|
|
835
862
|
if (codexBarComplete && prev.collector_source === "codexbar") continue;
|
|
836
863
|
const sanitized = sanitizeCachedProvider(prev);
|
|
837
864
|
if (!sanitized) continue;
|
|
838
|
-
// 0.1.4 이전 캐시엔 collected_at이
|
|
865
|
+
// 0.1.4 이전 캐시엔 collected_at이 없다, 없는 채로 보낸다. 서버는 스탬프 없는 업로드를
|
|
839
866
|
// "최신"이 아니라 "나이 미상"으로 보고 기존 행이 충분히 묵었을 때만 받아준다.
|
|
840
867
|
// (예전엔 epoch로 찍어 지게 만들었는데, 그 값이 앱까지 새어 "56년 전"으로 보였다.)
|
|
841
868
|
merged.push(sanitized);
|
|
842
869
|
// CodexBar CLI가 통째로 실패/미검출/타임아웃이면 statuses가 비어 있어 아무도 실패로
|
|
843
|
-
// 기록되지
|
|
870
|
+
// 기록되지 않는다, 판정이 없는 복원 항목만 'stale'로 채운다.
|
|
844
871
|
// 정상 수집이 남긴 판정(auth_expired/error 등)은 덮지 않는다.
|
|
845
872
|
if (nextStatuses && !nextStatuses[sanitized.id]) nextStatuses[sanitized.id] = "stale";
|
|
846
873
|
}
|
|
847
|
-
// 계정 식별 실패 시 마지막으로 알려진 계정 해시 유지 (계정 미상('')과 실제 계정 행이 갈라지는 것 방지)
|
|
848
|
-
for (const p of merged) {
|
|
849
|
-
if (!p.account) p.account = cached.find((c) => c.id === p.id)?.account ?? null;
|
|
850
|
-
}
|
|
851
|
-
// dedupe는 account 보정 뒤에 — 보정으로 비로소 같은 키가 되는 경우가 있다
|
|
852
874
|
return { providers: dedupeProviders(merged), statuses: nextStatuses };
|
|
853
875
|
}
|
|
854
876
|
|
|
877
|
+
function namespaceUnknownAccounts(providers, installationID) {
|
|
878
|
+
if (!installationID) return providers;
|
|
879
|
+
// 프로필 조회 실패 직전에 사용자가 계정을 바꿨을 수 있으므로 캐시의 예전 account를
|
|
880
|
+
// 추정해 붙이지 않는다. 확인되지 않은 값은 이 기기 전용 키로 보낸 뒤 서버에서 격리한다.
|
|
881
|
+
return dedupeProviders(providers.map((p) => p.account
|
|
882
|
+
? p
|
|
883
|
+
: { ...p, account: unknownAccountKey(installationID, p.id) }));
|
|
884
|
+
}
|
|
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
|
+
|
|
855
936
|
// 업로드 설정 — 우선순위: CHARGE_* 환경변수(테스트용) → ~/.charge/config.json(페어링)
|
|
856
937
|
function resolveMode() {
|
|
857
938
|
if (process.env.CHARGE_TOKEN && process.env.CHARGE_URL && process.env.CHARGE_ANON) {
|
|
858
|
-
return {
|
|
939
|
+
return {
|
|
940
|
+
url: process.env.CHARGE_URL,
|
|
941
|
+
anon: process.env.CHARGE_ANON,
|
|
942
|
+
token: process.env.CHARGE_TOKEN,
|
|
943
|
+
install_id: process.env.CHARGE_INSTALL_ID ?? null,
|
|
944
|
+
};
|
|
859
945
|
}
|
|
860
946
|
const conf = path.join(process.env.CHARGE_HOME ?? path.join(HOME, ".charge"), "config.json");
|
|
861
947
|
try {
|
|
@@ -866,7 +952,7 @@ function resolveMode() {
|
|
|
866
952
|
}
|
|
867
953
|
|
|
868
954
|
// 디바이스 토큰으로 charge_upload RPC 호출 (서버가 본인 행에만 기록)
|
|
869
|
-
// fetchFn은 테스트
|
|
955
|
+
// fetchFn은 테스트 주입용, 기본값이면 기존 동작 그대로.
|
|
870
956
|
async function pairedUpload(mode, daily, live, providers, collectStatus = null, { fetchFn = fetch } = {}) {
|
|
871
957
|
const post = (body) =>
|
|
872
958
|
fetchFn(`${mode.url}/rest/v1/rpc/charge_upload`, {
|
|
@@ -892,9 +978,9 @@ async function pairedUpload(mode, daily, live, providers, collectStatus = null,
|
|
|
892
978
|
res = await post(legacyBody);
|
|
893
979
|
if (res.ok) {
|
|
894
980
|
// 이 경로로 올라간 사이클은 collect_status가 서버에 반영되지 않는다(파라미터 자체가 없다).
|
|
895
|
-
// 상태는 매 사이클 통째로 다시 보내므로 서버가 갱신되면 다음 5분에 저절로 복구된다
|
|
981
|
+
// 상태는 매 사이클 통째로 다시 보내므로 서버가 갱신되면 다음 5분에 저절로 복구된다 ,
|
|
896
982
|
// 다만 "왜 상태가 비었나"를 로그 없이는 알 수 없어 흔적을 남긴다.
|
|
897
|
-
console.error("구버전 시그니처로 업로드
|
|
983
|
+
console.error("구버전 시그니처로 업로드 성공, collect_status는 이번 사이클 미반영(다음 사이클에 복구)");
|
|
898
984
|
return;
|
|
899
985
|
}
|
|
900
986
|
throw new Error(`업로드 실패 (${res.status}): ${await res.text()}`);
|
|
@@ -903,6 +989,14 @@ async function pairedUpload(mode, daily, live, providers, collectStatus = null,
|
|
|
903
989
|
}
|
|
904
990
|
|
|
905
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
|
+
}
|
|
906
1000
|
// 이전 성공 페이로드 캐시 — 일부 수집이 실패해도 그 부분만 이전 값으로 유지
|
|
907
1001
|
let cache = {};
|
|
908
1002
|
try {
|
|
@@ -928,7 +1022,7 @@ async function main() {
|
|
|
928
1022
|
live = liveR.value;
|
|
929
1023
|
} else {
|
|
930
1024
|
console.error(`live 수집 실패, 이전 값 유지: ${liveR.reason?.message ?? liveR.reason}`);
|
|
931
|
-
// 캐시 블록은 collected_at을 갱신하지 않고 통째로
|
|
1025
|
+
// 캐시 블록은 collected_at을 갱신하지 않고 통째로 재사용한다, 갱신하면 수집이 깨진
|
|
932
1026
|
// 기기의 옛 블록이 다른 기기의 진짜 활성 블록을 서버 신선도 규칙에서 이겨버린다.
|
|
933
1027
|
live = cache.live ?? null;
|
|
934
1028
|
}
|
|
@@ -949,13 +1043,18 @@ async function main() {
|
|
|
949
1043
|
statuses: providerCollection.statuses,
|
|
950
1044
|
}));
|
|
951
1045
|
|
|
1046
|
+
providers = namespaceUnknownAccounts(providers, mode?.install_id);
|
|
1047
|
+
statuses = recordCollectionHealth(statuses, {
|
|
1048
|
+
scope: accountHash(mode?.token),
|
|
1049
|
+
persist: !DRY_RUN && !!mode,
|
|
1050
|
+
});
|
|
1051
|
+
|
|
952
1052
|
if (DRY_RUN) {
|
|
953
1053
|
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers, collect_status: statuses }, null, 2));
|
|
954
1054
|
console.log(`\n[dry-run] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 예정`);
|
|
955
1055
|
return;
|
|
956
1056
|
}
|
|
957
1057
|
|
|
958
|
-
const mode = resolveMode();
|
|
959
1058
|
if (!mode) {
|
|
960
1059
|
console.error("페어링이 안 돼 있습니다. 앱에서 코드를 발급받아 `npx charge-connect <코드>`를 실행하세요.");
|
|
961
1060
|
process.exit(1);
|
|
@@ -977,7 +1076,10 @@ if (require.main === module) {
|
|
|
977
1076
|
|
|
978
1077
|
module.exports = {
|
|
979
1078
|
CCUSAGE_PKG,
|
|
1079
|
+
advanceCollectionHealth,
|
|
980
1080
|
accountHash,
|
|
1081
|
+
claudeCredentialLocation,
|
|
1082
|
+
claudeCredentials,
|
|
981
1083
|
claudeProvider,
|
|
982
1084
|
codexBarEntryToProvider,
|
|
983
1085
|
codexLiveWindows,
|
|
@@ -991,11 +1093,13 @@ module.exports = {
|
|
|
991
1093
|
fetchOnceRetried,
|
|
992
1094
|
freshestCredentials,
|
|
993
1095
|
mergeCachedProviders,
|
|
1096
|
+
namespaceUnknownAccounts,
|
|
994
1097
|
normalizeRateWindow,
|
|
995
1098
|
normalizeResetAt,
|
|
996
1099
|
pairedUpload,
|
|
997
1100
|
parseCodexBarJSON,
|
|
998
1101
|
percentValue,
|
|
1102
|
+
recordCollectionHealth,
|
|
999
1103
|
resolveMode,
|
|
1000
1104
|
runAsync,
|
|
1001
1105
|
sanitizeCachedProvider,
|
package/identity.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
|
|
5
|
+
const INSTALLATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
6
|
+
|
|
7
|
+
function parseInstallationID(raw) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(String(raw));
|
|
10
|
+
const id = typeof parsed === "string" ? parsed : parsed?.installation_id;
|
|
11
|
+
return typeof id === "string" && INSTALLATION_ID_RE.test(id) ? id.toLowerCase() : null;
|
|
12
|
+
} catch {
|
|
13
|
+
const id = String(raw).trim();
|
|
14
|
+
return INSTALLATION_ID_RE.test(id) ? id.toLowerCase() : null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 페어링 토큰(config.json)과 분리해 보관한다. unpair/re-pair나 토큰 회전 뒤에도 같은
|
|
19
|
+
// 설치를 같은 기기로 알아봐야 하고, 호스트명은 표시 이름일 뿐 식별자가 아니기 때문이다.
|
|
20
|
+
function resolveInstallationID(file, { randomUUID = crypto.randomUUID } = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const existing = parseInstallationID(fs.readFileSync(file, "utf8"));
|
|
23
|
+
if (existing) return existing;
|
|
24
|
+
} catch {}
|
|
25
|
+
|
|
26
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
27
|
+
try { fs.chmodSync(path.dirname(file), 0o700); } catch {}
|
|
28
|
+
|
|
29
|
+
const created = randomUUID().toLowerCase();
|
|
30
|
+
const body = JSON.stringify({ installation_id: created }, null, 2);
|
|
31
|
+
try {
|
|
32
|
+
// 동시에 두 페어링이 시작돼도 먼저 쓴 id가 정본이다.
|
|
33
|
+
fs.writeFileSync(file, body, { flag: "wx", mode: 0o600 });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e?.code !== "EEXIST") throw e;
|
|
36
|
+
const winner = parseInstallationID(fs.readFileSync(file, "utf8"));
|
|
37
|
+
if (winner) return winner;
|
|
38
|
+
// 기존 파일이 깨졌다면 새 정상값으로 복구한다. 이 파일에는 자격증명이 없다.
|
|
39
|
+
fs.writeFileSync(file, body, { mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
42
|
+
return created;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 계정 UUID를 못 얻은 관측끼리 전역 account='' 행에서 경쟁하지 않도록 기기별로 격리한다.
|
|
46
|
+
// 접두사는 앱/백엔드가 "확인된 동일 계정"과 구분할 수 있게 의도적으로 남긴다.
|
|
47
|
+
function unknownAccountKey(installationID, providerID) {
|
|
48
|
+
if (!installationID || !providerID) return null;
|
|
49
|
+
const digest = crypto
|
|
50
|
+
.createHash("sha256")
|
|
51
|
+
.update(`${installationID}\0${providerID}`)
|
|
52
|
+
.digest("hex")
|
|
53
|
+
.slice(0, 16);
|
|
54
|
+
return `unknown:${digest}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { parseInstallationID, resolveInstallationID, unknownAccountKey };
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "charge-connect",
|
|
3
|
-
"version": "0.1.
|
|
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"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "node --test collect.test.js"
|
|
9
|
+
"test": "node --test collect.test.js cli.test.js"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"cli.js",
|
|
13
13
|
"collect.js",
|
|
14
|
+
"identity.js",
|
|
14
15
|
"install.sh",
|
|
15
16
|
"install.linux.sh",
|
|
16
17
|
"install.ps1",
|