tokenphage 2026.7.0-BETA → 2026.7.1-BETA

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/README.md CHANGED
@@ -1,26 +1,70 @@
1
1
  # TokenPhage CLI
2
2
 
3
- > AI 토큰 사용량을 GitHub README 배지로 보여주는 CLI.
3
+ ![CLI](https://img.shields.io/badge/CLI-Node.js%20ESM-5EEAD4?style=flat-square)
4
+ ![License](https://img.shields.io/badge/license-MIT-555?style=flat-square)
5
+
6
+ > 먹어치운 토큰, 이젠 README에 기록하세요!
4
7
 
5
8
  Claude Code와 Codex의 토큰 사용량을 모아 GitHub README에 붙일 수 있는 배지로 만들어 줍니다.
6
9
 
7
10
  ---
8
11
 
9
- ## 요구 사항
12
+ ## 📋 요구 사항
10
13
 
11
14
  - **Node.js 20.17 이상**
12
15
  - macOS 또는 Windows (자동 동기화 사용 시)
13
16
  - GitHub 계정
14
17
 
15
- ## 설치
18
+ ## 🖼️ 이런 배지가 만들어져요
19
+
20
+ GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한 줄만 넣으면 됩니다. `kobenlys`를 본인 GitHub 아이디로 바꾸세요.
21
+
22
+ ```markdown
23
+ [![TokenPhage](https://api.tokenphage.com/badge/kobenlys)](https://github.com/TOKENPHAGE/tokenphage-api)
24
+ ```
25
+
26
+ **결과 ↓**
27
+
28
+ <table>
29
+ <tr>
30
+ <td align="center"><strong>gpu · dark</strong></td>
31
+ <td align="center"><strong>claude · light</strong></td>
32
+ </tr>
33
+ <tr>
34
+ <td align="center">
35
+ <a href="https://github.com/TOKENPHAGE/tokenphage-api"><img src="https://api.tokenphage.com/badge/kobenlys?theme=gpu&amp;mode=dark" alt="TokenPhage gpu dark 배지"></a><br/>
36
+ <code>https://api.tokenphage.com/badge/kobenlys?theme=gpu&amp;mode=dark</code>
37
+ </td>
38
+ <td align="center">
39
+ <a href="https://github.com/TOKENPHAGE/tokenphage-api"><img src="https://api.tokenphage.com/badge/kobenlys?theme=claude&amp;mode=light" alt="TokenPhage claude light 배지"></a><br/>
40
+ <code>https://api.tokenphage.com/badge/kobenlys?theme=claude&amp;mode=light</code>
41
+ </td>
42
+ </tr>
43
+ </table>
44
+
45
+ > 🎨 URL 뒤에 옵션을 붙일 수 있어요 — 테마 `?theme=gpu`(기본) 또는 `?theme=claude`, 색상 `?mode=light`(기본) 또는 `?mode=dark`.
46
+
47
+ ## 📦 설치
16
48
 
17
49
  ```bash
18
- npm install -g tokenphage
50
+ npm install -g tokenphage@latest
19
51
  ```
20
52
 
21
53
  설치 후 `tokenphage` 명령을 쓸 수 있습니다.
22
54
 
23
- ## 빠른 시작
55
+ ### ⬆️ 업데이트
56
+
57
+ CLI를 실행하면(대시보드를 열거나 `sync` 할 때) 새 버전이 있는지 확인합니다. 매번 조회하지는 않고, 과도한 요청을 막기 위해 **최대 하루 한 번**만 npm에 물어봅니다(24시간 캐시). 새 버전이 확인되면 대시보드 맨 위에 안내 배너가 뜨고, 메뉴에 **⬆ Update to …** 항목이 추가됩니다. 이 항목을 고르면 최신 버전이 자동으로 설치됩니다(전역 설치가 아니면 정확한 수동 명령을 안내).
58
+
59
+ 직접 올리고 싶다면 언제든 아래 명령을 실행하면 됩니다.
60
+
61
+ ```bash
62
+ npm install -g tokenphage@latest
63
+ ```
64
+
65
+ > 업데이트 알림을 끄려면 `TOKENPHAGE_NO_UPDATE_NOTIFIER=1` 환경 변수를 설정하세요.
66
+
67
+ ## 🚀 빠른 시작
24
68
 
25
69
  ```bash
26
70
  tokenphage # 대화형 화면 실행 (처음이면 로그인부터 안내)
@@ -31,23 +75,7 @@ tokenphage install-hook # 매일 04:00 자동 동기화 켜기
31
75
 
32
76
  ---
33
77
 
34
- ## 명령어
35
-
36
- | 명령 | 하는 일 |
37
- |------|---------|
38
- | `tokenphage` | 대화형 화면(메뉴)을 엽니다. 아래 [대화형 사용법](#대화형-사용법) 참고 |
39
- | `tokenphage login [github-id]` | GitHub 계정을 인증하고 로그인합니다. 아이디를 함께 적으면 입력 단계를 건너뜁니다 |
40
- | `tokenphage sync` | 지금 바로 토큰 사용량을 동기화합니다. 잠시 뒤 배지가 갱신됩니다 |
41
- | `tokenphage install-hook` | 매일 **오전 4시**에 자동으로 동기화되도록 설정합니다 |
42
- | `tokenphage uninstall-hook` | 자동 동기화를 끕니다 |
43
- | `tokenphage --version` | 버전을 봅니다 |
44
- | `tokenphage --help` | 도움말을 봅니다 |
45
-
46
- > `login`은 화면 입력이 필요해 일반 터미널에서 실행해야 합니다.
47
-
48
- ---
49
-
50
- ## 대화형 사용법
78
+ ## 💻 대화형 사용법
51
79
 
52
80
  `tokenphage`를 그냥 실행하면 메뉴가 뜹니다. **↑/↓로 이동, Enter로 선택, Esc로 종료**합니다.
53
81
 
@@ -69,6 +97,7 @@ tokenphage install-hook # 매일 04:00 자동 동기화 켜기
69
97
  Advanced settings
70
98
  Exit
71
99
  ```
100
+ - **⬆ Update to …** — 새 버전이 있을 때만 맨 위에 나타나며, 고르면 최신 버전으로 자동 업데이트
72
101
  - **Sync now** — 지금 바로 동기화
73
102
  - **Enable / Disable auto-sync** — 자동 동기화 켜기/끄기 (현재 상태에 맞춰 표시)
74
103
  - **Advanced settings** — 데이터 초기화 등
@@ -83,7 +112,7 @@ tokenphage install-hook # 매일 04:00 자동 동기화 켜기
83
112
 
84
113
  ---
85
114
 
86
- ## 로그인 (Gist 인증)
115
+ ## 🔐 로그인 (Gist 인증)
87
116
 
88
117
  비밀번호 없이, **본인 GitHub 계정으로 공개 Gist를 만들 수 있다는 점**으로 계정을 인증합니다.
89
118
 
@@ -96,7 +125,23 @@ tokenphage install-hook # 매일 04:00 자동 동기화 켜기
96
125
 
97
126
  ---
98
127
 
99
- ## 자동 동기화
128
+ ## ⌨️ 명령어
129
+
130
+ | 명령 | 하는 일 |
131
+ |------|---------|
132
+ | `tokenphage` | 대화형 화면(메뉴)을 엽니다. [대화형 사용법](#-대화형-사용법) 참고 |
133
+ | `tokenphage login [github-id]` | GitHub 계정을 인증하고 로그인합니다. 아이디를 함께 적으면 입력 단계를 건너뜁니다 |
134
+ | `tokenphage sync` | 지금 바로 토큰 사용량을 동기화합니다. 잠시 뒤 배지가 갱신됩니다 |
135
+ | `tokenphage install-hook` | 매일 자동으로 동기화되도록 설정합니다 (시간·상세는 자동 동기화 절 참고) |
136
+ | `tokenphage uninstall-hook` | 자동 동기화를 끕니다 |
137
+ | `tokenphage --version` | 버전을 봅니다 |
138
+ | `tokenphage --help` | 도움말을 봅니다 |
139
+
140
+ > `login`은 화면 입력이 필요해 일반 터미널에서 실행해야 합니다.
141
+
142
+ ---
143
+
144
+ ## ⏰ 자동 동기화
100
145
 
101
146
  `install-hook`을 켜면 매일 **오전 4시**에 자동으로 동기화됩니다(macOS·Windows). 컴퓨터를 거의 안 쓰는 시간대라 작업을 방해하지 않습니다.
102
147
 
@@ -110,24 +155,44 @@ tokenphage install-hook # 매일 04:00 자동 동기화 켜기
110
155
 
111
156
  ---
112
157
 
113
- ## 저장되는 곳
158
+ ## ⚙️ config.json 설정
114
159
 
115
- 로그인 정보와 설정은 `~/.tokenphage/` 폴더에 저장됩니다. 로그아웃하거나 초기화하려면 이 폴더를 지우면 됩니다.
160
+ 로그인 정보와 설정은 `~/.tokenphage/config.json`에 저장됩니다. 여기에는 로그인 토큰(JWT), **기기 식별자(deviceId)**, 마지막 동기화 날짜, 자동 동기화 설정이 들어 있습니다.
116
161
 
117
- ---
162
+ > ⚠️ **이 폴더나 `config.json`을 임의로 지우지 마세요.** `deviceId`가 사라지면 아래처럼 토큰 집계에 노이즈가 생길 수 있습니다.
163
+
164
+ ### 🗑️ 만약 지워졌다면 (노이즈가 생기는 이유)
165
+
166
+ `config.json`이 사라지면(폴더 삭제·재설치·다른 기기 등) 이런 일이 벌어집니다.
118
167
 
119
- ## 토큰이 누락되지 않게 하기
168
+ 1. 로그인 토큰이 없어져 **다시 Gist 인증**을 해야 합니다.
169
+ 2. 재인증하면 **새 `deviceId`가 발급**됩니다 (기존 것은 복구 불가).
170
+ 3. 마지막 동기화 기록도 사라져, 다음 `sync`가 **전체 히스토리를 처음부터 다시 업로드**합니다.
171
+ 4. 서버는 같은 사용량이라도 `deviceId`가 다르면 **별도 행으로 저장**하고, 배지의 누적 토큰은 **모든 `deviceId`를 합산**합니다. → 같은 기록이 옛 `deviceId` + 새 `deviceId`로 **이중 집계되어 누적 토큰이 실제보다 부풀려집니다.** (다른 PC에서 각각 동기화한 것과 같은 노이즈)
120
172
 
121
- Claude Code는 오래된 세션 기록을 자동으로 지울 수 있고, 지워진 기록의 토큰은 다시 집계되지 않습니다. 그래서 로그인 직후 CLI가 **"기록을 계속 보관할지"** 물어봅니다. **Yes**로 두면 이후 모든 토큰이 빠짐없이 집계됩니다.
173
+ ### ✅ 노이즈를 없애는 법 — 전체 초기화(Reset)
174
+
175
+ 1. `tokenphage` 실행 → 필요하면 먼저 재로그인(Gist 인증) → **Advanced settings → Reset all data**
176
+ 2. 확인을 위해 본인 GitHub 아이디를 입력하면, 서버에 쌓인 **내 토큰 사용량 전체(노이즈 포함)를 삭제**한 뒤 곧바로 처음부터 다시 동기화합니다.
177
+ 3. 그 결과 **현재 기기(단일 `deviceId`) 기준의 깨끗한 누적치**로 복구됩니다.
178
+
179
+ > ℹ️ Reset은 **서버 데이터만** 지우며 로컬 Claude 기록은 건드리지 않습니다. 남용 방지를 위해 **24시간에 한 번**만 가능합니다.
122
180
 
123
181
  ---
124
182
 
125
- ## 문제 해결
183
+ ## ⚠️ 유의사항
184
+
185
+ ### 🧹 토큰이 누락되지 않게 하기
186
+
187
+ **Claude Code는 오래된 세션 기록(트랜스크립트)을 자동으로 삭제합니다.** 삭제된 기록의 토큰은 다시 집계할 수 없어, 그대로 두면 누적량에서 빠져 버립니다.
188
+
189
+ - 보존 기간은 Claude Code의 **`cleanupPeriodDays`** 설정으로 정해집니다. **따로 지정하지 않으면 기본 30일**이라, 30일이 지난 세션은 사라집니다.
190
+ - 그래서 **로그인 직후**(보존 기간이 넉넉하지 않을 때) CLI가 한 번 **"기록을 계속 보관할지"**를 물어봅니다.
191
+ - **Yes**를 고르면 `~/.claude/settings.json`의 `cleanupPeriodDays`를 **`99999`(사실상 영구)**로 바꿔, 이후 모든 토큰이 빠짐없이 집계됩니다. (이미 충분히 큰 값이면 그대로 둡니다)
192
+ - 설정 파일이 손상돼 있으면 **건드리지 않고**, 수동으로 넣는 방법을 안내합니다.
126
193
 
127
- | 증상 | 해결 |
128
- |------|------|
129
- | `Not authenticated` 라고 나옴 | 먼저 `tokenphage login <github-id>` 로 로그인 |
130
- | `login` 이 바로 종료됨 | 일반 터미널(입력 가능한 창)에서 실행 |
131
- | 자동 동기화 설정 중 권한 거부 | macOS: 자동화 권한 허용 / Windows: 관리자 터미널에서 재실행 |
132
- | 자동 동기화가 안 도는 것 같음 | `~/.tokenphage/logs/sync.log` 확인 |
133
- | 토큰이 누락됨 | 로그인 직후 안내된 "기록 보관" 설정을 켰는지 확인 |
194
+ > 💡 **직접 설정하고 싶다면** `~/.claude/settings.json`에 아래 한 줄을 추가하세요.
195
+ >
196
+ > ```json
197
+ > { "cleanupPeriodDays": 99999 }
198
+ > ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenphage",
3
- "version": "2026.7.0-BETA",
3
+ "version": "2026.7.1-BETA",
4
4
  "description": "AI 토큰 사용량 GitHub README 뱃지",
5
5
  "author": "kobenlys <kobenlys@gmail.com>",
6
6
  "license": "MIT",
package/src/auth.js CHANGED
@@ -1,23 +1,43 @@
1
- import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
1
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync } from 'fs';
2
2
  import { homedir } from 'os';
3
3
  import { join } from 'path';
4
4
  import { randomUUID } from 'crypto';
5
+ import { normalizeConfig } from './lib/config-schema.js';
6
+ import { localDateOf } from './lib/dates.js';
5
7
 
6
8
  // 로컬 설정 파일 경로: ~/.tokenphage/config.json (JWT·deviceId·sync 상태 보관)
7
9
  const CONFIG_DIR = join(homedir(), '.tokenphage');
8
10
  const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
9
11
 
10
- /** config.json을 읽어 객체로 반환한다. 파일이 없거나 손상됐으면 빈 객체. */
12
+ /** 손상된 config.json을 .corrupt-{시각}으로 rename해 보존한다(원본 유지 + 재손상 방지). */
13
+ function quarantineCorruptConfig() {
14
+ try {
15
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
16
+ renameSync(CONFIG_PATH, `${CONFIG_PATH}.corrupt-${stamp}`);
17
+ } catch { /* 격리 실패는 무시 */ }
18
+ }
19
+
20
+ /** config.json을 읽어 정규화된 객체로 반환한다. 파일 없음/손상 시 빈 객체(손상은 격리 후). throw하지 않는다. */
11
21
  export function loadConfig() {
12
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
13
- catch { return {}; }
22
+ let raw;
23
+ try { raw = readFileSync(CONFIG_PATH, 'utf8'); }
24
+ catch (err) {
25
+ if (err.code === 'ENOENT') return {}; // 파일 없음
26
+ return {}; // 기타 읽기 실패
27
+ }
28
+ let parsed;
29
+ try { parsed = JSON.parse(raw); }
30
+ catch { quarantineCorruptConfig(); return {}; } // 파싱 실패 → 격리
31
+ return normalizeConfig(parsed, localDateOf(new Date()));
14
32
  }
15
33
 
16
- /** config 객체를 config.json에 저장한다. JWT가 담기므로 소유자 전용(0600) 권한으로 기록한다. */
34
+ /** config를 config.json에 원자적으로 저장한다(임시 파일 기록 후 rename, 소유자 전용 0600). */
17
35
  export function saveConfig(cfg) {
18
36
  mkdirSync(CONFIG_DIR, { recursive: true });
19
- writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
20
- try { chmodSync(CONFIG_PATH, 0o600); } catch { /* Windows는 chmod 미지원 — 무시 */ }
37
+ const tmp = `${CONFIG_PATH}.tmp-${process.pid}`; // 같은 디렉터리라 rename 원자적
38
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2), { mode: 0o600 });
39
+ try { chmodSync(tmp, 0o600); } catch { /* Windows 미지원 */ }
40
+ renameSync(tmp, CONFIG_PATH);
21
41
  }
22
42
 
23
43
  /** 저장된 JWT를 반환한다. 없으면 null. */
package/src/index.js CHANGED
@@ -307,7 +307,7 @@ program.command('sync')
307
307
 
308
308
  // install-hook: 매일 04:00 자동 sync 스케줄을 OS별(macOS launchd / Windows Task Scheduler)로 등록한다.
309
309
  program.command('install-hook')
310
- .description('Register a daily 04:00 auto-sync schedule (macOS launchd / Windows Task Scheduler)')
310
+ .description('Register a daily 04:00 auto-sync schedule (macOS launchd / Windows Task Scheduler / Linux cron)')
311
311
  .action(async () => {
312
312
  try { await runInstallHook(); }
313
313
  catch (err) { showError('Install-hook failed', err.message); process.exitCode = 1; }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * config.json 스키마 검증·정규화·마이그레이션. 순수 함수(fs·네트워크 없음).
3
+ * 이상 필드는 제거하고 유효·미지 필드는 보존한다. 형태/타입만 검증하며 JWT 서명은 검증하지 않는다.
4
+ */
5
+
6
+ // config.json 스키마 버전. breaking 변경 시 +1 하고 migrateConfig에 단계를 추가한다.
7
+ export const CURRENT_CONFIG_VERSION = 1;
8
+
9
+ const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
10
+ const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
11
+ const JWT_SEGMENT_RE = /^[A-Za-z0-9_-]+$/;
12
+
13
+ /** JWT 형태(base64url 3세그먼트)인지 검사한다. 서명은 검증하지 않는다. */
14
+ export function isJwtShape(v) {
15
+ if (typeof v !== 'string') return false;
16
+ const parts = v.split('.');
17
+ return parts.length === 3 && parts.every((p) => p.length > 0 && JWT_SEGMENT_RE.test(p));
18
+ }
19
+
20
+ /** UUID 형식(서버 SyncRequest 패턴과 동일)인지 검사한다. */
21
+ export function isUuid(v) {
22
+ return typeof v === 'string' && UUID_RE.test(v);
23
+ }
24
+
25
+ /**
26
+ * YYYY-MM-DD이고 실제 존재하는 날짜이며 today 이전인지 검사한다.
27
+ * @param {string} today 미래 금지 기준 날짜(YYYY-MM-DD)
28
+ */
29
+ export function isYmdDate(v, today) {
30
+ if (typeof v !== 'string' || !YMD_RE.test(v)) return false;
31
+ const [y, m, d] = v.split('-').map(Number);
32
+ // 롤오버(예: 2/30 → 3/2) 차단: 컴포넌트 재구성 후 일치하는 날짜만 통과.
33
+ const dt = new Date(Date.UTC(y, m - 1, d));
34
+ const real = dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
35
+ if (!real) return false;
36
+ // 미래 금지(동일 포맷이라 문자열 비교로 충분). today 미제공 시 생략.
37
+ if (typeof today === 'string' && YMD_RE.test(today) && v > today) return false;
38
+ return true;
39
+ }
40
+
41
+ /** Date.parse로 해석 가능한 ISO 시각 문자열인지 검사한다. */
42
+ export function isIsoDate(v) {
43
+ return typeof v === 'string' && !Number.isNaN(Date.parse(v));
44
+ }
45
+
46
+ /** 문자열만 담긴 배열인지 검사한다. */
47
+ export function isStringArray(v) {
48
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
49
+ }
50
+
51
+ /**
52
+ * config를 현재 스키마 버전으로 마이그레이트한다. 순수·멱등.
53
+ * @param {object} raw 파싱된 config
54
+ * @returns {object} 마이그레이트된 새 객체
55
+ */
56
+ export function migrateConfig(raw) {
57
+ const version = Number.isInteger(raw.configVersion) ? raw.configVersion : 0;
58
+ if (version > CURRENT_CONFIG_VERSION) return { ...raw }; // 미래 버전은 보존
59
+ let out = { ...raw };
60
+ if (version < 1) out = { ...out, configVersion: 1 }; // 0 → 1: 버전 스탬프만
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * 파싱된 config를 검증·정규화한다. 이상 필드는 제거하고 유효·미지 필드는 보존한다.
66
+ * @param {*} raw JSON.parse 결과
67
+ * @param {string} today lastSyncDate 미래 검사 기준(YYYY-MM-DD)
68
+ * @returns {object} 정규화된 config
69
+ */
70
+ export function normalizeConfig(raw, today) {
71
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
72
+ return { configVersion: CURRENT_CONFIG_VERSION };
73
+ }
74
+ const migrated = migrateConfig(raw);
75
+ // 미래 버전은 검증 없이 그대로 반환한다.
76
+ if (Number.isInteger(migrated.configVersion) && migrated.configVersion > CURRENT_CONFIG_VERSION) {
77
+ return migrated;
78
+ }
79
+
80
+ const out = { ...migrated }; // 미지 필드 보존
81
+ const dropIf = (key, ok) => { if (key in out && !ok(out[key])) delete out[key]; };
82
+
83
+ dropIf('token', isJwtShape);
84
+ dropIf('deviceId', isUuid); // 없으면 getOrCreateDeviceId가 재발급
85
+ dropIf('lastSyncDate', (v) => isYmdDate(v, today)); // 없으면 전체 재파싱
86
+ dropIf('hookInstalledAt', isIsoDate);
87
+ dropIf('lastUpdateCheck', isIsoDate);
88
+ dropIf('hookInstalled', (v) => typeof v === 'boolean');
89
+ dropIf('latestVersion', (v) => typeof v === 'string');
90
+ dropIf('claudePaths', isStringArray);
91
+ dropIf('hookMeta', (v) => v != null && typeof v === 'object' && !Array.isArray(v));
92
+
93
+ out.configVersion = CURRENT_CONFIG_VERSION;
94
+ return out;
95
+ }
package/src/scheduler.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'child_process';
2
- import { mkdirSync, writeFileSync, unlinkSync, realpathSync, existsSync } from 'fs';
2
+ import { mkdirSync, writeFileSync, unlinkSync, realpathSync, existsSync, readFileSync } from 'fs';
3
3
  import { homedir, tmpdir, userInfo } from 'os';
4
4
  import { join } from 'path';
5
5
 
@@ -57,8 +57,8 @@ function plistPath() {
57
57
  return join(homedir(), 'Library', 'LaunchAgents', `${TASK_LABEL}.plist`);
58
58
  }
59
59
 
60
- // macOS용 node 탐색 래퍼 셸 스크립트 경로 (~/.tokenphage/run-sync.sh).
61
- function syncScriptPathMac() {
60
+ // macOS·Linux 공용 node 탐색 래퍼 셸 스크립트 경로 (~/.tokenphage/run-sync.sh).
61
+ function syncScriptPathSh() {
62
62
  return join(homedir(), '.tokenphage', 'run-sync.sh');
63
63
  }
64
64
 
@@ -175,7 +175,7 @@ function installMac() {
175
175
  const { script } = resolveTokenphageBinary();
176
176
  const logPath = ensureLogDir();
177
177
  const path = plistPath();
178
- const wrapper = syncScriptPathMac();
178
+ const wrapper = syncScriptPathSh();
179
179
  mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
180
180
  mkdirSync(join(homedir(), '.tokenphage'), { recursive: true });
181
181
 
@@ -199,7 +199,7 @@ function installMac() {
199
199
  // macOS 제거: launchd 등록 해제 후 plist·래퍼 파일을 삭제한다(best-effort). removed 여부를 반환.
200
200
  function uninstallMac() {
201
201
  const path = plistPath();
202
- const wrapper = syncScriptPathMac();
202
+ const wrapper = syncScriptPathSh();
203
203
  const fileExisted = existsSync(path);
204
204
  // best-effort 해제 — exit code는 신뢰할 수 없다(거짓 양성).
205
205
  launchctlBootout();
@@ -426,18 +426,182 @@ function uninstallWindows() {
426
426
  return { platform: 'win32', removed };
427
427
  }
428
428
 
429
+ // ─── Linux (cron) ─────────────────────────────────────────────────────────────
430
+
431
+ // crontab 관리 마커: BEGIN..END로 우리 블록만 감싸 멱등 설치/제거 시 식별한다.
432
+ export const CRON_BEGIN = `# BEGIN ${TASK_LABEL} (managed by tokenphage-cli — do not edit)`;
433
+ export const CRON_END = `# END ${TASK_LABEL}`;
434
+
435
+ // 매일 04:00 sync를 도는 crontab 블록을 만든다. cron은 PATH가 빈약하므로 명령을
436
+ // 직접 부르지 않고 node 탐색 sh 래퍼를 호출하며, 출력은 로그로 리다이렉트한다.
437
+ // 인용은 두 계층을 모두 처리한다: shSingleQuote로 셸 인용(공백·특수문자·한글 안전)한
438
+ // 뒤, cron 파서가 명령 필드에서 개행으로 해석하는 '%'를 '\%'로 이스케이프한다.
439
+ // 경로에 개행이 있으면 단일 crontab 엔트리로 표현할 수 없으므로 설치를 중단한다.
440
+ export function buildCronBlock(wrapperPath, logPath) {
441
+ for (const p of [wrapperPath, logPath]) {
442
+ if (/\n/.test(p)) throw new Error(`경로에 개행이 있어 cron 엔트리를 만들 수 없습니다: ${JSON.stringify(p)}`);
443
+ }
444
+ const schedule = `${SCHEDULE_MINUTE} ${SCHEDULE_HOUR} * * *`; // "0 4 * * *"
445
+ const cronArg = (s) => shSingleQuote(s).replace(/%/g, '\\%'); // 셸 인용 후 cron '%' 이스케이프(순서 중요)
446
+ const line = `${schedule} /bin/sh ${cronArg(wrapperPath)} >> ${cronArg(logPath)} 2>&1`;
447
+ return `${CRON_BEGIN}\n${line}\n${CRON_END}`;
448
+ }
449
+
450
+ // 기존 crontab 문자열에서 우리 BEGIN..END 블록만 제거한다(남의 항목은 보존).
451
+ // 정규식 대신 라인 단위 매칭으로 사용자 라인을 오검출하지 않는다. 완결된 BEGIN..END
452
+ // 페어만 제거하고, END를 못 만난 고아 BEGIN(수동 편집·손상)은 버퍼째 되살려 그 뒤
453
+ // 사용자 라인이 통째로 삭제되는 데이터 손실을 막는다(파괴적 해석 대신 보존).
454
+ export function removeCronBlock(existing) {
455
+ const lines = String(existing).split('\n');
456
+ const out = [];
457
+ let buffer = null; // 미종료 BEGIN 이후 라인을 임시 보관
458
+ for (const line of lines) {
459
+ if (line === CRON_BEGIN) {
460
+ if (buffer !== null) out.push(...buffer); // 직전 고아 BEGIN 블록은 보존
461
+ buffer = [line];
462
+ continue;
463
+ }
464
+ if (buffer !== null) {
465
+ if (line === CRON_END) { buffer = null; continue; } // 완결 페어 → 제거 확정
466
+ buffer.push(line);
467
+ continue;
468
+ }
469
+ out.push(line);
470
+ }
471
+ if (buffer !== null) out.push(...buffer); // 고아 BEGIN: 아무것도 삭제하지 않고 원문 보존
472
+ return out.join('\n');
473
+ }
474
+
475
+ // 기존 블록 제거 후 새 블록을 append한다. 항상 trailing newline으로 끝낸다
476
+ // (vixie-cron은 마지막 줄 개행이 없으면 거부/경고할 수 있음).
477
+ export function upsertCronContent(existing, block) {
478
+ const base = removeCronBlock(existing).replace(/\n+$/, ''); // 꼬리 공백 라인 정리
479
+ const body = base.length ? `${base}\n${block}` : block;
480
+ return `${body}\n`;
481
+ }
482
+
483
+ // crontab 바이너리 존재 여부 (최소/distroless 컨테이너엔 없을 수 있음).
484
+ function hasCrontab() {
485
+ try {
486
+ execFileSync('sh', ['-c', 'command -v crontab'], { stdio: 'ignore' });
487
+ return true;
488
+ } catch {
489
+ return false;
490
+ }
491
+ }
492
+
493
+ // 현재 사용자 crontab 전체를 반환한다. '확정된 빈 crontab'만 ''로 취급하고,
494
+ // 그 외 읽기 실패는 fail-closed로 throw한다 — 이 값이 crontab 전체 재기록의 base가
495
+ // 되므로, 애매한 실패를 ''로 흡수하면 사용자의 기존 항목을 통째로 덮어쓸 수 있다.
496
+ function readCrontab() {
497
+ try {
498
+ // maxBuffer 상향: 대형(정상) crontab이 기본 1MB를 넘겨 ENOBUFS로 오인 실패하는 것을 막는다.
499
+ return execFileSync('crontab', ['-l'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
500
+ } catch (err) {
501
+ // 빈 crontab: exit≠0 + stderr 'no crontab for <user>' → 이때만 빈 문자열(잃을 항목 없음).
502
+ const stderr = (err.stderr?.toString() || '').toLowerCase();
503
+ if (stderr.includes('no crontab')) return '';
504
+ if (err.code === 'ENOENT') throw new Error('crontab command not found');
505
+ // 그 외(ENOBUFS·권한·일시 I/O 등)는 파괴적 재기록을 막기 위해 중단한다.
506
+ throw new Error(`기존 crontab을 안전하게 읽지 못했습니다 (code=${err.code ?? 'unknown'}). 사용자 항목 소실을 막기 위해 중단합니다.`);
507
+ }
508
+ }
509
+
510
+ // cron 데몬 가동 여부(best-effort). Debian=cron, RHEL=crond, Alpine=busybox crond.
511
+ // 탐지 실패가 곧 '안 돎'을 뜻하진 않으므로 경고 판단에만 쓰고 hard-fail하지 않는다.
512
+ function detectCronDaemon() {
513
+ try {
514
+ execFileSync('sh', ['-c', 'pgrep -x cron || pgrep -x crond'], { stdio: 'ignore' });
515
+ return true;
516
+ } catch {
517
+ return false;
518
+ }
519
+ }
520
+
521
+ // WSL 여부: WSL_DISTRO_NAME env 또는 /proc/version에 microsoft 포함.
522
+ function isWSL() {
523
+ if (process.env.WSL_DISTRO_NAME) return true;
524
+ try {
525
+ return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf8'));
526
+ } catch {
527
+ return false;
528
+ }
529
+ }
530
+
531
+ // Linux 설치: sh 래퍼 작성(멱등) → crontab에 마커 블록 upsert → 데몬/WSL 경고.
532
+ function installLinux() {
533
+ if (!hasCrontab()) {
534
+ throw new Error(
535
+ 'crontab 명령을 찾을 수 없습니다. cron을 설치한 뒤 다시 시도하세요.\n' +
536
+ ' Debian/Ubuntu: sudo apt install cron\n' +
537
+ ' RHEL 계열: sudo dnf install cronie');
538
+ }
539
+ const { script } = resolveTokenphageBinary();
540
+ const logPath = ensureLogDir();
541
+ const wrapper = syncScriptPathSh();
542
+ mkdirSync(join(homedir(), '.tokenphage'), { recursive: true });
543
+
544
+ // 래퍼를 먼저 깔고, crontab 라인이 이 래퍼를 가리키게 한다.
545
+ writeFileSync(wrapper, buildSyncShellScript(script), { mode: 0o755 });
546
+
547
+ // 읽기 → 우리 블록 제거 → 새 블록 append → stdin으로 전체 교체(멱등).
548
+ const next = upsertCronContent(readCrontab(), buildCronBlock(wrapper, logPath));
549
+ execFileSync('crontab', ['-'], { input: next, encoding: 'utf8' });
550
+
551
+ // best-effort 경고 — 등록은 성공했으나 실제 발화 조건을 사용자에게 알린다.
552
+ if (!detectCronDaemon()) {
553
+ const startCmd = isWSL()
554
+ ? 'sudo service cron start'
555
+ : 'sudo systemctl enable --now crond 2>/dev/null || sudo systemctl enable --now cron (WSL: sudo service cron start)';
556
+ console.log('[!] cron 데몬이 실행 중이 아닙니다. 아래로 켜야 04:00 스케줄이 실제로 동작합니다:');
557
+ console.log(` ${startCmd}`);
558
+ }
559
+ if (isWSL()) {
560
+ console.log('[!] WSL은 인스턴스가 떠 있는 동안에만 cron이 동작합니다(04:00에 PC·WSL이 켜져 있어야 함).');
561
+ }
562
+
563
+ return {
564
+ platform: 'linux',
565
+ method: 'cron',
566
+ logPath,
567
+ scheduleSpec: `daily ${String(SCHEDULE_HOUR).padStart(2, '0')}:${String(SCHEDULE_MINUTE).padStart(2, '0')}`,
568
+ artifactPath: `crontab (${TASK_LABEL})`,
569
+ };
570
+ }
571
+
572
+ // Linux 제거: crontab에서 마커 블록 제거 후 래퍼 파일 정리(best-effort). removed 반환.
573
+ function uninstallLinux() {
574
+ let removed = false;
575
+ if (hasCrontab()) {
576
+ const current = readCrontab();
577
+ if (current.includes(CRON_BEGIN)) {
578
+ const cleaned = removeCronBlock(current).replace(/\n+$/, '');
579
+ // 남은 내용이 있으면 개행 보장해 기록, 전부 비면 빈 crontab으로 교체.
580
+ execFileSync('crontab', ['-'], { input: cleaned.length ? `${cleaned}\n` : '', encoding: 'utf8' });
581
+ removed = true;
582
+ }
583
+ }
584
+ const wrapper = syncScriptPathSh();
585
+ if (existsSync(wrapper)) {
586
+ try { unlinkSync(wrapper); removed = true; } catch { /* noop */ }
587
+ }
588
+ return { platform: 'linux', removed };
589
+ }
590
+
429
591
  // ─── Public API ──────────────────────────────────────────────────────────────
430
592
 
431
- /** 현재 OS에 맞는 자동 sync 스케줄을 설치한다 (macOS=launchd / Windows=schtasks). 그 외 OS는 throw. */
593
+ /** 현재 OS에 맞는 자동 sync 스케줄을 설치한다 (macOS=launchd / Windows=schtasks / Linux=cron). 그 외 OS는 throw. */
432
594
  export async function installHook() {
433
595
  if (process.platform === 'darwin') return installMac();
434
596
  if (process.platform === 'win32') return installWindows();
435
- throw new Error(`Unsupported OS: ${process.platform}. Only macOS and Windows are supported.`);
597
+ if (process.platform === 'linux') return installLinux();
598
+ throw new Error(`Unsupported OS: ${process.platform}. Only macOS, Windows, and Linux are supported.`);
436
599
  }
437
600
 
438
- /** 현재 OS의 자동 sync 스케줄을 제거한다 (macOS=launchd / Windows=schtasks). 그 외 OS는 throw. */
601
+ /** 현재 OS의 자동 sync 스케줄을 제거한다 (macOS=launchd / Windows=schtasks / Linux=cron). 그 외 OS는 throw. */
439
602
  export async function uninstallHook() {
440
603
  if (process.platform === 'darwin') return uninstallMac();
441
604
  if (process.platform === 'win32') return uninstallWindows();
442
- throw new Error(`Unsupported OS: ${process.platform}. Only macOS and Windows are supported.`);
605
+ if (process.platform === 'linux') return uninstallLinux();
606
+ throw new Error(`Unsupported OS: ${process.platform}. Only macOS, Windows, and Linux are supported.`);
443
607
  }
package/src/sync.js CHANGED
@@ -1,3 +1,20 @@
1
+ /**
2
+ * 서버 HTTP 상태코드를 사용자 대면 메시지로 매핑한다(응답 원문은 노출하지 않음).
3
+ * @param {number} status HTTP 상태코드
4
+ * @param {string} [bodyText] 400 세부 분기 판단용 응답 본문
5
+ * @returns {string} 사용자 메시지
6
+ */
7
+ export function mapServerError(status, bodyText) {
8
+ if (status === 401) return 'Authentication expired or invalid. Run `tokenphage login` to sign in again.';
9
+ if (status === 400) {
10
+ const lower = (bodyText || '').toLowerCase();
11
+ if (lower.includes('deviceid')) return 'Your config file looks corrupted. Run `tokenphage login` to re-authenticate.';
12
+ return 'Request format error. Update the CLI: npm i -g tokenphage@latest';
13
+ }
14
+ if (status >= 500) return 'Server temporarily unavailable. Your existing badge data is safe.';
15
+ return `Server error: ${status}`; // 매핑되지 않은 상태 폴백
16
+ }
17
+
1
18
  /**
2
19
  * 집계된 토큰 사용 기록을 서버에 업로드한다 (POST /api/sync).
3
20
  * 사용자는 서버가 JWT(Bearer)에서 식별하고, 본문에 deviceId와 records를 담아 보낸다.
@@ -5,7 +22,7 @@
5
22
  * @param {string} token Bearer JWT
6
23
  * @param {string} deviceId 기기 식별 UUID
7
24
  * @param {Array<{date:string, model:string, inputTok:number, outputTok:number, cacheReadTok:number, cacheCreateTok:number}>} records 날짜·모델별 집계 레코드
8
- * @throws {Error} 응답이 2xx가 아니면 status와 응답 본문을 담아 throw
25
+ * @throws {Error} 2xx가 아니면 mapServerError 메시지로 throw
9
26
  */
10
27
  export async function syncRecords(apiBase, token, deviceId, records) {
11
28
  // Node 18+ 전역 fetch 사용 (node-fetch 의존성 불필요).
@@ -14,7 +31,7 @@ export async function syncRecords(apiBase, token, deviceId, records) {
14
31
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
15
32
  body: JSON.stringify({ deviceId, records }),
16
33
  });
17
- if (!res.ok) throw new Error(`Server error: ${res.status} ${await res.text()}`);
34
+ if (!res.ok) throw new Error(mapServerError(res.status, await res.text()));
18
35
  }
19
36
 
20
37
  /**
@@ -29,8 +46,12 @@ export async function resetData(apiBase, token) {
29
46
  });
30
47
  if (!res.ok) {
31
48
  const text = await res.text();
32
- let msg = text;
33
- try { msg = JSON.parse(text).message ?? text; } catch { }
34
- throw new Error(res.status === 429 ? msg : `Server error: ${res.status} ${msg}`);
49
+ if (res.status === 429) {
50
+ // 429: 서버 쿨다운 메시지를 그대로 노출
51
+ let msg = text;
52
+ try { msg = JSON.parse(text).message ?? text; } catch { /* 비JSON은 원문 */ }
53
+ throw new Error(msg);
54
+ }
55
+ throw new Error(mapServerError(res.status, text));
35
56
  }
36
57
  }