charge-connect 0.1.0
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 +109 -0
- package/cloud.json +4 -0
- package/collect.js +634 -0
- package/install.ps1 +27 -0
- package/install.sh +41 -0
- package/package.json +26 -0
package/cli.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// charge-connect CLI — 앱에서 받은 페어링 코드로 연결하고 자동 수집을 등록한다.
|
|
3
|
+
//
|
|
4
|
+
// 사용법:
|
|
5
|
+
// npx charge-connect <페어링코드> # 페어링 + 첫 수집 + 스케줄 등록 (일반 사용자용 원라이너)
|
|
6
|
+
// charge-connect run # 수집 1회 (스케줄러가 호출)
|
|
7
|
+
// charge-connect unpair # 페어링 해제
|
|
8
|
+
//
|
|
9
|
+
// 설정 파일: ~/.charge/config.json (CHARGE_HOME으로 위치 변경 가능)
|
|
10
|
+
|
|
11
|
+
const fs = require("node:fs");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
const { execFileSync } = require("node:child_process");
|
|
14
|
+
|
|
15
|
+
const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
16
|
+
const CONF_DIR = process.env.CHARGE_HOME ?? path.join(HOME, ".charge");
|
|
17
|
+
const CONF = path.join(CONF_DIR, "config.json");
|
|
18
|
+
const WIN = process.platform === "win32";
|
|
19
|
+
|
|
20
|
+
// 백엔드 주소 — collector/cloud.json (출시 시 charge 전용 프로젝트로 교체, npm 패키지에 포함)
|
|
21
|
+
const CLOUD = (() => {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(fs.readFileSync(path.join(__dirname, "cloud.json"), "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
|
|
29
|
+
async function pair(code) {
|
|
30
|
+
const url = process.env.CHARGE_URL ?? CLOUD.url;
|
|
31
|
+
const anon = process.env.CHARGE_ANON ?? CLOUD.anon;
|
|
32
|
+
if (!url || !anon) {
|
|
33
|
+
console.error("백엔드 주소가 없습니다 (collector/cloud.json 또는 CHARGE_URL/CHARGE_ANON).");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const res = await fetch(`${url}/rest/v1/rpc/charge_claim_pairing_code`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { apikey: anon, Authorization: `Bearer ${anon}`, "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify({ p_code: code, p_label: require("node:os").hostname() }),
|
|
40
|
+
signal: AbortSignal.timeout(15_000),
|
|
41
|
+
});
|
|
42
|
+
const token = res.ok ? await res.json() : null;
|
|
43
|
+
if (!token || typeof token !== "string") {
|
|
44
|
+
console.error(res.ok ? "잘못됐거나 만료된 페어링 코드입니다." : `페어링 실패 (${res.status}): ${await res.text()}`);
|
|
45
|
+
console.error("앱에서 새 코드를 발급받아 다시 시도하세요 (코드는 10분간 유효).");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
// 디바이스 토큰은 본인만 읽을 수 있게 저장 (0700/0600)
|
|
49
|
+
fs.mkdirSync(CONF_DIR, { recursive: true, mode: 0o700 });
|
|
50
|
+
try { fs.chmodSync(CONF_DIR, 0o700); } catch {}
|
|
51
|
+
fs.writeFileSync(CONF, JSON.stringify({ url, anon, token }, null, 2), { mode: 0o600 });
|
|
52
|
+
try { fs.chmodSync(CONF, 0o600); } catch {}
|
|
53
|
+
console.log("✓ 페어링 완료");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// npx 캐시는 언제든 청소될 수 있으므로, 스케줄 등록 전에 런타임을 ~/.charge/app 으로 복사한다
|
|
57
|
+
function installRuntime() {
|
|
58
|
+
const dest = path.join(CONF_DIR, "app");
|
|
59
|
+
fs.mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
60
|
+
for (const f of ["cli.js", "collect.js", "install.sh", "install.ps1", "cloud.json", "package.json"]) {
|
|
61
|
+
const src = path.join(__dirname, f);
|
|
62
|
+
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(dest, f));
|
|
63
|
+
}
|
|
64
|
+
if (!WIN) {
|
|
65
|
+
try { fs.chmodSync(path.join(dest, "install.sh"), 0o755); } catch {}
|
|
66
|
+
}
|
|
67
|
+
return dest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function collect(dir = __dirname) {
|
|
71
|
+
execFileSync(process.execPath, [path.join(dir, "collect.js")], { stdio: "inherit" });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function installSchedule() {
|
|
75
|
+
const dir = installRuntime();
|
|
76
|
+
const p = path.join(dir, WIN ? "install.ps1" : "install.sh");
|
|
77
|
+
if (WIN) {
|
|
78
|
+
execFileSync("powershell.exe", ["-ExecutionPolicy", "Bypass", "-File", p], { stdio: "inherit" });
|
|
79
|
+
} else {
|
|
80
|
+
execFileSync("/bin/bash", [p], { stdio: "inherit" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
(async () => {
|
|
85
|
+
const arg = process.argv[2];
|
|
86
|
+
if (!arg) {
|
|
87
|
+
console.error("사용법: npx charge-connect <페어링코드> (앱 온보딩에서 코드 발급)");
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
if (arg === "run") {
|
|
91
|
+
collect();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (arg === "unpair") {
|
|
95
|
+
fs.rmSync(CONF, { force: true });
|
|
96
|
+
console.log("✓ 페어링 해제 (스케줄 해제는 install.sh/install.ps1 안내 참고)");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// 페어링 코드로 간주: 페어링 → 첫 수집 → 스케줄 등록
|
|
100
|
+
await pair(arg);
|
|
101
|
+
console.log("첫 수집을 실행합니다…");
|
|
102
|
+
collect();
|
|
103
|
+
console.log("5분 간격 자동 수집을 등록합니다…");
|
|
104
|
+
installSchedule();
|
|
105
|
+
console.log("✓ 설정 끝! 이제 앱에서 데이터가 보입니다.");
|
|
106
|
+
})().catch((e) => {
|
|
107
|
+
console.error(e.message ?? e);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
});
|
package/cloud.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"url": "https://kfzdukmoprvmqiyrptqt.supabase.co",
|
|
3
|
+
"anon": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtmemR1a21vcHJ2bXFpeXJwdHF0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODUzMzAyODUsImV4cCI6MjEwMDkwNjI4NX0.MCrxQhsoKEMMnM5_x1huHdbyMjph5TWJ83hptP9s-9I"
|
|
4
|
+
}
|
package/collect.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Charge 수집기 — ccusage로 토큰 사용량을 뽑아 Charge 백엔드에 업로드한다.
|
|
3
|
+
// 사용법: node collect.js [--dry-run] [--days N]
|
|
4
|
+
// 인증: `npx charge-connect <페어링코드>`가 저장한 ~/.charge/config.json의 디바이스 토큰
|
|
5
|
+
|
|
6
|
+
const { execFileSync } = require("node:child_process");
|
|
7
|
+
const fs = require("node:fs");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
|
|
10
|
+
const DRY_RUN = process.argv.includes("--dry-run");
|
|
11
|
+
const daysArg = process.argv.indexOf("--days");
|
|
12
|
+
const parsedDays = daysArg > -1 ? parseInt(process.argv[daysArg + 1], 10) : NaN;
|
|
13
|
+
const DAYS = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 60;
|
|
14
|
+
const CACHE_FILE = path.join(__dirname, ".last-payload.json");
|
|
15
|
+
const HOME = process.env.HOME ?? process.env.USERPROFILE;
|
|
16
|
+
const WIN = process.platform === "win32";
|
|
17
|
+
const EXTRA_PATH = WIN ? "" : ":/opt/homebrew/bin:/usr/local/bin";
|
|
18
|
+
const DEFAULT_CODEXBAR_CLI = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI";
|
|
19
|
+
|
|
20
|
+
function run(cmd, args, timeout = 120_000) {
|
|
21
|
+
return execFileSync(cmd, args, {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
24
|
+
timeout,
|
|
25
|
+
env: { ...process.env, PATH: `${process.env.PATH}${EXTRA_PATH}` },
|
|
26
|
+
// Windows에서 npx/ccusage는 .cmd 셔틀이라 셸을 거쳐야 실행된다 (인자는 모두 고정 문자열)
|
|
27
|
+
shell: WIN,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runCcusage(args) {
|
|
32
|
+
// 전역 설치본이 있으면 사용(빠름), 없으면 npx로 대체
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(run("ccusage", args));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error(`ccusage 직접 실행 실패(${e.code ?? e.message}), npx로 재시도`);
|
|
37
|
+
return JSON.parse(run("npx", ["-y", "ccusage@latest", ...args], 300_000));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 프로바이더 계정 식별자 해시 — 원문 대신 해시만 업로드 (다른 계정이면 카드가 분리되도록)
|
|
42
|
+
function accountHash(id) {
|
|
43
|
+
if (!id) return null;
|
|
44
|
+
return require("node:crypto").createHash("sha256").update(String(id)).digest("hex").slice(0, 12);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// 리셋 시각이 이미 지난 창은 무효 처리 (리셋 후 100% 박제 방지)
|
|
48
|
+
function dropExpired(w) {
|
|
49
|
+
if (!w) return null;
|
|
50
|
+
if (w.resets_at && new Date(w.resets_at).getTime() < Date.now()) return null;
|
|
51
|
+
return w;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const PROVIDER_NAMES = {
|
|
55
|
+
"azure-openai": "Azure OpenAI",
|
|
56
|
+
"openai": "OpenAI",
|
|
57
|
+
"opencode": "OpenCode",
|
|
58
|
+
"opencodego": "OpenCode Go",
|
|
59
|
+
"openrouter": "OpenRouter",
|
|
60
|
+
"vertexai": "Vertex AI",
|
|
61
|
+
"alibaba-coding-plan": "Alibaba Coding Plan",
|
|
62
|
+
"alibaba-token-plan": "Alibaba Token Plan",
|
|
63
|
+
"antigravity": "Antigravity",
|
|
64
|
+
"copilot": "GitHub Copilot",
|
|
65
|
+
"deepseek": "DeepSeek",
|
|
66
|
+
"jetbrains": "JetBrains",
|
|
67
|
+
"minimax": "MiniMax",
|
|
68
|
+
"perplexity": "Perplexity",
|
|
69
|
+
"windsurf": "Windsurf",
|
|
70
|
+
"zai": "Z.ai",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function titleCaseProvider(id) {
|
|
74
|
+
return String(id)
|
|
75
|
+
.split(/[-_.]+/)
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
|
78
|
+
.join(" ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeResetAt(value) {
|
|
82
|
+
if (value == null || value === "") return null;
|
|
83
|
+
const numeric = typeof value === "number" ? value : Number.NaN;
|
|
84
|
+
const date = Number.isFinite(numeric)
|
|
85
|
+
? new Date(numeric < 10_000_000_000 ? numeric * 1000 : numeric)
|
|
86
|
+
: new Date(value);
|
|
87
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function durationLabel(minutes) {
|
|
91
|
+
if (minutes === 300) return "Session";
|
|
92
|
+
if (minutes === 1440) return "Daily";
|
|
93
|
+
if (minutes === 10_080) return "Weekly";
|
|
94
|
+
if (minutes === 43_200) return "Monthly";
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function explicitWindowLabel(window) {
|
|
99
|
+
const value = window?.label ?? window?.title ?? window?.displayName ?? window?.name ?? window?.limitName;
|
|
100
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeRateWindow(window, slot, duplicateDuration = false, forcedLabel = null) {
|
|
104
|
+
if (!window || typeof window !== "object") return null;
|
|
105
|
+
const percent = Number(window.usedPercent ?? window.percent ?? window.utilization);
|
|
106
|
+
if (!Number.isFinite(percent)) return null;
|
|
107
|
+
const rawMinutes = window.windowMinutes ?? window.window_minutes ?? window.durationMinutes;
|
|
108
|
+
const parsedMinutes = rawMinutes == null ? null : Number(rawMinutes);
|
|
109
|
+
const minutes = Number.isFinite(parsedMinutes) && parsedMinutes > 0 ? parsedMinutes : null;
|
|
110
|
+
const baseLabel = durationLabel(minutes);
|
|
111
|
+
const slotLabel = slot[0].toUpperCase() + slot.slice(1);
|
|
112
|
+
const label = forcedLabel
|
|
113
|
+
?? explicitWindowLabel(window)
|
|
114
|
+
?? (duplicateDuration && baseLabel ? `${slotLabel} ${baseLabel.toLowerCase()}` : baseLabel)
|
|
115
|
+
?? slotLabel;
|
|
116
|
+
return dropExpired({
|
|
117
|
+
percent: Math.min(100, Math.max(0, percent)),
|
|
118
|
+
resets_at: normalizeResetAt(window.resetsAt ?? window.resets_at ?? window.resetAt),
|
|
119
|
+
window_minutes: minutes,
|
|
120
|
+
label,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function textValue(...values) {
|
|
125
|
+
return values.find((value) => typeof value === "string" && value.trim())?.trim() ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function usefulPlan(...values) {
|
|
129
|
+
const value = textValue(...values);
|
|
130
|
+
if (!value) return null;
|
|
131
|
+
const generic = new Set(["api", "auto", "browser", "cli", "cookie", "oauth", "unknown", "web"]);
|
|
132
|
+
return generic.has(value.toLowerCase()) ? null : value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sinceStr(days) {
|
|
136
|
+
const d = new Date(Date.now() - days * 86400_000);
|
|
137
|
+
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function collectDaily() {
|
|
141
|
+
const data = runCcusage(["daily", "--json", "--since", sinceStr(DAYS)]);
|
|
142
|
+
let rows = data.daily ?? [];
|
|
143
|
+
// 에이전트별 행과 "all" 집계 행이 섞여 있으면 "all"만 사용해 중복 합산을 방지
|
|
144
|
+
if (rows.some((r) => r.agent === "all")) rows = rows.filter((r) => r.agent === "all");
|
|
145
|
+
const byDay = new Map();
|
|
146
|
+
for (const r of rows) {
|
|
147
|
+
const e = byDay.get(r.period) ?? {
|
|
148
|
+
period: r.period,
|
|
149
|
+
total_cost: 0, total_tokens: 0, input_tokens: 0, output_tokens: 0,
|
|
150
|
+
cache_read_tokens: 0, cache_creation_tokens: 0, models: [],
|
|
151
|
+
};
|
|
152
|
+
e.total_cost += r.totalCost ?? 0;
|
|
153
|
+
e.total_tokens += r.totalTokens ?? 0;
|
|
154
|
+
e.input_tokens += r.inputTokens ?? 0;
|
|
155
|
+
e.output_tokens += r.outputTokens ?? 0;
|
|
156
|
+
e.cache_read_tokens += r.cacheReadTokens ?? 0;
|
|
157
|
+
e.cache_creation_tokens += r.cacheCreationTokens ?? 0;
|
|
158
|
+
e.models = e.models.concat(r.modelBreakdowns ?? []);
|
|
159
|
+
byDay.set(r.period, e);
|
|
160
|
+
}
|
|
161
|
+
return [...byDay.values()].sort((a, b) => a.period.localeCompare(b.period));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function collectLive() {
|
|
165
|
+
const data = runCcusage(["blocks", "--active", "--json"]);
|
|
166
|
+
return (data.blocks ?? []).find((b) => b.isActive) ?? null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Claude Code 자격증명: macOS는 Keychain, Windows/Linux는 ~/.claude/.credentials.json
|
|
170
|
+
function claudeCredentials() {
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(run("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"]));
|
|
173
|
+
} catch {
|
|
174
|
+
return JSON.parse(fs.readFileSync(path.join(HOME, ".claude", ".credentials.json"), "utf8"));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Claude: Claude Code OAuth 세션을 재사용해 공식 usage API 조회
|
|
179
|
+
async function claudeProvider() {
|
|
180
|
+
try {
|
|
181
|
+
const cred = claudeCredentials();
|
|
182
|
+
const oauth = cred.claudeAiOauth ?? cred;
|
|
183
|
+
const token = oauth.accessToken;
|
|
184
|
+
// rateLimitTier "default_claude_max_20x" → "Max 20x", 없으면 subscriptionType "max" → "Max"
|
|
185
|
+
const tier = /max_(\d+)x/.exec(oauth.rateLimitTier ?? "");
|
|
186
|
+
const plan = tier
|
|
187
|
+
? `Max ${tier[1]}x`
|
|
188
|
+
: oauth.subscriptionType
|
|
189
|
+
? oauth.subscriptionType[0].toUpperCase() + oauth.subscriptionType.slice(1)
|
|
190
|
+
: null;
|
|
191
|
+
const res = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
192
|
+
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
193
|
+
signal: AbortSignal.timeout(10_000),
|
|
194
|
+
});
|
|
195
|
+
if (!res.ok) return null;
|
|
196
|
+
const d = await res.json();
|
|
197
|
+
// 계정 UUID → 해시 (프로필 조회 실패 시 null — 캐시 폴백이 채워준다)
|
|
198
|
+
let account = null;
|
|
199
|
+
try {
|
|
200
|
+
const pr = await fetch("https://api.anthropic.com/api/oauth/profile", {
|
|
201
|
+
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
202
|
+
signal: AbortSignal.timeout(10_000),
|
|
203
|
+
});
|
|
204
|
+
if (pr.ok) account = accountHash((await pr.json()).account?.uuid);
|
|
205
|
+
} catch {}
|
|
206
|
+
const win = (w, mins) =>
|
|
207
|
+
w ? { percent: w.utilization ?? 0, resets_at: w.resets_at ?? null, window_minutes: mins } : null;
|
|
208
|
+
// limits 배열의 weekly_scoped 항목 = 모델별 주간 한도 (예: "Fable only")
|
|
209
|
+
const extras = (d.limits ?? [])
|
|
210
|
+
.filter((l) => l.kind === "weekly_scoped" && l.scope?.model?.display_name)
|
|
211
|
+
.map((l) => ({
|
|
212
|
+
name: l.scope.model.display_name,
|
|
213
|
+
window: {
|
|
214
|
+
percent: l.percent ?? 0,
|
|
215
|
+
resets_at: l.resets_at ?? null,
|
|
216
|
+
window_minutes: 10080,
|
|
217
|
+
label: `${l.scope.model.display_name} weekly`,
|
|
218
|
+
},
|
|
219
|
+
}));
|
|
220
|
+
return {
|
|
221
|
+
id: "claude",
|
|
222
|
+
name: "Claude",
|
|
223
|
+
plan,
|
|
224
|
+
account,
|
|
225
|
+
session: dropExpired(win(d.five_hour, 300)),
|
|
226
|
+
weekly: dropExpired(win(d.seven_day, 10080)),
|
|
227
|
+
extras: extras.length ? extras : null,
|
|
228
|
+
};
|
|
229
|
+
} catch (e) {
|
|
230
|
+
console.error(`claude 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Codex: 최신 세션 로그에 기록된 rate_limits 스냅샷 파싱
|
|
236
|
+
// Codex 자격증명·계정 정보 (~/.codex/auth.json) — 없으면 null
|
|
237
|
+
function codexAuth() {
|
|
238
|
+
try {
|
|
239
|
+
const auth = JSON.parse(fs.readFileSync(path.join(HOME, ".codex", "auth.json"), "utf8"));
|
|
240
|
+
let plan = null;
|
|
241
|
+
let account = null;
|
|
242
|
+
try {
|
|
243
|
+
// id_token JWT의 chatgpt_plan_type ("education" 등) + 계정 식별자
|
|
244
|
+
const seg = auth.tokens.id_token.split(".")[1];
|
|
245
|
+
const claims = JSON.parse(Buffer.from(seg, "base64url").toString("utf8"));
|
|
246
|
+
const a = claims["https://api.openai.com/auth"] ?? {};
|
|
247
|
+
if (a.chatgpt_plan_type) plan = a.chatgpt_plan_type[0].toUpperCase() + a.chatgpt_plan_type.slice(1);
|
|
248
|
+
account = accountHash(a.chatgpt_account_id);
|
|
249
|
+
} catch {}
|
|
250
|
+
return {
|
|
251
|
+
accessToken: auth.tokens?.access_token ?? null,
|
|
252
|
+
accountId: auth.tokens?.account_id ?? null,
|
|
253
|
+
plan,
|
|
254
|
+
account,
|
|
255
|
+
};
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Codex 실시간 조회: Codex CLI 자신이 60초마다 폴링하는 것과 같은 엔드포인트를
|
|
262
|
+
// 로컬 OAuth 토큰으로 호출한다. 토큰 갱신은 하지 않는다 — 만료됐으면 null을 반환하고
|
|
263
|
+
// 스냅샷 폴백에 맡긴다 (CLI가 다음 실행 때 알아서 갱신해 둔다).
|
|
264
|
+
async function codexLiveWindows(auth) {
|
|
265
|
+
if (!auth?.accessToken || !auth?.accountId) return null;
|
|
266
|
+
try {
|
|
267
|
+
const res = await fetch("https://chatgpt.com/backend-api/wham/usage", {
|
|
268
|
+
headers: {
|
|
269
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
270
|
+
"chatgpt-account-id": auth.accountId,
|
|
271
|
+
"User-Agent": "charge-connect",
|
|
272
|
+
},
|
|
273
|
+
signal: AbortSignal.timeout(10_000),
|
|
274
|
+
});
|
|
275
|
+
if (!res.ok) return null;
|
|
276
|
+
const d = await res.json();
|
|
277
|
+
const win = (w) =>
|
|
278
|
+
w && Number.isFinite(Number(w.used_percent))
|
|
279
|
+
? {
|
|
280
|
+
percent: Number(w.used_percent),
|
|
281
|
+
resets_at: w.reset_at ? new Date(w.reset_at * 1000).toISOString() : null,
|
|
282
|
+
window_minutes: Number.isFinite(Number(w.limit_window_seconds))
|
|
283
|
+
? Math.round(Number(w.limit_window_seconds) / 60)
|
|
284
|
+
: null,
|
|
285
|
+
}
|
|
286
|
+
: null;
|
|
287
|
+
const session = win(d.rate_limit?.primary_window);
|
|
288
|
+
const weekly = win(d.rate_limit?.secondary_window);
|
|
289
|
+
if (!session && !weekly) return null;
|
|
290
|
+
const plan = usefulPlan(d.plan_type);
|
|
291
|
+
return { session, weekly, plan: plan ? plan[0].toUpperCase() + plan.slice(1) : null };
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Codex 스냅샷 폴백: 가장 최근 세션 로그(.jsonl)에 기록된 마지막 rate_limits —
|
|
298
|
+
// 마지막으로 Codex를 실제 사용한 시점의 값이라 실시간 조회가 실패했을 때만 쓴다
|
|
299
|
+
function codexSnapshotWindows() {
|
|
300
|
+
const dir = path.join(HOME, ".codex", "sessions");
|
|
301
|
+
let latest = null;
|
|
302
|
+
(function walk(d) {
|
|
303
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
|
|
304
|
+
const p = path.join(d, e.name);
|
|
305
|
+
if (e.isDirectory()) walk(p);
|
|
306
|
+
else if (e.name.endsWith(".jsonl")) {
|
|
307
|
+
const m = fs.statSync(p).mtimeMs;
|
|
308
|
+
if (!latest || m > latest.m) latest = { p, m };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
})(dir);
|
|
312
|
+
if (!latest) return null;
|
|
313
|
+
|
|
314
|
+
const findRL = (o) => {
|
|
315
|
+
if (!o || typeof o !== "object") return null;
|
|
316
|
+
if (o.rate_limits) return o.rate_limits;
|
|
317
|
+
for (const v of Object.values(o)) {
|
|
318
|
+
const r = findRL(v);
|
|
319
|
+
if (r) return r;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
};
|
|
323
|
+
let rl = null;
|
|
324
|
+
for (const line of fs.readFileSync(latest.p, "utf8").split("\n")) {
|
|
325
|
+
if (!line.includes('"rate_limits"')) continue;
|
|
326
|
+
try {
|
|
327
|
+
rl = findRL(JSON.parse(line)) ?? rl;
|
|
328
|
+
} catch {}
|
|
329
|
+
}
|
|
330
|
+
if (!rl) return null;
|
|
331
|
+
const win = (w) =>
|
|
332
|
+
w
|
|
333
|
+
? {
|
|
334
|
+
percent: w.used_percent ?? 0,
|
|
335
|
+
resets_at: w.resets_at ? new Date(w.resets_at * 1000).toISOString() : null,
|
|
336
|
+
window_minutes: w.window_minutes ?? null,
|
|
337
|
+
}
|
|
338
|
+
: null;
|
|
339
|
+
return { session: win(rl.primary), weekly: win(rl.secondary) };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function codexProvider() {
|
|
343
|
+
try {
|
|
344
|
+
const auth = codexAuth();
|
|
345
|
+
const windows = (await codexLiveWindows(auth)) ?? codexSnapshotWindows();
|
|
346
|
+
if (!windows) return null;
|
|
347
|
+
return {
|
|
348
|
+
id: "codex",
|
|
349
|
+
name: "Codex",
|
|
350
|
+
plan: windows.plan ?? auth?.plan ?? null,
|
|
351
|
+
account: auth?.account ?? null,
|
|
352
|
+
session: dropExpired(windows.session),
|
|
353
|
+
weekly: dropExpired(windows.weekly),
|
|
354
|
+
extras: null,
|
|
355
|
+
};
|
|
356
|
+
} catch (e) {
|
|
357
|
+
console.error(`codex 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function extraWindowValues(value) {
|
|
363
|
+
if (Array.isArray(value)) return value;
|
|
364
|
+
if (!value || typeof value !== "object") return [];
|
|
365
|
+
return Object.entries(value).map(([name, window]) => ({ name, ...(window ?? {}) }));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// CodexBar 공통 JSON을 Charge의 두 기본 창 + 추가 창 구조로 변환한다.
|
|
369
|
+
// 원본 identity는 절대 반환하지 않고 계정 식별값의 짧은 해시만 보낸다.
|
|
370
|
+
function codexBarEntryToProvider(entry) {
|
|
371
|
+
if (!entry || entry.error || typeof entry !== "object") return null;
|
|
372
|
+
const id = textValue(entry.provider, entry.id)?.toLowerCase();
|
|
373
|
+
if (!id || id === "claude" || id === "codex") return null;
|
|
374
|
+
|
|
375
|
+
const usage = entry.usage && typeof entry.usage === "object" ? entry.usage : entry;
|
|
376
|
+
const identity = usage.identity && typeof usage.identity === "object"
|
|
377
|
+
? usage.identity
|
|
378
|
+
: entry.identity && typeof entry.identity === "object" ? entry.identity : {};
|
|
379
|
+
const slots = ["primary", "secondary", "tertiary"]
|
|
380
|
+
.map((slot) => ({ slot, value: usage[slot] ?? entry[slot] }))
|
|
381
|
+
.filter(({ value }) => value && typeof value === "object");
|
|
382
|
+
const durationCounts = new Map();
|
|
383
|
+
for (const { value } of slots) {
|
|
384
|
+
const minutes = Number(value.windowMinutes ?? value.window_minutes ?? value.durationMinutes);
|
|
385
|
+
if (Number.isFinite(minutes)) durationCounts.set(minutes, (durationCounts.get(minutes) ?? 0) + 1);
|
|
386
|
+
}
|
|
387
|
+
const normalizedWindows = slots
|
|
388
|
+
.map(({ slot, value }) => {
|
|
389
|
+
const minutes = Number(value.windowMinutes ?? value.window_minutes ?? value.durationMinutes);
|
|
390
|
+
return {
|
|
391
|
+
slot,
|
|
392
|
+
window: normalizeRateWindow(value, slot, (durationCounts.get(minutes) ?? 0) > 1),
|
|
393
|
+
};
|
|
394
|
+
})
|
|
395
|
+
.filter(({ window }) => window);
|
|
396
|
+
const primary = normalizedWindows.find(({ slot }) => slot === "primary")?.window ?? null;
|
|
397
|
+
const secondary = normalizedWindows.find(({ slot }) => slot === "secondary")?.window ?? null;
|
|
398
|
+
|
|
399
|
+
const extras = normalizedWindows
|
|
400
|
+
.filter(({ slot }) => slot === "tertiary")
|
|
401
|
+
.map(({ window }) => ({ name: window.label, window }));
|
|
402
|
+
const rawExtras = extraWindowValues(usage.extraRateWindows ?? entry.extraRateWindows);
|
|
403
|
+
rawExtras.forEach((raw, index) => {
|
|
404
|
+
const value = raw.window && typeof raw.window === "object" ? raw.window : raw;
|
|
405
|
+
const label = explicitWindowLabel(raw)
|
|
406
|
+
?? explicitWindowLabel(value)
|
|
407
|
+
?? `Limit ${index + normalizedWindows.length + 1}`;
|
|
408
|
+
const window = normalizeRateWindow(value, "limit", false, label);
|
|
409
|
+
if (window) extras.push({ name: label, window });
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
if (!primary && !secondary && extras.length === 0) return null;
|
|
413
|
+
const rawAccount = textValue(
|
|
414
|
+
identity.accountEmail,
|
|
415
|
+
identity.email,
|
|
416
|
+
usage.accountEmail,
|
|
417
|
+
entry.accountEmail,
|
|
418
|
+
identity.accountId,
|
|
419
|
+
usage.accountId,
|
|
420
|
+
identity.accountOrganization,
|
|
421
|
+
usage.accountOrganization
|
|
422
|
+
);
|
|
423
|
+
const plan = usefulPlan(
|
|
424
|
+
usage.plan,
|
|
425
|
+
identity.plan,
|
|
426
|
+
usage.subscription,
|
|
427
|
+
identity.subscription,
|
|
428
|
+
usage.loginMethod,
|
|
429
|
+
identity.loginMethod
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
id,
|
|
434
|
+
name: textValue(entry.displayName, entry.providerName, usage.providerName) ?? PROVIDER_NAMES[id] ?? titleCaseProvider(id),
|
|
435
|
+
plan,
|
|
436
|
+
account: accountHash(rawAccount),
|
|
437
|
+
session: primary,
|
|
438
|
+
weekly: secondary,
|
|
439
|
+
extras: extras.length ? extras : null,
|
|
440
|
+
status: null,
|
|
441
|
+
collector_source: "codexbar",
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function parseCodexBarJSON(raw) {
|
|
446
|
+
const text = String(raw ?? "").trim();
|
|
447
|
+
if (!text) return null;
|
|
448
|
+
try {
|
|
449
|
+
const parsed = JSON.parse(text);
|
|
450
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
451
|
+
} catch {
|
|
452
|
+
const start = text.indexOf("[");
|
|
453
|
+
const end = text.lastIndexOf("]");
|
|
454
|
+
if (start < 0 || end <= start) return null;
|
|
455
|
+
try {
|
|
456
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
457
|
+
} catch {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function collectCodexBarProviders() {
|
|
464
|
+
if (process.env.CHARGE_DISABLE_CODEXBAR === "1") return { providers: [], complete: false };
|
|
465
|
+
const cli = process.env.CHARGE_CODEXBAR_CLI
|
|
466
|
+
?? (!WIN && fs.existsSync(DEFAULT_CODEXBAR_CLI) ? DEFAULT_CODEXBAR_CLI : null);
|
|
467
|
+
if (!cli) return { providers: [], complete: false };
|
|
468
|
+
|
|
469
|
+
let raw = "";
|
|
470
|
+
let commandSucceeded = true;
|
|
471
|
+
try {
|
|
472
|
+
raw = run(cli, ["usage", "--format", "json"], 90_000);
|
|
473
|
+
} catch (e) {
|
|
474
|
+
commandSucceeded = false;
|
|
475
|
+
raw = e.stdout?.toString?.() ?? "";
|
|
476
|
+
if (!raw.trim()) {
|
|
477
|
+
console.error(`CodexBar 프로바이더 수집 실패: ${e.message ?? e}`);
|
|
478
|
+
return { providers: [], complete: false };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const entries = parseCodexBarJSON(raw);
|
|
483
|
+
if (!entries) {
|
|
484
|
+
console.error("CodexBar 프로바이더 수집 실패: JSON 출력을 해석할 수 없습니다.");
|
|
485
|
+
return { providers: [], complete: false };
|
|
486
|
+
}
|
|
487
|
+
const bridgeEntries = entries.filter((entry) => !["claude", "codex"].includes(entry?.provider));
|
|
488
|
+
const providers = bridgeEntries.map(codexBarEntryToProvider).filter(Boolean);
|
|
489
|
+
const complete = commandSucceeded && !bridgeEntries.some((entry) => entry?.error);
|
|
490
|
+
for (const entry of bridgeEntries.filter((item) => item?.error)) {
|
|
491
|
+
console.error(`CodexBar ${entry.provider ?? "프로바이더"} 수집 실패: ${entry.error.message ?? "알 수 없는 오류"}`);
|
|
492
|
+
}
|
|
493
|
+
return { providers, complete };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 프로바이더 상태 페이지 (Statuspage 공용 API)
|
|
497
|
+
async function statusOf(url) {
|
|
498
|
+
try {
|
|
499
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8_000) });
|
|
500
|
+
if (!res.ok) return null;
|
|
501
|
+
const d = await res.json();
|
|
502
|
+
return { indicator: d.status?.indicator ?? "none", description: d.status?.description ?? null };
|
|
503
|
+
} catch {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function collectProviders() {
|
|
509
|
+
const claudePromise = claudeProvider();
|
|
510
|
+
const claudeStatusPromise = statusOf("https://status.anthropic.com/api/v2/status.json");
|
|
511
|
+
const codexStatusPromise = statusOf("https://status.openai.com/api/v2/status.json");
|
|
512
|
+
const codexPromise = codexProvider();
|
|
513
|
+
const codexBar = collectCodexBarProviders();
|
|
514
|
+
const [claude, codex, claudeStatus, codexStatus] = await Promise.all([
|
|
515
|
+
claudePromise,
|
|
516
|
+
codexPromise,
|
|
517
|
+
claudeStatusPromise,
|
|
518
|
+
codexStatusPromise,
|
|
519
|
+
]);
|
|
520
|
+
if (claude) claude.status = claudeStatus;
|
|
521
|
+
if (codex) codex.status = codexStatus;
|
|
522
|
+
return {
|
|
523
|
+
providers: [claude, codex, ...codexBar.providers].filter(Boolean),
|
|
524
|
+
codexBarComplete: codexBar.complete,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 업로드 설정 — 우선순위: CHARGE_* 환경변수(테스트용) → ~/.charge/config.json(페어링)
|
|
529
|
+
function resolveMode() {
|
|
530
|
+
if (process.env.CHARGE_TOKEN && process.env.CHARGE_URL && process.env.CHARGE_ANON) {
|
|
531
|
+
return { url: process.env.CHARGE_URL, anon: process.env.CHARGE_ANON, token: process.env.CHARGE_TOKEN };
|
|
532
|
+
}
|
|
533
|
+
const conf = path.join(process.env.CHARGE_HOME ?? path.join(HOME, ".charge"), "config.json");
|
|
534
|
+
try {
|
|
535
|
+
return JSON.parse(fs.readFileSync(conf, "utf8"));
|
|
536
|
+
} catch {
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// 디바이스 토큰으로 charge_upload RPC 호출 (서버가 본인 행에만 기록)
|
|
542
|
+
async function pairedUpload(mode, daily, live, providers) {
|
|
543
|
+
const res = await fetch(`${mode.url}/rest/v1/rpc/charge_upload`, {
|
|
544
|
+
method: "POST",
|
|
545
|
+
headers: {
|
|
546
|
+
apikey: mode.anon,
|
|
547
|
+
Authorization: `Bearer ${mode.anon}`,
|
|
548
|
+
"Content-Type": "application/json",
|
|
549
|
+
},
|
|
550
|
+
body: JSON.stringify({ p_token: mode.token, p_daily: daily, p_live: live, p_providers: providers }),
|
|
551
|
+
signal: AbortSignal.timeout(30_000),
|
|
552
|
+
});
|
|
553
|
+
if (!res.ok) throw new Error(`업로드 실패 (${res.status}): ${await res.text()}`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function main() {
|
|
557
|
+
// 이전 성공 페이로드 캐시 — 일부 수집이 실패해도 그 부분만 이전 값으로 유지
|
|
558
|
+
let cache = {};
|
|
559
|
+
try {
|
|
560
|
+
cache = JSON.parse(fs.readFileSync(CACHE_FILE, "utf8"));
|
|
561
|
+
} catch {}
|
|
562
|
+
|
|
563
|
+
let daily, live, providers;
|
|
564
|
+
try {
|
|
565
|
+
daily = collectDaily();
|
|
566
|
+
} catch (e) {
|
|
567
|
+
console.error(`daily 수집 실패, 이전 값 유지: ${e.message ?? e}`);
|
|
568
|
+
daily = cache.daily ?? [];
|
|
569
|
+
}
|
|
570
|
+
try {
|
|
571
|
+
live = collectLive();
|
|
572
|
+
} catch (e) {
|
|
573
|
+
console.error(`live 수집 실패, 이전 값 유지: ${e.message ?? e}`);
|
|
574
|
+
live = cache.live ?? null;
|
|
575
|
+
}
|
|
576
|
+
let providerCollection;
|
|
577
|
+
try {
|
|
578
|
+
providerCollection = await collectProviders();
|
|
579
|
+
} catch (e) {
|
|
580
|
+
console.error(`프로바이더 수집 실패, 이전 값 유지: ${e.message ?? e}`);
|
|
581
|
+
providerCollection = { providers: [], codexBarComplete: false };
|
|
582
|
+
}
|
|
583
|
+
providers = providerCollection.providers;
|
|
584
|
+
// 일부 프로바이더만 실패해도 목록에서 사라지지 않게 이전 값으로 채운다 (앱 설정 토글 유지)
|
|
585
|
+
for (const prev of cache.providers ?? []) {
|
|
586
|
+
if (providers.some((p) => p.id === prev.id)) continue;
|
|
587
|
+
// CodexBar가 정상 완료된 경우, 거기서 더 이상 반환하지 않는 항목은 사용자가 끈 것으로 본다.
|
|
588
|
+
if (providerCollection.codexBarComplete && prev.collector_source === "codexbar") continue;
|
|
589
|
+
providers.push(prev);
|
|
590
|
+
}
|
|
591
|
+
// 계정 식별 실패 시 마지막으로 알려진 계정 해시 유지 (계정 미상('')과 실제 계정 행이 갈라지는 것 방지)
|
|
592
|
+
for (const p of providers) {
|
|
593
|
+
if (!p.account) p.account = (cache.providers ?? []).find((c) => c.id === p.id)?.account ?? null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (DRY_RUN) {
|
|
597
|
+
console.log(JSON.stringify({ daily: daily.slice(-1), live, providers }, null, 2));
|
|
598
|
+
console.log(`\n[dry-run] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 예정`);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const mode = resolveMode();
|
|
603
|
+
if (!mode) {
|
|
604
|
+
console.error("페어링이 안 돼 있습니다. 앱에서 코드를 발급받아 `npx charge-connect <코드>`를 실행하세요.");
|
|
605
|
+
process.exit(1);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const now = new Date().toISOString();
|
|
609
|
+
await pairedUpload(mode, daily, live, providers);
|
|
610
|
+
|
|
611
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify({ daily, live, providers }));
|
|
612
|
+
console.log(`[${now}] daily ${daily.length}행 + live + providers ${providers.length}개 업로드 완료`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (require.main === module) {
|
|
616
|
+
main().catch((e) => {
|
|
617
|
+
console.error(e.message ?? e);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
module.exports = {
|
|
623
|
+
accountHash,
|
|
624
|
+
codexBarEntryToProvider,
|
|
625
|
+
collectCodexBarProviders,
|
|
626
|
+
dropExpired,
|
|
627
|
+
durationLabel,
|
|
628
|
+
normalizeRateWindow,
|
|
629
|
+
normalizeResetAt,
|
|
630
|
+
parseCodexBarJSON,
|
|
631
|
+
resolveMode,
|
|
632
|
+
titleCaseProvider,
|
|
633
|
+
usefulPlan,
|
|
634
|
+
};
|
package/install.ps1
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Charge 수집기를 Windows 작업 스케줄러에 등록한다 (5분 간격).
|
|
2
|
+
# 실행: PowerShell에서 .\install.ps1 (실행 정책 오류 시: powershell -ExecutionPolicy Bypass -File install.ps1)
|
|
3
|
+
$ErrorActionPreference = "Stop"
|
|
4
|
+
|
|
5
|
+
$taskName = "ChargeConnect"
|
|
6
|
+
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
7
|
+
$node = (Get-Command node).Source
|
|
8
|
+
$log = Join-Path $env:USERPROFILE "charge-connect.log"
|
|
9
|
+
|
|
10
|
+
# 예전 이름(ChargeCollector)으로 등록된 잔재가 있으면 정리
|
|
11
|
+
Unregister-ScheduledTask -TaskName "ChargeCollector" -Confirm:$false -ErrorAction SilentlyContinue
|
|
12
|
+
|
|
13
|
+
# cmd /c 로 감싸 로그 리다이렉션 (작업 스케줄러는 자체 리다이렉션이 없음)
|
|
14
|
+
$arg = "/c `"`"$node`" `"$scriptDir\collect.js`" >> `"$log`" 2>&1`""
|
|
15
|
+
$action = New-ScheduledTaskAction -Execute "cmd.exe" -Argument $arg
|
|
16
|
+
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) `
|
|
17
|
+
-RepetitionInterval (New-TimeSpan -Minutes 5) `
|
|
18
|
+
-RepetitionDuration (New-TimeSpan -Days 3650)
|
|
19
|
+
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable `
|
|
20
|
+
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
|
|
21
|
+
-ExecutionTimeLimit (New-TimeSpan -Minutes 10)
|
|
22
|
+
|
|
23
|
+
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null
|
|
24
|
+
Start-ScheduledTask -TaskName $taskName
|
|
25
|
+
|
|
26
|
+
Write-Host "등록 완료: $taskName (5분 간격). 로그: $log"
|
|
27
|
+
Write-Host "해제하려면: Unregister-ScheduledTask -TaskName $taskName -Confirm:`$false"
|
package/install.sh
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# launchd에 수집기를 등록한다 (5분 간격).
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
LABEL="com.charge.connect"
|
|
6
|
+
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
|
7
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
8
|
+
LOG="$HOME/Library/Logs/charge-connect.log"
|
|
9
|
+
|
|
10
|
+
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
|
|
11
|
+
|
|
12
|
+
# 예전 이름(charge-collector)으로 등록된 잔재가 있으면 정리
|
|
13
|
+
OLD_PLIST="$HOME/Library/LaunchAgents/com.charge.collector.plist"
|
|
14
|
+
if [ -f "$OLD_PLIST" ]; then
|
|
15
|
+
launchctl unload "$OLD_PLIST" 2>/dev/null || true
|
|
16
|
+
rm -f "$OLD_PLIST"
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
cat > "$PLIST" <<EOF
|
|
20
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
21
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
22
|
+
<plist version="1.0">
|
|
23
|
+
<dict>
|
|
24
|
+
<key>Label</key><string>$LABEL</string>
|
|
25
|
+
<key>ProgramArguments</key>
|
|
26
|
+
<array>
|
|
27
|
+
<string>/bin/zsh</string>
|
|
28
|
+
<string>-lc</string>
|
|
29
|
+
<string>exec node "$SCRIPT_DIR/collect.js"</string>
|
|
30
|
+
</array>
|
|
31
|
+
<key>StartInterval</key><integer>300</integer>
|
|
32
|
+
<key>RunAtLoad</key><true/>
|
|
33
|
+
<key>StandardOutPath</key><string>$LOG</string>
|
|
34
|
+
<key>StandardErrorPath</key><string>$LOG</string>
|
|
35
|
+
</dict>
|
|
36
|
+
</plist>
|
|
37
|
+
EOF
|
|
38
|
+
|
|
39
|
+
launchctl unload "$PLIST" 2>/dev/null || true
|
|
40
|
+
launchctl load "$PLIST"
|
|
41
|
+
echo "등록 완료: $LABEL (5분 간격). 로그: $LOG"
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "charge-connect",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Connects your computer to the Charge iPhone app and uploads AI coding usage",
|
|
5
|
+
"bin": {
|
|
6
|
+
"charge-connect": "cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "node --test collect.test.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"cli.js",
|
|
13
|
+
"collect.js",
|
|
14
|
+
"install.sh",
|
|
15
|
+
"install.ps1",
|
|
16
|
+
"cloud.json"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/baekdusan/charge.git"
|
|
25
|
+
}
|
|
26
|
+
}
|