charge-connect 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/cli.js +80 -6
  2. package/collect.js +38 -13
  3. package/install.ps1 +13 -0
  4. package/package.json +1 -1
package/cli.js CHANGED
@@ -2,15 +2,17 @@
2
2
  // charge-connect CLI — 앱에서 받은 페어링 코드로 연결하고 자동 수집을 등록한다.
3
3
  //
4
4
  // 사용법:
5
- // npx charge-connect <페어링코드> # 페어링 + 첫 수집 + 스케줄 등록 (일반 사용자용 원라이너)
5
+ // npx charge-connect <페어링코드> [--label 이름] # 페어링 + 첫 수집 + 스케줄 등록 (원라이너)
6
+ // # --label(또는 CHARGE_LABEL)로 앱에 표시될 기기명 지정, 기본은 호스트명
6
7
  // charge-connect run # 수집 1회 (스케줄러가 호출)
7
- // charge-connect unpair # 페어링 해제
8
+ // charge-connect unpair # 페어링 해제 (서버 디바이스 토큰도 폐기)
8
9
  //
9
10
  // 설정 파일: ~/.charge/config.json (CHARGE_HOME으로 위치 변경 가능)
10
11
 
11
12
  const fs = require("node:fs");
12
13
  const path = require("node:path");
13
14
  const { execFileSync } = require("node:child_process");
15
+ const { CCUSAGE_PKG } = require("./collect.js"); // 버전 고정을 한 곳에서 관리
14
16
 
15
17
  const HOME = process.env.HOME ?? process.env.USERPROFILE;
16
18
  const CONF_DIR = process.env.CHARGE_HOME ?? path.join(HOME, ".charge");
@@ -28,6 +30,17 @@ const CLOUD = (() => {
28
30
  }
29
31
  })();
30
32
 
33
+ // 앱 기기 목록에 표시될 이름 — 기본은 호스트명이지만 실명이 든 경우가 흔하므로
34
+ // 최초 페어링 때 --label 또는 CHARGE_LABEL로 덮어쓸 수 있게 하고, 무엇으로 등록되는지 알려준다.
35
+ // 주의: 라벨은 서버가 같은 머신을 알아보는 유일한 키다(중복제거). 이미 페어링된 머신을
36
+ // 다른 라벨로 재페어링하면 새 디바이스로 잡혀 일별 사용량이 이중 집계되므로, 사후 변경은 권하지 않는다.
37
+ function resolveLabel() {
38
+ const i = process.argv.indexOf("--label");
39
+ const flag = i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith("-") ? process.argv[i + 1] : null;
40
+ const raw = flag ?? process.env.CHARGE_LABEL ?? require("node:os").hostname();
41
+ return String(raw).trim().slice(0, 64) || require("node:os").hostname();
42
+ }
43
+
31
44
  async function pair(code) {
32
45
  const url = process.env.CHARGE_URL ?? CLOUD.url;
33
46
  const anon = process.env.CHARGE_ANON ?? CLOUD.anon;
@@ -35,10 +48,11 @@ async function pair(code) {
35
48
  console.error("백엔드 주소가 없습니다 (collector/cloud.json 또는 CHARGE_URL/CHARGE_ANON).");
36
49
  process.exit(1);
37
50
  }
51
+ const label = resolveLabel();
38
52
  const res = await fetch(`${url}/rest/v1/rpc/charge_claim_pairing_code`, {
39
53
  method: "POST",
40
54
  headers: { apikey: anon, Authorization: `Bearer ${anon}`, "Content-Type": "application/json" },
41
- body: JSON.stringify({ p_code: code, p_label: require("node:os").hostname() }),
55
+ body: JSON.stringify({ p_code: code, p_label: label }),
42
56
  signal: AbortSignal.timeout(15_000),
43
57
  });
44
58
  const token = res.ok ? await res.json() : null;
@@ -52,7 +66,29 @@ async function pair(code) {
52
66
  try { fs.chmodSync(CONF_DIR, 0o700); } catch {}
53
67
  fs.writeFileSync(CONF, JSON.stringify({ url, anon, token }, null, 2), { mode: 0o600 });
54
68
  try { fs.chmodSync(CONF, 0o600); } catch {}
55
- console.log("✓ 페어링 완료");
69
+ console.log(`✓ 페어링 완료 — 이 컴퓨터가 '${label}'(으)로 등록되었습니다.`);
70
+ }
71
+
72
+ // 페어링 해제: 서버의 디바이스 토큰까지 폐기한다.
73
+ // 로컬 config만 지우면 그 토큰은 서버에서 계속 유효해, 유출됐을 경우 남이 업로드에 쓸 수 있다.
74
+ async function unpair() {
75
+ let conf = null;
76
+ try { conf = JSON.parse(fs.readFileSync(CONF, "utf8")); } catch {}
77
+ if (conf?.url && conf?.anon && conf?.token) {
78
+ try {
79
+ const res = await fetch(`${conf.url}/rest/v1/rpc/charge_revoke_device`, {
80
+ method: "POST",
81
+ headers: { apikey: conf.anon, Authorization: `Bearer ${conf.anon}`, "Content-Type": "application/json" },
82
+ body: JSON.stringify({ p_token: conf.token }),
83
+ signal: AbortSignal.timeout(15_000),
84
+ });
85
+ if (!res.ok) console.error(`서버 디바이스 폐기 실패 (${res.status}) — 앱 설정의 기기 목록에서 직접 제거하세요.`);
86
+ } catch {
87
+ console.error("서버 디바이스 폐기 요청에 실패했습니다 — 앱 설정의 기기 목록에서 직접 제거하세요.");
88
+ }
89
+ }
90
+ fs.rmSync(CONF, { force: true });
91
+ console.log("✓ 페어링 해제 (스케줄 해제는 install.sh/install.ps1 안내 참고)");
56
92
  }
57
93
 
58
94
  // npx 캐시는 언제든 청소될 수 있으므로, 스케줄 등록 전에 런타임을 ~/.charge/app 으로 복사한다
@@ -85,6 +121,42 @@ function installSchedule(dir) {
85
121
  }
86
122
  }
87
123
 
124
+ // ccusage가 전역에 없어도 수집은 npx 폴백으로 동작하지만, 5분마다 npx 해석을 거쳐
125
+ // 느려진다. 사용자가 터미널 앞에 있는 페어링 시점에 한 번 물어보고 깔아준다.
126
+ async function offerCcusageInstall() {
127
+ // Windows에서 ccusage/npm은 .cmd 셔틀이라 cmd.exe /d /c 로 감싼다 — shell:true의
128
+ // DEP0190 경고를 피하면서 AutoRun도 억제하는, collect.js의 runAsync와 같은 방식.
129
+ const run = (cmd, args, opts) =>
130
+ execFileSync(WIN ? process.env.ComSpec ?? "cmd.exe" : cmd, WIN ? ["/d", "/c", cmd, ...args] : args, opts);
131
+ try {
132
+ run("ccusage", ["--version"], { stdio: "ignore", timeout: 15_000 });
133
+ return; // 이미 설치돼 있다
134
+ } catch {}
135
+ const hint = `'npm i -g ${CCUSAGE_PKG}'를 해두면 수집이 빨라집니다`;
136
+ // 파이프/CI처럼 물어볼 콘솔이 없으면 안내만 하고 넘어간다
137
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
138
+ console.log(`참고: ${hint} (지금은 npx 대체 실행으로 동작).`);
139
+ return;
140
+ }
141
+ const rl = require("node:readline/promises").createInterface({ input: process.stdin, output: process.stdout });
142
+ const answer = (await rl.question("수집 도구 ccusage를 전역 설치할까요? 수집이 빨라집니다 (Y/n) ")).trim().toLowerCase();
143
+ rl.close();
144
+ if (answer === "n" || answer === "no") {
145
+ console.log(`건너뜁니다 — 나중에 ${hint}.`);
146
+ return;
147
+ }
148
+ try {
149
+ // 전역 설치도 버전을 고정한다 — @latest면 설치 시점 최신본의 라이프사이클 스크립트를 그대로 실행한다
150
+ run("npm", ["install", "-g", CCUSAGE_PKG], { stdio: "inherit", timeout: 300_000 });
151
+ console.log("✓ ccusage 설치 완료");
152
+ } catch {
153
+ console.error(
154
+ "설치에 실패했지만 수집은 npx 대체 실행으로 계속 동작합니다." +
155
+ (WIN ? ` 나중에 ${hint}.` : ` 나중에 ${hint} (권한 오류라면 'sudo npm i -g ccusage').`)
156
+ );
157
+ }
158
+ }
159
+
88
160
  (async () => {
89
161
  const arg = process.argv[2];
90
162
  if (!arg) {
@@ -96,8 +168,7 @@ function installSchedule(dir) {
96
168
  return;
97
169
  }
98
170
  if (arg === "unpair") {
99
- fs.rmSync(CONF, { force: true });
100
- console.log("✓ 페어링 해제 (스케줄 해제는 install.sh/install.ps1 안내 참고)");
171
+ await unpair();
101
172
  return;
102
173
  }
103
174
  // 페어링 코드로 간주: 페어링 → 스케줄 등록 → 첫 수집.
@@ -117,6 +188,9 @@ function installSchedule(dir) {
117
188
  else console.error(`자동 수집 등록 실패: ${e.message ?? e}`);
118
189
  }
119
190
 
191
+ // 스케줄 등록 뒤에 물어본다 — 프롬프트에서 사용자가 자리를 비워도 자동 수집은 이미 살아 있다
192
+ await offerCcusageInstall();
193
+
120
194
  console.log("첫 수집을 실행합니다…");
121
195
  let collected = true;
122
196
  try {
package/collect.js CHANGED
@@ -22,12 +22,14 @@ const HOME = process.env.HOME ?? process.env.USERPROFILE;
22
22
  // 5분마다 도는 작업이라 그냥 이어 붙이면 끝없이 자라므로 5MB에서 .old로 민다.
23
23
  const logArg = process.argv.indexOf("--log");
24
24
  const LOG_FILE = logArg > -1 ? process.argv[logArg + 1] : null;
25
- if (logArg > -1 && (!LOG_FILE || LOG_FILE.startsWith("-"))) {
25
+ // 아래 로그 리다이렉션·핸들러 등록·process.exit은 스크립트로 직접 실행될 때만 해야 한다.
26
+ // cli.js가 버전 상수를 require할 때 이 부작용이 딸려오면 CLI 콘솔이 파일로 새거나 조기 종료된다.
27
+ if (require.main === module && logArg > -1 && (!LOG_FILE || LOG_FILE.startsWith("-"))) {
26
28
  // 값을 안 주면 조용히 로그가 꺼지고, 다음 옵션을 삼키면 "--dry-run"이라는 파일에 쓰게 된다
27
29
  console.error("--log 뒤에는 파일 경로가 필요합니다.");
28
30
  process.exit(1);
29
31
  }
30
- if (LOG_FILE) {
32
+ if (require.main === module && LOG_FILE) {
31
33
  try {
32
34
  if (fs.statSync(LOG_FILE).size > 5 * 1024 * 1024) fs.renameSync(LOG_FILE, `${LOG_FILE}.old`);
33
35
  } catch {}
@@ -63,20 +65,36 @@ const EXTRA_PATH = WIN
63
65
  : ":/opt/homebrew/bin:/usr/local/bin";
64
66
  const DEFAULT_CODEXBAR_CLI = "/Applications/CodexBar.app/Contents/Helpers/CodexBarCLI";
65
67
 
68
+ // ccusage는 정확한 버전으로 고정한다 — @latest로 5분마다 무인 실행하면 ccusage npm
69
+ // 계정/배포가 탈취됐을 때 별도 조작 없이 악성 코드가 모든 사용자 PC에서 돌게 된다.
70
+ // 이 프로세스는 Claude/Codex 자격증명이 있는 홈에서 사용자 권한으로 실행되므로 위험이 크다.
71
+ // 갱신은 릴리스마다 새 버전을 검토한 뒤 의도적으로만 올린다.
72
+ const CCUSAGE_VERSION = "20.0.19";
73
+ const CCUSAGE_PKG = `ccusage@${CCUSAGE_VERSION}`;
74
+
66
75
  // 모든 외부 명령은 비동기로 실행한다 — 동기(execFileSync)는 이벤트 루프를 세워
67
76
  // 진행 중인 fetch(AbortSignal 타이머 포함)를 전부 타임아웃시킨다.
68
- // Windows에서 npx/ccusage는 .cmd 셔틀이라 셸을 거쳐야 실행된다 (인자는 모두 고정 문자열).
69
77
  const execOpts = (timeout) => ({
70
78
  encoding: "utf8",
71
79
  maxBuffer: 64 * 1024 * 1024,
72
80
  timeout,
73
81
  env: { ...process.env, PATH: `${process.env.PATH}${EXTRA_PATH}` },
74
- shell: WIN,
75
82
  });
76
83
 
77
84
  async function runAsync(cmd, args, timeout = 120_000) {
85
+ // Windows에서 npx/ccusage는 .cmd 셔틀이라 셸 없이는 실행되지 않는데, shell:true에
86
+ // 인자 배열을 주면 Node 24부터 DEP0190 경고가 붙는다. 그래서 ComSpec /d /c 로 직접
87
+ // 감싼다 — /d는 shell:true도 내부에서 쓰던 AutoRun 억제로, 사용자 레지스트리의
88
+ // AutoRun 출력(chcp·clink 등)이 JSON 파싱을 깨는 것을 막는다. 경로로 주어진 명령
89
+ // (CodexBar CLI 같은 .exe)은 셸이 필요 없고 cmd 인용 규칙이 공백·괄호 경로를
90
+ // 깨뜨리므로 직접 실행한다. cmd로 넘기는 인자는 모두 고정 문자열이다.
91
+ // 한계: .cmd/.bat 경로는 cmd 경유가 불가피해 특수문자(공백+괄호, &) 경로는 지원하지 않는다.
92
+ const viaCmd = WIN && (!/[\\/]/.test(cmd) || /\.(cmd|bat)$/i.test(cmd));
93
+ const [file, argv] = viaCmd
94
+ ? [process.env.ComSpec ?? "cmd.exe", ["/d", "/c", cmd, ...args]]
95
+ : [cmd, args];
78
96
  // promisify된 execFile은 실패 시에도 err.stdout/stderr를 붙여준다
79
- return (await execFileAsync(cmd, args, execOpts(timeout))).stdout;
97
+ return (await execFileAsync(file, argv, execOpts(timeout))).stdout;
80
98
  }
81
99
 
82
100
  async function runCcusage(args) {
@@ -84,9 +102,10 @@ async function runCcusage(args) {
84
102
  try {
85
103
  return JSON.parse(await runAsync("ccusage", args));
86
104
  } catch (e) {
87
- console.error(`ccusage 직접 실행 실패(${e.code ?? e.message}), npx로 재시도 — 'npm i -g ccusage'를 해두면 빨라집니다`);
105
+ console.error(`ccusage 직접 실행 실패(${e.code ?? e.message}), npx로 재시도 — 'npm i -g ${CCUSAGE_PKG}'를 해두면 빨라집니다`);
88
106
  // --prefer-offline: npx 캐시가 있으면 레지스트리 조회 없이 재사용 (5분마다 재다운로드 방지)
89
- return JSON.parse(await runAsync("npx", ["-y", "--prefer-offline", "ccusage@latest", ...args], 300_000));
107
+ // 버전은 CCUSAGE_PKG로 고정 — @latest 자동 추적은 공급망 탈취를 무인 실행 채널로 만든다.
108
+ return JSON.parse(await runAsync("npx", ["-y", "--prefer-offline", CCUSAGE_PKG, ...args], 300_000));
90
109
  }
91
110
  }
92
111
 
@@ -482,15 +501,18 @@ function codexBarEntryToProvider(entry) {
482
501
  });
483
502
 
484
503
  if (!primary && !secondary && extras.length === 0) return null;
504
+ // 불투명 식별자(계정 ID·조직)를 이메일보다 먼저 쓴다 — account는 salt 없는 sha256의
505
+ // 앞 12자(48비트)라, 이메일을 넣으면 DB 유출 시 사전 대입으로 원문이 역산된다.
506
+ // 고엔트로피 ID는 그 대입이 통하지 않는다. 이메일은 다른 식별자가 전혀 없을 때만 폴백.
485
507
  const rawAccount = textValue(
486
- identity.accountEmail,
487
- identity.email,
488
- usage.accountEmail,
489
- entry.accountEmail,
490
508
  identity.accountId,
491
509
  usage.accountId,
492
510
  identity.accountOrganization,
493
- usage.accountOrganization
511
+ usage.accountOrganization,
512
+ identity.accountEmail,
513
+ identity.email,
514
+ usage.accountEmail,
515
+ entry.accountEmail
494
516
  );
495
517
  const plan = usefulPlan(
496
518
  usage.plan,
@@ -534,7 +556,8 @@ function parseCodexBarJSON(raw) {
534
556
 
535
557
  async function collectCodexBarProviders() {
536
558
  if (process.env.CHARGE_DISABLE_CODEXBAR === "1") return { providers: [], complete: false };
537
- const cli = process.env.CHARGE_CODEXBAR_CLI
559
+ // 따옴표로 감싼 경로(Windows에서 흔한 습관)는 벗겨서 execFile에 그대로 넘긴다
560
+ const cli = process.env.CHARGE_CODEXBAR_CLI?.replace(/^"(.*)"$/s, "$1")
538
561
  ?? (!WIN && fs.existsSync(DEFAULT_CODEXBAR_CLI) ? DEFAULT_CODEXBAR_CLI : null);
539
562
  if (!cli) return { providers: [], complete: false };
540
563
 
@@ -699,6 +722,7 @@ if (require.main === module) {
699
722
  }
700
723
 
701
724
  module.exports = {
725
+ CCUSAGE_PKG,
702
726
  accountHash,
703
727
  codexBarEntryToProvider,
704
728
  collectCodexBarProviders,
@@ -708,6 +732,7 @@ module.exports = {
708
732
  normalizeResetAt,
709
733
  parseCodexBarJSON,
710
734
  resolveMode,
735
+ runAsync,
711
736
  sanitizeCachedProvider,
712
737
  titleCaseProvider,
713
738
  usefulPlan,
package/install.ps1 CHANGED
@@ -85,10 +85,19 @@ function Test-ChargeTaskRuns {
85
85
  }
86
86
 
87
87
  $background = $true
88
+ $adminRetryHint = $false
88
89
  try {
89
90
  Register-ChargeTask -Background
90
91
  } catch {
91
92
  Write-Host "백그라운드 실행 등록 실패: $($_.Exception.Message)"
93
+ # 비관리자 PowerShell에서는 S4U(창 없는) 등록이 항상 거부된다 — 같은 계정을 관리자
94
+ # 권한으로 승격해 재실행하면 해결되므로 그때만 안내한다. 판별은 토큰 Claims의
95
+ # Administrators SID(S-1-5-32-544)로 한다 — UAC 필터드 토큰에서 이 그룹은 deny-only로
96
+ # 강등되는데 .Groups는 deny-only를 걸러내 버리고, .Claims에는 DenyOnlySid로 남는다.
97
+ # 표준 사용자(Claims에 아예 없음)에게 안내하면 다른 관리자 계정으로 승격해 그 계정
98
+ # 프로필로 작업을 덮어쓰게 되고, 아래 Test-ChargeTaskRuns 폴백(SeBatchLogonRight 부재)은
99
+ # 승격으로 해결되지 않는다 — 두 경우 모두 안내하지 않는다.
100
+ $adminRetryHint = [Security.Principal.WindowsIdentity]::GetCurrent().Claims.Value -contains "S-1-5-32-544"
92
101
  $background = $false
93
102
  }
94
103
  if ($background -and -not (Test-ChargeTaskRuns)) {
@@ -105,6 +114,10 @@ if (-not $background) {
105
114
  exit 1
106
115
  }
107
116
  Write-Host "참고: 5분마다 콘솔 창이 잠깐 보일 수 있습니다 (백그라운드 실행 권한이 없는 환경)."
117
+ if ($adminRetryHint) {
118
+ Write-Host "창이 뜨지 않게 하려면: 관리자 권한 PowerShell에서 아래를 한 번 실행하세요."
119
+ Write-Host " powershell -ExecutionPolicy Bypass -File `"$scriptDir\install.ps1`""
120
+ }
108
121
  }
109
122
 
110
123
  Write-Host "등록 완료: $taskName (5분 간격). 로그: $log"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "charge-connect",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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"