tokenphage 2026.7.0-BETA → 2026.7.2-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 +102 -37
- package/package.json +1 -1
- package/src/auth.js +27 -18
- package/src/index.js +6 -10
- package/src/lib/config-schema.js +93 -0
- package/src/lib/update-check.js +19 -66
- package/src/scheduler.js +173 -9
- package/src/sync.js +26 -5
- package/src/ui.js +5 -4
package/README.md
CHANGED
|
@@ -1,26 +1,70 @@
|
|
|
1
1
|
# TokenPhage CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+

|
|
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
|
+
[](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&mode=dark" alt="TokenPhage gpu dark 배지"></a><br/>
|
|
36
|
+
<code>https://api.tokenphage.com/badge/kobenlys?theme=gpu&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&mode=light" alt="TokenPhage claude light 배지"></a><br/>
|
|
40
|
+
<code>https://api.tokenphage.com/badge/kobenlys?theme=claude&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
|
+
대시보드를 열면 새 버전이 있는지 확인합니다. 새 버전이 확인되면 대시보드 맨 위에 안내 배너가 뜨고, 메뉴에 **⬆ 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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
| 자동 동기화가 안 도는 것 같음 | `~/.tokenphage/logs/sync.log` 확인 |
|
|
133
|
-
| 토큰이 누락됨 | 로그인 직후 안내된 "기록 보관" 설정을 켰는지 확인 |
|
|
194
|
+
> 💡 **직접 설정하고 싶다면** `~/.claude/settings.json`에 아래 한 줄을 추가하세요.
|
|
195
|
+
>
|
|
196
|
+
> ```json
|
|
197
|
+
> { "cleanupPeriodDays": 99999 }
|
|
198
|
+
> ```
|
package/package.json
CHANGED
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
|
-
|
|
13
|
-
|
|
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
|
|
34
|
+
/** config를 config.json에 원자적으로 저장한다(임시 파일 기록 후 rename, 소유자 전용 0600). */
|
|
17
35
|
export function saveConfig(cfg) {
|
|
18
36
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
19
|
-
|
|
20
|
-
|
|
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. */
|
|
@@ -73,17 +93,6 @@ export function clearLastSyncDate() {
|
|
|
73
93
|
saveConfig(rest);
|
|
74
94
|
}
|
|
75
95
|
|
|
76
|
-
/** 업데이트 캐시(npm 최신버전·마지막 조회시각)를 반환한다. 없으면 각각 null. */
|
|
77
|
-
export function getUpdateCache() {
|
|
78
|
-
const cfg = loadConfig();
|
|
79
|
-
return { latestVersion: cfg.latestVersion ?? null, lastUpdateCheck: cfg.lastUpdateCheck ?? null };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** npm 최신버전과 조회시각(ISO)을 다른 설정을 보존한 채 갱신 저장한다. */
|
|
83
|
-
export function saveUpdateCache(latestVersion, lastUpdateCheck) {
|
|
84
|
-
saveConfig({ ...loadConfig(), latestVersion, lastUpdateCheck });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
96
|
// Gist 소유권 검증용 파일명 — 사용자가 이 이름으로 Gist를 만들어야 한다.
|
|
88
97
|
export const VERIFICATION_FILE = 'tokenphage.txt';
|
|
89
98
|
|
package/src/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { accent } from './gradient.js';
|
|
|
14
14
|
import { localDateOf } from './lib/dates.js';
|
|
15
15
|
import { claudeDirCandidates } from './parser.js';
|
|
16
16
|
import { checkClaudeRetention, applyRetentionSetting, DEFAULT_CLEANUP_DAYS } from './lib/retention.js';
|
|
17
|
-
import {
|
|
17
|
+
import { checkLatestVersion, getUpdateInfo } from './lib/update-check.js';
|
|
18
18
|
import { runUpdate } from './lib/updater.js';
|
|
19
19
|
import { confirm } from '@inquirer/prompts';
|
|
20
20
|
import chalk from 'chalk';
|
|
@@ -149,10 +149,6 @@ async function runSync() {
|
|
|
149
149
|
await syncRecords(API_BASE, token, deviceId, records);
|
|
150
150
|
saveLastSyncDate(today);
|
|
151
151
|
console.log('Done! Badge will update shortly.');
|
|
152
|
-
|
|
153
|
-
// 이미 네트워크를 쓰는 지점이므로 여기서 npm 최신버전 캐시를 갱신한다(TTL 게이트 내장, 실패 무해).
|
|
154
|
-
// 04:00 자동 sync가 이 경로로 캐시를 채워, 다음 대시보드 진입 시 업데이트 배너가 뜬다.
|
|
155
|
-
await refreshCache(Date.now());
|
|
156
152
|
}
|
|
157
153
|
|
|
158
154
|
/**
|
|
@@ -245,12 +241,12 @@ program.action(async () => {
|
|
|
245
241
|
|
|
246
242
|
// 인증 이후 메뉴는 최신 설정이 필요하므로 여기서 동적 import (상단 정적 import과 별개)
|
|
247
243
|
const { loadConfig } = await import('./auth.js');
|
|
248
|
-
//
|
|
249
|
-
|
|
244
|
+
// TUI 진입 시 npm 최신버전을 1회 조회(짧은 타임아웃). 세션 동안 재사용해 배너/메뉴에 반영한다.
|
|
245
|
+
const latest = await checkLatestVersion();
|
|
250
246
|
// 대시보드 루프: 액션 수행 후 메인으로 복귀, Exit/Esc 에서만 종료
|
|
251
247
|
while (true) {
|
|
252
248
|
const cfg = loadConfig();
|
|
253
|
-
const action = await promptDashboard(cfg);
|
|
249
|
+
const action = await promptDashboard(cfg, latest);
|
|
254
250
|
if (action === 'exit') break;
|
|
255
251
|
if (action === 'sync') await runSync();
|
|
256
252
|
else if (action === 'install-hook') await runInstallHook();
|
|
@@ -258,7 +254,7 @@ program.action(async () => {
|
|
|
258
254
|
else if (action === 'advanced') await runAdvanced(API_BASE, cfg);
|
|
259
255
|
else if (action === 'update') {
|
|
260
256
|
// 업데이트 성공 시 현재 프로세스는 여전히 구버전 코드 → 재실행 안내 후 종료.
|
|
261
|
-
const { hasUpdate
|
|
257
|
+
const { hasUpdate } = getUpdateInfo(version, latest);
|
|
262
258
|
if (hasUpdate) {
|
|
263
259
|
const result = await runUpdate(latest);
|
|
264
260
|
await pauseForEnter();
|
|
@@ -307,7 +303,7 @@ program.command('sync')
|
|
|
307
303
|
|
|
308
304
|
// install-hook: 매일 04:00 자동 sync 스케줄을 OS별(macOS launchd / Windows Task Scheduler)로 등록한다.
|
|
309
305
|
program.command('install-hook')
|
|
310
|
-
.description('Register a daily 04:00 auto-sync schedule (macOS launchd / Windows Task Scheduler)')
|
|
306
|
+
.description('Register a daily 04:00 auto-sync schedule (macOS launchd / Windows Task Scheduler / Linux cron)')
|
|
311
307
|
.action(async () => {
|
|
312
308
|
try { await runInstallHook(); }
|
|
313
309
|
catch (err) { showError('Install-hook failed', err.message); process.exitCode = 1; }
|
|
@@ -0,0 +1,93 @@
|
|
|
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('hookInstalled', (v) => typeof v === 'boolean');
|
|
88
|
+
dropIf('claudePaths', isStringArray);
|
|
89
|
+
dropIf('hookMeta', (v) => v != null && typeof v === 'object' && !Array.isArray(v));
|
|
90
|
+
|
|
91
|
+
out.configVersion = CURRENT_CONFIG_VERSION;
|
|
92
|
+
return out;
|
|
93
|
+
}
|
package/src/lib/update-check.js
CHANGED
|
@@ -1,53 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI 업데이트 검사: npm 레지스트리에서
|
|
3
|
-
* 현재 버전과
|
|
2
|
+
* CLI 업데이트 검사: TUI(대시보드) 진입 시 npm 레지스트리에서 latest를 1회 조회하고,
|
|
3
|
+
* 현재 버전과 문자열이 다르면 업데이트가 있다고 판정한다.
|
|
4
4
|
*
|
|
5
5
|
* 설계 원칙
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* - 어떤 실패(네트워크·404·파싱·옵트아웃)도 조용히 흘려보내
|
|
6
|
+
* - update 제안은 대화형 TUI에서만 뜬다. 비대화형(sync·스크립트)은 대상 아님 → 캐시/TTL 불필요.
|
|
7
|
+
* - checkLatestVersion()을 TUI 진입 시 1회 await로 호출(짧은 타임아웃), 결과를 세션 메모리에 담아 배너 판정에 쓴다.
|
|
8
|
+
* - 어떤 실패(네트워크·404·파싱·옵트아웃)도 조용히 null로 흘려보내 대시보드를 절대 막지 않는다.
|
|
9
9
|
*/
|
|
10
|
-
import { loadConfig, saveUpdateCache } from '../auth.js';
|
|
11
10
|
|
|
12
11
|
// 배포 패키지명 — 레지스트리 URL과 `npm i -g` 대상에서 공유한다.
|
|
13
12
|
export const PACKAGE_NAME = 'tokenphage';
|
|
14
13
|
// npm 레지스트리 latest 매니페스트(공개·무인증). { "version": "", ... } 반환, 미배포면 404.
|
|
15
14
|
export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
16
15
|
|
|
17
|
-
const
|
|
18
|
-
const FETCH_TIMEOUT_MS = 2500; // 레지스트리 지연이 sync를 끌지 않도록 상한
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* CalVer "YYYY.M.PATCH" 두 버전을 숫자 3파트로 비교한다.
|
|
22
|
-
* @returns {-1|0|1} a<b → -1, a==b → 0, a>b → 1. 어느 한쪽이라도 비숫자(dev 등)면 0(=차이 없음)으로 안전 폴백.
|
|
23
|
-
*/
|
|
24
|
-
export function compareVersion(a, b) {
|
|
25
|
-
const pa = String(a).split('.').map(Number);
|
|
26
|
-
const pb = String(b).split('.').map(Number);
|
|
27
|
-
// CalVer는 정확히 3파트여야 한다. 형식이 어긋나면(2파트/4파트/dev 등) 비교 불가로 보고 0(=업데이트 없음) 폴백.
|
|
28
|
-
if (pa.length !== 3 || pb.length !== 3) return 0;
|
|
29
|
-
for (let i = 0; i < 3; i++) {
|
|
30
|
-
const x = pa[i];
|
|
31
|
-
const y = pb[i];
|
|
32
|
-
if (Number.isNaN(x) || Number.isNaN(y)) return 0; // 비CalVer → 비교 불가 → 업데이트 없음
|
|
33
|
-
if (x > y) return 1;
|
|
34
|
-
if (x < y) return -1;
|
|
35
|
-
}
|
|
36
|
-
return 0;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 마지막 조회 이후 TTL(24h)이 지났는지 판정한다. now는 인자로 주입해 테스트 결정성을 확보한다.
|
|
41
|
-
* @param {string|null} lastUpdateCheck 마지막 조회 ISO 시각 (없으면 null)
|
|
42
|
-
* @param {number} now 현재 epoch ms
|
|
43
|
-
* @returns {boolean} 조회가 필요하면 true
|
|
44
|
-
*/
|
|
45
|
-
export function shouldCheck(lastUpdateCheck, now) {
|
|
46
|
-
if (!lastUpdateCheck) return true;
|
|
47
|
-
const last = Date.parse(lastUpdateCheck);
|
|
48
|
-
if (Number.isNaN(last)) return true; // 손상된 시각 → 재조회
|
|
49
|
-
return now - last >= CHECK_INTERVAL_MS;
|
|
50
|
-
}
|
|
16
|
+
const FETCH_TIMEOUT_MS = 1500; // TUI 진입 조회 상한 — 오프라인/지연 시 이 시간 후 배너 없이 진행
|
|
51
17
|
|
|
52
18
|
/** 업데이트 알림을 끄는 환경인지. 옵트아웃 env 또는 dev(TOKENPHAGE_API 세팅)면 조회·표시 모두 스킵. */
|
|
53
19
|
export function isUpdateNotifierDisabled() {
|
|
@@ -55,23 +21,27 @@ export function isUpdateNotifierDisabled() {
|
|
|
55
21
|
}
|
|
56
22
|
|
|
57
23
|
/**
|
|
58
|
-
*
|
|
24
|
+
* 조회한 latest와 현재 버전을 비교해 업데이트 정보를 반환한다. 순수 함수(네트워크·env·캐시 없음).
|
|
25
|
+
* update 액션은 항상 `@latest`를 설치하므로 "설치본 ≠ latest"가 곧 "업데이트하면 바뀜"과 일치한다.
|
|
26
|
+
* 프리릴리스 접미사(-BETA)도 파싱 없이 문자열 비교라 안전하다.
|
|
59
27
|
* @param {string} currentVersion 현재 설치 버전(package.json)
|
|
60
|
-
* @param {
|
|
28
|
+
* @param {string|null|undefined} latest checkLatestVersion() 결과(조회 실패/옵트아웃 시 null)
|
|
61
29
|
* @returns {{hasUpdate:boolean, latest?:string, current?:string}}
|
|
62
30
|
*/
|
|
63
|
-
export function getUpdateInfo(currentVersion,
|
|
64
|
-
if (
|
|
65
|
-
const latest = cfg?.latestVersion;
|
|
66
|
-
if (!latest) return { hasUpdate: false };
|
|
67
|
-
if (compareVersion(latest, currentVersion) > 0) {
|
|
31
|
+
export function getUpdateInfo(currentVersion, latest) {
|
|
32
|
+
if (latest && latest !== currentVersion) {
|
|
68
33
|
return { hasUpdate: true, latest, current: currentVersion };
|
|
69
34
|
}
|
|
70
35
|
return { hasUpdate: false };
|
|
71
36
|
}
|
|
72
37
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
38
|
+
/**
|
|
39
|
+
* npm 레지스트리에서 latest 버전 문자열을 1회 조회한다. TUI 진입 시 한 번 await로 호출해 세션 동안 재사용한다.
|
|
40
|
+
* 옵트아웃/실패(타임아웃·404·파싱)는 모두 null — 절대 throw하지 않는다.
|
|
41
|
+
* @returns {Promise<string|null>}
|
|
42
|
+
*/
|
|
43
|
+
export async function checkLatestVersion() {
|
|
44
|
+
if (isUpdateNotifierDisabled()) return null; // 옵트아웃/dev → 조회 스킵
|
|
75
45
|
const controller = new AbortController();
|
|
76
46
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
77
47
|
try {
|
|
@@ -85,20 +55,3 @@ async function fetchLatestFromNpm() {
|
|
|
85
55
|
clearTimeout(timer);
|
|
86
56
|
}
|
|
87
57
|
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* TTL이 지났으면 npm 최신버전을 조회해 config 캐시를 갱신한다. 절대 throw하지 않는다.
|
|
91
|
-
* @param {number} now 현재 epoch ms (Date.now()를 주입)
|
|
92
|
-
*/
|
|
93
|
-
export async function refreshCache(now) {
|
|
94
|
-
try {
|
|
95
|
-
if (isUpdateNotifierDisabled()) return;
|
|
96
|
-
const cfg = loadConfig();
|
|
97
|
-
if (!shouldCheck(cfg.lastUpdateCheck, now)) return;
|
|
98
|
-
const latest = await fetchLatestFromNpm();
|
|
99
|
-
if (!latest) return;
|
|
100
|
-
saveUpdateCache(latest, new Date(now).toISOString());
|
|
101
|
-
} catch {
|
|
102
|
-
// 캐시 갱신 실패는 무해 — 다음 기회에 재시도한다.
|
|
103
|
-
}
|
|
104
|
-
}
|
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
|
|
61
|
-
function
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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(
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
}
|
package/src/ui.js
CHANGED
|
@@ -127,14 +127,15 @@ export async function promptFirstRun() {
|
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
129
|
* 인증된 사용자를 위한 대시보드 메뉴.
|
|
130
|
-
* @param {{ token: string, lastSyncDate: string|null, hookInstalled: boolean, hookMeta: object|null
|
|
130
|
+
* @param {{ token: string, lastSyncDate: string|null, hookInstalled: boolean, hookMeta: object|null }} cfg
|
|
131
|
+
* @param {string|null} latest TUI 진입 시 조회한 npm 최신버전(조회 실패/옵트아웃 시 null)
|
|
131
132
|
* @returns {'update' | 'sync' | 'install-hook' | 'uninstall-hook' | 'advanced' | 'exit'}
|
|
132
133
|
*/
|
|
133
|
-
export async function promptDashboard(cfg) {
|
|
134
|
+
export async function promptDashboard(cfg, latest) {
|
|
134
135
|
printWelcome();
|
|
135
136
|
|
|
136
|
-
//
|
|
137
|
-
const update = getUpdateInfo(version,
|
|
137
|
+
// TUI 진입 시 조회한 npm 최신버전과 현재 버전을 비교해, 다를 때만 민트 배너를 출력한다.
|
|
138
|
+
const update = getUpdateInfo(version, latest);
|
|
138
139
|
if (update.hasUpdate) {
|
|
139
140
|
const bar = accent(' ┃ ');
|
|
140
141
|
console.log(bar + accent(`⬆ Update available v${update.current} → v${update.latest}`));
|