tokenphage 2026.7.0-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/LICENSE +21 -0
- package/README.md +133 -0
- package/package.json +41 -0
- package/src/animated-select.js +109 -0
- package/src/auth.js +203 -0
- package/src/gradient.js +58 -0
- package/src/index.js +324 -0
- package/src/lib/dates.js +32 -0
- package/src/lib/paths.js +43 -0
- package/src/lib/retention.js +67 -0
- package/src/lib/update-check.js +104 -0
- package/src/lib/updater.js +86 -0
- package/src/parser.js +63 -0
- package/src/parsers/claude.js +93 -0
- package/src/parsers/codex.js +213 -0
- package/src/scheduler.js +443 -0
- package/src/sync.js +36 -0
- package/src/ui.js +251 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tokenphage CLI 진입점 (package.json "bin"이 가리키는 파일).
|
|
4
|
+
* 구조: ① 상단 import(의존성) → ② 중단 run* 함수 정의(명령별 동작) → ③ 하단에서 commander로 명령을 등록하고
|
|
5
|
+
* 맨 끝 program.parse()가 argv를 해석해 실제 시동을 건다.
|
|
6
|
+
*/
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import { parseAll } from './parser.js';
|
|
9
|
+
import { loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled, getLastSyncDate, saveLastSyncDate, clearLastSyncDate } from './auth.js';
|
|
10
|
+
import { syncRecords, resetData } from './sync.js';
|
|
11
|
+
import { installHook, uninstallHook } from './scheduler.js';
|
|
12
|
+
import { promptFirstRun, promptDashboard, promptAdvanced, confirmReset, pauseForEnter, showError } from './ui.js';
|
|
13
|
+
import { accent } from './gradient.js';
|
|
14
|
+
import { localDateOf } from './lib/dates.js';
|
|
15
|
+
import { claudeDirCandidates } from './parser.js';
|
|
16
|
+
import { checkClaudeRetention, applyRetentionSetting, DEFAULT_CLEANUP_DAYS } from './lib/retention.js';
|
|
17
|
+
import { refreshCache, getUpdateInfo } from './lib/update-check.js';
|
|
18
|
+
import { runUpdate } from './lib/updater.js';
|
|
19
|
+
import { confirm } from '@inquirer/prompts';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
|
|
23
|
+
// API 서버 주소: 환경변수 우선, 없으면 운영 기본값 (dev는 TOKENPHAGE_API로 주입)
|
|
24
|
+
const API_BASE = process.env.TOKENPHAGE_API ?? 'https://api.tokenphage.com';
|
|
25
|
+
|
|
26
|
+
// package.json의 version 조회
|
|
27
|
+
// createRequire는 import.meta.url 기준 상대경로를 해석하고, JSON import와 달리 Node 20에서도 경고 없이 동작한다.
|
|
28
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
29
|
+
|
|
30
|
+
/** 저장된 JWT를 반환한다. 없으면 로그인 안내를 출력하고 프로세스를 종료(exit 1)한다. */
|
|
31
|
+
function requireToken() {
|
|
32
|
+
const token = loadToken();
|
|
33
|
+
if (!token) {
|
|
34
|
+
showError('Not authenticated', 'No saved credentials found.',
|
|
35
|
+
'Run `tokenphage login <github-username>` first.');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** install-hook 명령 본체: 자동 sync 스케줄을 등록하고 설치 상태를 저장한 뒤 초기 sync까지 수행한다. */
|
|
42
|
+
async function runInstallHook() {
|
|
43
|
+
requireToken();
|
|
44
|
+
const result = await installHook();
|
|
45
|
+
setHookInstalled(true, result);
|
|
46
|
+
console.log(`[OK] ${result.method} registered. Runs ${result.scheduleSpec}.`);
|
|
47
|
+
console.log(` Log: ${result.logPath}`);
|
|
48
|
+
console.log(` Artifact: ${result.artifactPath}`);
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log('Running initial sync...');
|
|
51
|
+
await runSync();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** uninstall-hook 명령 본체: 자동 sync 스케줄을 제거하고 설치 상태를 해제한다(스케줄이 없어도 정상 처리). */
|
|
55
|
+
async function runUninstallHook() {
|
|
56
|
+
const result = await uninstallHook();
|
|
57
|
+
setHookInstalled(false);
|
|
58
|
+
console.log(result.removed ? '[OK] Removed' : '[OK] No schedule found');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* gist 인증 직후 1회: Claude Code의 자동 삭제 위험을 실제 설정값과 함께 안내하고,
|
|
63
|
+
* 동의(Y) 시 무조건 99999로 설정한다. 이미 99999 이상이면 조용히 통과한다.
|
|
64
|
+
*/
|
|
65
|
+
async function promptRetentionSetup() {
|
|
66
|
+
const check = checkClaudeRetention(claudeDirCandidates());
|
|
67
|
+
if (!check.needed) return;
|
|
68
|
+
|
|
69
|
+
const printManualGuide = () => {
|
|
70
|
+
console.log(` → To set it manually, add ${accent('"cleanupPeriodDays": 99999')} to ${accent(check.settingsPath)}`);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(` ${chalk.bold.red('⚠ Token history can be auto-deleted')}`);
|
|
75
|
+
console.log('');
|
|
76
|
+
|
|
77
|
+
// 설정 파일이 손상돼 현재값을 신뢰할 수 없으면 자동 설정 없이 수동 안내로 폴백.
|
|
78
|
+
if (check.corrupted) {
|
|
79
|
+
console.log(` ${check.settingsPath} could not be parsed — skipping automatic setup.`);
|
|
80
|
+
printManualGuide();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 라벨을 색 없이 padEnd로 먼저 정렬한 뒤 값/강조에만 accent를 입힌다 (ANSI 코드가 폭 계산을 깨지 않도록).
|
|
85
|
+
const labelWidth = 'Current setting'.length;
|
|
86
|
+
const contIndent = ' '.repeat(3 + labelWidth + 3); // 라벨 다음 줄(연속 줄)을 값 위치에 맞추는 들여쓰기
|
|
87
|
+
const row = (label, value, highlightLabel = false) => {
|
|
88
|
+
const shown = highlightLabel ? accent(label) : label;
|
|
89
|
+
const pad = ' '.repeat(Math.max(0, labelWidth - label.length));
|
|
90
|
+
return ` ${shown}${pad} : ${value}`;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// 실제 cleanupPeriodDays 값 표시. 미설정이면 Claude Code 기본값(30) + (default) 표기.
|
|
94
|
+
const currentDesc = check.currentValue === null
|
|
95
|
+
? `delete after ${accent(`${DEFAULT_CLEANUP_DAYS} days`)} ${chalk.dim('(default)')}`
|
|
96
|
+
: `delete after ${accent(`${check.currentValue} days`)}`;
|
|
97
|
+
|
|
98
|
+
console.log(row('Current setting', currentDesc));
|
|
99
|
+
console.log(row('Risk', 'deleted sessions are lost forever and'));
|
|
100
|
+
console.log(`${contIndent}go ${accent('MISSING')} from your token stats`);
|
|
101
|
+
console.log(row('Fix (Yes)', 'keep history forever — count every token', true));
|
|
102
|
+
console.log('');
|
|
103
|
+
|
|
104
|
+
let agreed;
|
|
105
|
+
try {
|
|
106
|
+
agreed = await confirm({
|
|
107
|
+
message: `${accent('Keep history forever?')} (updates ${accent(check.settingsPath)})`,
|
|
108
|
+
default: true,
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err.name === 'ExitPromptError') process.exit(0); // Ctrl+C
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!agreed) {
|
|
116
|
+
printManualGuide();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (applyRetentionSetting(check.settingsPath)) {
|
|
121
|
+
console.log(` ${chalk.green('✓')} Set ${accent('cleanupPeriodDays: 99999')} — history is now kept forever.`);
|
|
122
|
+
} else {
|
|
123
|
+
console.log(` ${chalk.yellow('!')} Could not update the file automatically.`);
|
|
124
|
+
printManualGuide();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** sync 명령 본체: 로컬 로그를 파싱(최초=전체 / 이후=증분)해 서버로 업로드하고, 마지막 sync 날짜를 갱신한다. */
|
|
129
|
+
async function runSync() {
|
|
130
|
+
const token = requireToken();
|
|
131
|
+
const deviceId = getOrCreateDeviceId();
|
|
132
|
+
const today = localDateOf(new Date());
|
|
133
|
+
|
|
134
|
+
// 최초(lastSyncDate 없음)면 fromDate=null → 전체 히스토리 파싱.
|
|
135
|
+
// 이후엔 직전 sync일부터 오늘까지 증분만 파싱.
|
|
136
|
+
const lastSyncDate = getLastSyncDate();
|
|
137
|
+
const fromDate = lastSyncDate ?? null;
|
|
138
|
+
|
|
139
|
+
console.log(`Parsing local JSONL... (${fromDate ?? 'beginning'} ~ ${today})`);
|
|
140
|
+
const records = await parseAll(fromDate);
|
|
141
|
+
console.log(`${records.length} records aggregated (date×model)`);
|
|
142
|
+
if (records.length === 0) {
|
|
143
|
+
console.log('Nothing to sync.');
|
|
144
|
+
saveLastSyncDate(today);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log('Uploading to server...');
|
|
149
|
+
await syncRecords(API_BASE, token, deviceId, records);
|
|
150
|
+
saveLastSyncDate(today);
|
|
151
|
+
console.log('Done! Badge will update shortly.');
|
|
152
|
+
|
|
153
|
+
// 이미 네트워크를 쓰는 지점이므로 여기서 npm 최신버전 캐시를 갱신한다(TTL 게이트 내장, 실패 무해).
|
|
154
|
+
// 04:00 자동 sync가 이 경로로 캐시를 채워, 다음 대시보드 진입 시 업데이트 배너가 뜬다.
|
|
155
|
+
await refreshCache(Date.now());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 고급 메뉴 본체: 현재는 'reset'(전체 초기화)만 처리한다.
|
|
160
|
+
* 확인 → 서버 데이터 삭제 → 로컬 워터마크 제거 → 전체 재-sync 순으로 진행하며, 각 단계 실패를 명확히 안내한다.
|
|
161
|
+
* @param {string} apiBase API 서버 base URL
|
|
162
|
+
* @param {object} cfg 현재 설정(JWT 포함)
|
|
163
|
+
*/
|
|
164
|
+
async function runAdvanced(apiBase, cfg) {
|
|
165
|
+
const sub = await promptAdvanced();
|
|
166
|
+
if (sub !== 'reset') return; // 'back' → 메시지 없이 즉시 대시보드 복귀
|
|
167
|
+
const confirmed = await confirmReset(cfg); // Ctrl+C는 ExitPromptError로 바깥 핸들러가 종료 처리
|
|
168
|
+
if (!confirmed) {
|
|
169
|
+
console.log(accent(' Reset cancelled.'));
|
|
170
|
+
await pauseForEnter();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const token = cfg.token;
|
|
174
|
+
if (!token) {
|
|
175
|
+
showError('Not authenticated', 'No saved credentials found.',
|
|
176
|
+
'Authenticate again from the main menu.');
|
|
177
|
+
await pauseForEnter();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// 1단계: 서버 데이터 삭제. 실패(쿨다운 429 등) 시 삭제된 데이터가 없으므로 그대로 종료.
|
|
181
|
+
try {
|
|
182
|
+
console.log(' Resetting server data...');
|
|
183
|
+
await resetData(apiBase, token);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
showError('Reset failed', err.message, 'No data was deleted. Please try again later.');
|
|
186
|
+
await pauseForEnter();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
// 2단계: 로컬 워터마크 제거 후 전체 재-sync. 재-sync가 실패해도 서버 삭제는 이미 완료됐음을 명확히 안내.
|
|
190
|
+
clearLastSyncDate();
|
|
191
|
+
console.log(accent(' [OK] Server data reset.') + ' Re-syncing from scratch...');
|
|
192
|
+
try {
|
|
193
|
+
await runSync();
|
|
194
|
+
console.log(accent(' [OK] Reset complete — your data was rebuilt from scratch.'));
|
|
195
|
+
} catch (err) {
|
|
196
|
+
showError('Re-sync failed', err.message,
|
|
197
|
+
'Your server data was reset. Run `tokenphage sync` to rebuild your badge.');
|
|
198
|
+
}
|
|
199
|
+
await pauseForEnter();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ========================================== START - CLI =========================================
|
|
203
|
+
const program = new Command();
|
|
204
|
+
program.name('tokenphage').description('AI token usage as a GitHub README badge — Tokenphage').version(version);
|
|
205
|
+
|
|
206
|
+
// 기본 동작: 인자 없이 `tokenphage` 실행 시.
|
|
207
|
+
// 미인증이면 인증 루프 → 보존 설정 → 초기 sync를 먼저 거치고, 그 뒤 대시보드 루프로 진입한다.
|
|
208
|
+
program.action(async () => {
|
|
209
|
+
if (!process.stdin.isTTY) {
|
|
210
|
+
program.help();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
if (!loadToken()) {
|
|
215
|
+
// 인증 루프: 실패해도 CLI를 죽이지 않고 알림 후 메뉴로 복귀(재시도/종료 선택 가능)
|
|
216
|
+
while (true) {
|
|
217
|
+
const action = await promptFirstRun();
|
|
218
|
+
if (action !== 'auth') return; // Exit 선택 → 종료
|
|
219
|
+
try {
|
|
220
|
+
const jwt = await gistLogin(API_BASE);
|
|
221
|
+
saveToken(jwt);
|
|
222
|
+
console.log(' → Saved to ~/.tokenphage/config.json');
|
|
223
|
+
break; // 인증 성공 → 루프 탈출
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err.name === 'ExitPromptError') process.exit(0); // Ctrl+C
|
|
226
|
+
showError('Authentication failed', err.message,
|
|
227
|
+
'Make sure your Gist is public and owned by your GitHub account, then try again.');
|
|
228
|
+
await pauseForEnter(); // 사용자가 읽고 → 다시 메뉴로 복귀
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// 인증 성공 → 보존 설정 안내 (첫 sync 전에 잡아야 이후 기록이 전부 보호됨)
|
|
232
|
+
await promptRetentionSetup();
|
|
233
|
+
// 초기 전체 sync (실패해도 죽지 않고 대시보드로 진행)
|
|
234
|
+
try {
|
|
235
|
+
console.log('');
|
|
236
|
+
console.log('Running initial full sync...');
|
|
237
|
+
await runSync();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err.name === 'ExitPromptError') process.exit(0);
|
|
240
|
+
showError('Initial sync failed', err.message,
|
|
241
|
+
'You are authenticated — you can run sync later from the menu.');
|
|
242
|
+
await pauseForEnter();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 인증 이후 메뉴는 최신 설정이 필요하므로 여기서 동적 import (상단 정적 import과 별개)
|
|
247
|
+
const { loadConfig } = await import('./auth.js');
|
|
248
|
+
// 업데이트 캐시를 백그라운드로 갱신한다(await 안 함 → startup 지연 0). 결과는 다음 렌더에 반영된다.
|
|
249
|
+
refreshCache(Date.now()).catch(() => {});
|
|
250
|
+
// 대시보드 루프: 액션 수행 후 메인으로 복귀, Exit/Esc 에서만 종료
|
|
251
|
+
while (true) {
|
|
252
|
+
const cfg = loadConfig();
|
|
253
|
+
const action = await promptDashboard(cfg);
|
|
254
|
+
if (action === 'exit') break;
|
|
255
|
+
if (action === 'sync') await runSync();
|
|
256
|
+
else if (action === 'install-hook') await runInstallHook();
|
|
257
|
+
else if (action === 'uninstall-hook') await runUninstallHook();
|
|
258
|
+
else if (action === 'advanced') await runAdvanced(API_BASE, cfg);
|
|
259
|
+
else if (action === 'update') {
|
|
260
|
+
// 업데이트 성공 시 현재 프로세스는 여전히 구버전 코드 → 재실행 안내 후 종료.
|
|
261
|
+
const { hasUpdate, latest } = getUpdateInfo(version, cfg);
|
|
262
|
+
if (hasUpdate) {
|
|
263
|
+
const result = await runUpdate(latest);
|
|
264
|
+
await pauseForEnter();
|
|
265
|
+
if (result.updated) process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (err.name === 'ExitPromptError') process.exit(0);
|
|
271
|
+
showError('Unexpected error', err.message);
|
|
272
|
+
process.exitCode = 1;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// ── 명시적 서브커맨드: `tokenphage <command>` 형태로 직접 호출하며, 위에서 정의한 run* 함수를 실행한다 ──
|
|
277
|
+
|
|
278
|
+
// login [username]: Gist로 GitHub 소유권 증명 → JWT 저장 → 보존 설정 → 초기 sync. (대화형 터미널 필요)
|
|
279
|
+
program.command('login [username]')
|
|
280
|
+
.description('Prove GitHub account ownership via a public Gist and save the JWT')
|
|
281
|
+
.action(async (username) => {
|
|
282
|
+
if (!process.stdin.isTTY) {
|
|
283
|
+
showError('Interactive terminal required', 'The login command needs an interactive terminal.');
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const token = await gistLogin(API_BASE, username);
|
|
288
|
+
saveToken(token);
|
|
289
|
+
console.log(' → Saved to ~/.tokenphage/config.json');
|
|
290
|
+
await promptRetentionSetup();
|
|
291
|
+
console.log('');
|
|
292
|
+
console.log('Running initial full sync...');
|
|
293
|
+
await runSync();
|
|
294
|
+
} catch (err) {
|
|
295
|
+
showError('Login failed', err.message);
|
|
296
|
+
process.exitCode = 1;
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// sync: 로컬 Claude/Codex 로그를 파싱해 서버로 업로드한다(배지 갱신). 미인증이면 안내 후 종료.
|
|
301
|
+
program.command('sync')
|
|
302
|
+
.description('Sync local Claude Code token usage to the server')
|
|
303
|
+
.action(async () => {
|
|
304
|
+
try { await runSync(); }
|
|
305
|
+
catch (err) { showError('Sync failed', err.message); process.exitCode = 1; }
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// install-hook: 매일 04:00 자동 sync 스케줄을 OS별(macOS launchd / Windows Task Scheduler)로 등록한다.
|
|
309
|
+
program.command('install-hook')
|
|
310
|
+
.description('Register a daily 04:00 auto-sync schedule (macOS launchd / Windows Task Scheduler)')
|
|
311
|
+
.action(async () => {
|
|
312
|
+
try { await runInstallHook(); }
|
|
313
|
+
catch (err) { showError('Install-hook failed', err.message); process.exitCode = 1; }
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// uninstall-hook: 자동 sync 스케줄을 제거한다(스케줄이 없어도 안전한 멱등 동작).
|
|
317
|
+
program.command('uninstall-hook')
|
|
318
|
+
.description('Remove the auto-sync schedule (idempotent)')
|
|
319
|
+
.action(async () => {
|
|
320
|
+
try { await runUninstallHook(); }
|
|
321
|
+
catch (err) { showError('Uninstall-hook failed', err.message); process.exitCode = 1; }
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
program.parse(); // argv를 해석해 알맞은 .action()으로 분기 — CLI 실제 시동 지점
|
package/src/lib/dates.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// 날짜 버킷팅 공용 헬퍼.
|
|
2
|
+
// 트랜스크립트 타임스탬프(UTC)를 "사용자 체감 날짜"인 로컬 타임존 기준
|
|
3
|
+
// lastSyncDate(로컬 기준)와 레코드 날짜의 기준을 일치시킨다.
|
|
4
|
+
|
|
5
|
+
const formatterCache = new Map();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 타임스탬프를 로컬(또는 지정 타임존) 날짜 문자열로 변환한다.
|
|
9
|
+
* @param {string|Date} ts ISO8601 문자열 또는 Date
|
|
10
|
+
* @param {string} [timeZone] IANA 타임존 (예: 'Asia/Seoul') — 미지정 시 시스템 로컬
|
|
11
|
+
* @returns {string|null} YYYY-MM-DD, 파싱 불가면 null
|
|
12
|
+
*/
|
|
13
|
+
export function localDateOf(ts, timeZone = undefined) {
|
|
14
|
+
if (!ts) return null;
|
|
15
|
+
const d = ts instanceof Date ? ts : new Date(ts);
|
|
16
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
17
|
+
|
|
18
|
+
if (timeZone) {
|
|
19
|
+
let fmt = formatterCache.get(timeZone);
|
|
20
|
+
if (!fmt) {
|
|
21
|
+
// en-CA 로케일은 YYYY-MM-DD 형식을 보장한다.
|
|
22
|
+
fmt = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
23
|
+
formatterCache.set(timeZone, fmt);
|
|
24
|
+
}
|
|
25
|
+
return fmt.format(d);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const y = d.getFullYear();
|
|
29
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
30
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
31
|
+
return `${y}-${m}-${day}`;
|
|
32
|
+
}
|
package/src/lib/paths.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { join, resolve } from 'path';
|
|
2
|
+
|
|
3
|
+
// Claude 설정 디렉터리 후보를 우선순위 순으로 만든다.
|
|
4
|
+
// 순서: 기본(~/.claude) → XDG($XDG_CONFIG_HOME/claude, 기본 ~/.config/claude) → 사용자 지정.
|
|
5
|
+
|
|
6
|
+
function expandTilde(p, home) {
|
|
7
|
+
if (p === '~') return home;
|
|
8
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return join(home, p.slice(2));
|
|
9
|
+
return p;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Claude 설정 디렉터리 후보 목록을 우선순위 순으로 반환한다.
|
|
14
|
+
* @param {{ home: string, xdgConfigHome?: string, customPaths?: string[] }} opts
|
|
15
|
+
* home: 사용자 홈, xdgConfigHome: $XDG_CONFIG_HOME, customPaths: config.json의 claudePaths
|
|
16
|
+
* @returns {string[]} normalize·중복 제거된 절대 경로 목록
|
|
17
|
+
*/
|
|
18
|
+
export function resolveClaudeDirs({ home, xdgConfigHome, customPaths } = {}) {
|
|
19
|
+
const xdgBase = typeof xdgConfigHome === 'string' && xdgConfigHome.trim()
|
|
20
|
+
? xdgConfigHome
|
|
21
|
+
: join(home, '.config');
|
|
22
|
+
|
|
23
|
+
// 외부 입력(config.json) 검증: 문자열 배열만 인정, 그 외는 통째로 무시
|
|
24
|
+
const custom = Array.isArray(customPaths)
|
|
25
|
+
? customPaths.filter((p) => typeof p === 'string' && p.trim())
|
|
26
|
+
: [];
|
|
27
|
+
|
|
28
|
+
const candidates = [
|
|
29
|
+
join(home, '.claude'),
|
|
30
|
+
join(xdgBase, 'claude'),
|
|
31
|
+
...custom.map((p) => expandTilde(p.trim(), home)),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const dirs = [];
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
const normalized = resolve(candidate);
|
|
38
|
+
if (seen.has(normalized)) continue;
|
|
39
|
+
seen.add(normalized);
|
|
40
|
+
dirs.push(normalized);
|
|
41
|
+
}
|
|
42
|
+
return dirs;
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
// Claude Code의 cleanupPeriodDays(트랜스크립트 보존 기간) 검사·설정.
|
|
5
|
+
// 규칙은 하나: 99999 이상이면 통과, 아니면 동의를 받아 무조건 99999로 설정한다.
|
|
6
|
+
// (0=off는 Claude Code가 거부하므로 큰 값으로 무력화하는 방식)
|
|
7
|
+
// 손상된 설정 파일은 절대 건드리지 않는다.
|
|
8
|
+
|
|
9
|
+
export const FOREVER_DAYS = 99999;
|
|
10
|
+
|
|
11
|
+
// Claude Code가 cleanupPeriodDays 미설정 시 적용하는 기본 보존 일수.
|
|
12
|
+
// 미설정 사용자에게 "실제로 무슨 일이 일어나는지"를 보여주기 위한 표시용 상수.
|
|
13
|
+
export const DEFAULT_CLEANUP_DAYS = 30;
|
|
14
|
+
|
|
15
|
+
// 후보 중 실제 사용 중인 설정 디렉터리: projects/가 존재하는 첫 dir, 없으면 첫 후보.
|
|
16
|
+
function pickActiveClaudeDir(claudeDirs) {
|
|
17
|
+
return claudeDirs.find((dir) => existsSync(join(dir, 'projects'))) ?? claudeDirs[0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 사용 중인 Claude 설정 디렉터리의 cleanupPeriodDays가 99999 이상인지 검사한다.
|
|
22
|
+
* @param {string[]} claudeDirs resolveClaudeDirs() 결과 (우선순위 순)
|
|
23
|
+
* @returns {{ needed: boolean, settingsPath: string, corrupted: boolean, currentValue: number | null }}
|
|
24
|
+
* needed: 설정이 필요한지(99999 미만/미설정/손상), corrupted: settings.json 파싱 실패 여부,
|
|
25
|
+
* currentValue: 현재 설정된 cleanupPeriodDays 숫자값 (미설정·파일없음·손상이면 null — 호출부가 기본값으로 표시)
|
|
26
|
+
*/
|
|
27
|
+
export function checkClaudeRetention(claudeDirs) {
|
|
28
|
+
const settingsPath = join(pickActiveClaudeDir(claudeDirs), 'settings.json');
|
|
29
|
+
|
|
30
|
+
if (!existsSync(settingsPath)) {
|
|
31
|
+
return { needed: true, settingsPath, corrupted: false, currentValue: null };
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const value = JSON.parse(readFileSync(settingsPath, 'utf8')).cleanupPeriodDays;
|
|
35
|
+
const currentValue = typeof value === 'number' ? value : null;
|
|
36
|
+
const isForever = currentValue !== null && currentValue >= FOREVER_DAYS;
|
|
37
|
+
return { needed: !isForever, settingsPath, corrupted: false, currentValue };
|
|
38
|
+
} catch {
|
|
39
|
+
return { needed: true, settingsPath, corrupted: true, currentValue: null };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* settings.json의 cleanupPeriodDays를 무조건 99999로 설정한다 (다른 필드 보존, 원자 쓰기).
|
|
45
|
+
* @param {string} settingsPath checkClaudeRetention()이 돌려준 settings.json 경로
|
|
46
|
+
* @returns {boolean} 성공 여부 — 손상 JSON이면 수정하지 않고 false
|
|
47
|
+
*/
|
|
48
|
+
export function applyRetentionSetting(settingsPath) {
|
|
49
|
+
let config = {};
|
|
50
|
+
if (existsSync(settingsPath)) {
|
|
51
|
+
try {
|
|
52
|
+
config = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
53
|
+
} catch {
|
|
54
|
+
return false; // 손상 파일은 건드리지 않는다
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const next = { ...config, cleanupPeriodDays: FOREVER_DAYS };
|
|
60
|
+
const tmpPath = settingsPath + '.tmp';
|
|
61
|
+
writeFileSync(tmpPath, JSON.stringify(next, null, 2) + '\n');
|
|
62
|
+
renameSync(tmpPath, settingsPath); // 같은 디렉터리 내 rename = 원자적 교체
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false; // 권한 문제 등 — 호출부가 수동 안내로 폴백
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI 업데이트 검사: npm 레지스트리에서 최신버전을 조회해 config.json에 캐시하고,
|
|
3
|
+
* 현재 버전과 비교해 업데이트 여부를 판정한다.
|
|
4
|
+
*
|
|
5
|
+
* 설계 원칙
|
|
6
|
+
* - 화면(메인 대시보드)은 캐시(getUpdateInfo)만 읽어 즉시 렌더 → startup 지연 0.
|
|
7
|
+
* - 네트워크 조회(refreshCache)는 백그라운드/sync 시점에 돌아 캐시를 채우고, 결과는 "다음 렌더"에 반영된다.
|
|
8
|
+
* - 어떤 실패(네트워크·404·파싱·옵트아웃)도 조용히 흘려보내 본 기능(대시보드)을 절대 막지 않는다.
|
|
9
|
+
*/
|
|
10
|
+
import { loadConfig, saveUpdateCache } from '../auth.js';
|
|
11
|
+
|
|
12
|
+
// 배포 패키지명 — 레지스트리 URL과 `npm i -g` 대상에서 공유한다.
|
|
13
|
+
export const PACKAGE_NAME = 'tokenphage';
|
|
14
|
+
// npm 레지스트리 latest 매니페스트(공개·무인증). { "version": "", ... } 반환, 미배포면 404.
|
|
15
|
+
export const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
16
|
+
|
|
17
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h TTL — 하루 1회만 조회
|
|
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
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 업데이트 알림을 끄는 환경인지. 옵트아웃 env 또는 dev(TOKENPHAGE_API 세팅)면 조회·표시 모두 스킵. */
|
|
53
|
+
export function isUpdateNotifierDisabled() {
|
|
54
|
+
return !!process.env.TOKENPHAGE_NO_UPDATE_NOTIFIER || !!process.env.TOKENPHAGE_API;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 캐시(cfg.latestVersion)와 현재 버전을 비교해 업데이트 정보를 반환한다. 네트워크를 쓰지 않는 동기 함수.
|
|
59
|
+
* @param {string} currentVersion 현재 설치 버전(package.json)
|
|
60
|
+
* @param {object} cfg loadConfig() 결과(또는 동등한 객체)
|
|
61
|
+
* @returns {{hasUpdate:boolean, latest?:string, current?:string}}
|
|
62
|
+
*/
|
|
63
|
+
export function getUpdateInfo(currentVersion, cfg) {
|
|
64
|
+
if (isUpdateNotifierDisabled()) return { hasUpdate: false };
|
|
65
|
+
const latest = cfg?.latestVersion;
|
|
66
|
+
if (!latest) return { hasUpdate: false };
|
|
67
|
+
if (compareVersion(latest, currentVersion) > 0) {
|
|
68
|
+
return { hasUpdate: true, latest, current: currentVersion };
|
|
69
|
+
}
|
|
70
|
+
return { hasUpdate: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** npm 레지스트리에서 latest 버전 문자열을 조회한다. 실패(타임아웃·404·파싱)는 모두 null. */
|
|
74
|
+
async function fetchLatestFromNpm() {
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
77
|
+
try {
|
|
78
|
+
const res = await fetch(REGISTRY_URL, { signal: controller.signal });
|
|
79
|
+
if (!res.ok) return null;
|
|
80
|
+
const body = await res.json();
|
|
81
|
+
return typeof body?.version === 'string' ? body.version : null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null; // 네트워크 단절·abort·비JSON 등 — 조용히 무시
|
|
84
|
+
} finally {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1-클릭 업데이트: 전역(npm i -g) 설치인지 감지해, 전역이면 최신버전을 설치하고,
|
|
3
|
+
* 그 외(npx/로컬 의존성/git clone)면 정확한 수동 명령을 안내한다.
|
|
4
|
+
*
|
|
5
|
+
* 현재 프로세스는 설치 직후에도 여전히 "구버전 코드"이므로(핫스왑 불가),
|
|
6
|
+
* 성공 시 재실행을 안내하고 호출부(index.js)에서 프로세스를 종료한다.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn, execFileSync } from 'child_process';
|
|
9
|
+
import { realpathSync } from 'fs';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { accent } from '../gradient.js';
|
|
12
|
+
import { PACKAGE_NAME } from './update-check.js';
|
|
13
|
+
|
|
14
|
+
const isWin = process.platform === 'win32';
|
|
15
|
+
// Windows는 spawn('npm')이 ENOENT → npm.cmd를 쓴다. 또 최신 Node는 보안 패치(CVE-2024-27980) 이후
|
|
16
|
+
// .cmd 실행에 shell:true를 요구한다. 인자는 전부 고정 리터럴(아래 PACKAGE_NAME 상수)이라 인젝션 표면이 없다.
|
|
17
|
+
const npmCmd = isWin ? 'npm.cmd' : 'npm';
|
|
18
|
+
const MANUAL_CMD = `npm install -g ${PACKAGE_NAME}@latest`;
|
|
19
|
+
|
|
20
|
+
/** npm 전역 설치 루트(`npm root -g`)를 반환한다. 실패하면 null. */
|
|
21
|
+
function globalRoot() {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync(npmCmd, ['root', '-g'], { encoding: 'utf8', shell: isWin }).trim();
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 현재 실행 중인 CLI가 전역 npm 설치(.../lib/node_modules/...)인지 realpath prefix 매칭으로 판정한다. */
|
|
30
|
+
function isGlobalInstall() {
|
|
31
|
+
try {
|
|
32
|
+
const script = realpathSync(process.argv[1]);
|
|
33
|
+
const root = globalRoot();
|
|
34
|
+
return !!root && script.startsWith(realpathSync(root));
|
|
35
|
+
} catch {
|
|
36
|
+
return false; // 불확실하면 전역 아님으로 간주 → 수동 안내(잘못된 npm i -g 방지)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `npm install -g <pkg>@latest`를 출력 스트리밍으로 실행한다. {ok, code?, err?} 반환. */
|
|
41
|
+
function npmInstallGlobal() {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const child = spawn(npmCmd, ['install', '-g', `${PACKAGE_NAME}@latest`], {
|
|
44
|
+
stdio: 'inherit',
|
|
45
|
+
shell: isWin,
|
|
46
|
+
});
|
|
47
|
+
child.on('error', (err) => resolve({ ok: false, err })); // ENOENT 등 실행 자체 실패
|
|
48
|
+
child.on('close', (code) => resolve({ ok: code === 0, code })); // 종료 코드로 성공 판정
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 정확한 수동 업데이트 명령을 강조색으로 출력한다. */
|
|
53
|
+
function printManual() {
|
|
54
|
+
console.log(accent(` → ${MANUAL_CMD}`));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 업데이트를 수행한다.
|
|
59
|
+
* @param {string} latest 설치 대상 최신버전(표시용)
|
|
60
|
+
* @returns {Promise<{updated:boolean}>} updated=true면 호출부가 재실행 안내 후 프로세스를 종료해야 한다.
|
|
61
|
+
*/
|
|
62
|
+
export async function runUpdate(latest) {
|
|
63
|
+
// 전역 설치가 아니면 자동 업데이트가 위험/무의미 → 수동 명령만 안내.
|
|
64
|
+
if (!isGlobalInstall()) {
|
|
65
|
+
console.log(chalk.gray(' Not a global npm install — update manually:'));
|
|
66
|
+
printManual();
|
|
67
|
+
return { updated: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(accent(` Updating to v${latest}...`));
|
|
71
|
+
const result = await npmInstallGlobal();
|
|
72
|
+
|
|
73
|
+
if (result.ok) {
|
|
74
|
+
console.log(accent(` ✓ Updated. Restart tokenphage to use v${latest}.`));
|
|
75
|
+
return { updated: true };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 실행 실패(npm 없음) vs 종료코드 실패(권한 등)를 구분해 안내.
|
|
79
|
+
if (result.err) {
|
|
80
|
+
console.log(chalk.yellow(' npm was not found or failed to launch.'));
|
|
81
|
+
} else {
|
|
82
|
+
console.log(chalk.yellow(' Update failed (permission?). Try again with the right permissions, or update manually:'));
|
|
83
|
+
}
|
|
84
|
+
printManual();
|
|
85
|
+
return { updated: false };
|
|
86
|
+
}
|