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/ui.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import gradient from 'gradient-string';
|
|
3
|
+
import { colorAt, PURPLE_STOPS, ACCENT_STOPS, accent } from './gradient.js';
|
|
4
|
+
import { selectWithEsc } from './animated-select.js';
|
|
5
|
+
import { input } from '@inquirer/prompts';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { getUpdateInfo, PACKAGE_NAME } from './lib/update-check.js';
|
|
8
|
+
|
|
9
|
+
// 버전은 package.json 단일 출처에서 읽는다 (index.js와 동일 패턴 — 하드코딩으로 인한 표기 불일치 방지).
|
|
10
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
11
|
+
|
|
12
|
+
const TOKEN_LINES = [
|
|
13
|
+
' ████████╗ ██████╗ ██╗ ██╗███████╗███╗ ██╗',
|
|
14
|
+
' ╚══██╔══╝██╔═══██╗██║ ██╔╝██╔════╝████╗ ██║',
|
|
15
|
+
' ██║ ██║ ██║█████╔╝ █████╗ ██╔██╗██║',
|
|
16
|
+
' ██║ ██║ ██║██╔═██╗ ██╔══╝ ██║╚████║',
|
|
17
|
+
' ██║ ╚██████╔╝██║ ██╗███████╗██║ ╚███║',
|
|
18
|
+
' ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚══╝',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const PHAGE_LINES = [
|
|
22
|
+
' ██████╗ ██╗ ██╗ █████╗ ██████╗ ███████╗',
|
|
23
|
+
' ██╔══██╗██║ ██║██╔══██╗██╔════╝ ██╔════╝',
|
|
24
|
+
' ██████╔╝███████║███████║██║ ███╗█████╗',
|
|
25
|
+
' ██╔═══╝ ██╔══██║██╔══██║██║ ██║██╔══╝',
|
|
26
|
+
' ██║ ██║ ██║██║ ██║╚██████╔╝███████╗',
|
|
27
|
+
' ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
// PHAGE 뒤에 붙는 느낌표 블록 (PHAGE 와 동일한 6줄 높이)
|
|
31
|
+
const EXCLAIM_LINES = [
|
|
32
|
+
' ██╗',
|
|
33
|
+
' ██║',
|
|
34
|
+
' ██║',
|
|
35
|
+
' ╚═╝',
|
|
36
|
+
' ██╗',
|
|
37
|
+
' ╚═╝',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// PHAGE 줄 정렬 폭 (E 글자 줄이 짧아 우측 패딩으로 느낌표 위치를 맞춘다)
|
|
41
|
+
const PHAGE_WIDTH = 43;
|
|
42
|
+
|
|
43
|
+
/** 라인을 세로 위치 i/n 의 좁은 색 범위로 그라데이션 + bold 처리해 반환. */
|
|
44
|
+
function paintLine(line, i, n, stops) {
|
|
45
|
+
return chalk.bold(gradient([colorAt(i / n, stops), colorAt((i + 1) / n, stops)])(line));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* TOKENPHAGE 로고를 세로 보라 그라데이션으로 렌더링한다.
|
|
50
|
+
* PHAGE 6줄 뒤에는 민트 시안 강조색 느낌표를 덧붙여, 보라 본문과 대비되는
|
|
51
|
+
* 포인트를 준다. 각 영역은 자기 영역 기준의 세로 그라데이션을 갖는다.
|
|
52
|
+
*/
|
|
53
|
+
function renderLogo() {
|
|
54
|
+
const all = [...TOKEN_LINES, ...PHAGE_LINES];
|
|
55
|
+
const n = all.length;
|
|
56
|
+
const tokenCount = TOKEN_LINES.length;
|
|
57
|
+
const phageCount = PHAGE_LINES.length;
|
|
58
|
+
|
|
59
|
+
return all
|
|
60
|
+
.map((line, i) => {
|
|
61
|
+
const body = paintLine(line, i, n, PURPLE_STOPS);
|
|
62
|
+
if (i < tokenCount) return body;
|
|
63
|
+
|
|
64
|
+
// PHAGE 영역: 우측을 폭에 맞춰 패딩한 뒤 민트 강조 느낌표를 덧붙인다.
|
|
65
|
+
const phageIdx = i - tokenCount;
|
|
66
|
+
const padded = chalk.bold(
|
|
67
|
+
gradient([colorAt(i / n, PURPLE_STOPS), colorAt((i + 1) / n, PURPLE_STOPS)])(
|
|
68
|
+
line.padEnd(PHAGE_WIDTH),
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
const exclaim = paintLine(EXCLAIM_LINES[phageIdx], phageIdx, phageCount, ACCENT_STOPS);
|
|
72
|
+
return padded + exclaim;
|
|
73
|
+
})
|
|
74
|
+
.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Decode JWT payload to extract username (no verification, display only). */
|
|
78
|
+
function decodeJwtUsername(token) {
|
|
79
|
+
try {
|
|
80
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
|
|
81
|
+
return payload.username ?? null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Print welcome screen: Tokenphage logo. */
|
|
88
|
+
export function printWelcome() {
|
|
89
|
+
console.clear();
|
|
90
|
+
|
|
91
|
+
const logo = renderLogo();
|
|
92
|
+
|
|
93
|
+
console.log('\n' + logo);
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(chalk.gray(` AI token usage as a GitHub badge — v${version}`));
|
|
96
|
+
console.log();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* First-run menu: prompt unauthenticated user to start Gist auth or exit.
|
|
101
|
+
* @returns {'auth' | 'exit'}
|
|
102
|
+
*/
|
|
103
|
+
export async function promptFirstRun() {
|
|
104
|
+
printWelcome();
|
|
105
|
+
|
|
106
|
+
console.log(accent(' Not authenticated yet.'));
|
|
107
|
+
console.log(chalk.gray(' Verify GitHub account ownership via a public Gist to get started.'));
|
|
108
|
+
console.log();
|
|
109
|
+
|
|
110
|
+
const action = await selectWithEsc({
|
|
111
|
+
message: 'What would you like to do?',
|
|
112
|
+
choices: [
|
|
113
|
+
{
|
|
114
|
+
name: 'Authenticate with Gist',
|
|
115
|
+
value: 'auth',
|
|
116
|
+
description: 'Create a public Gist to prove ownership and save your JWT',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'Exit',
|
|
120
|
+
value: 'exit',
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return action;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 인증된 사용자를 위한 대시보드 메뉴.
|
|
130
|
+
* @param {{ token: string, lastSyncDate: string|null, hookInstalled: boolean, hookMeta: object|null, latestVersion?: string }} cfg
|
|
131
|
+
* @returns {'update' | 'sync' | 'install-hook' | 'uninstall-hook' | 'advanced' | 'exit'}
|
|
132
|
+
*/
|
|
133
|
+
export async function promptDashboard(cfg) {
|
|
134
|
+
printWelcome();
|
|
135
|
+
|
|
136
|
+
// 캐시된 npm 최신버전과 현재 버전을 비교해, 새 버전이 있을 때만 민트 배너를 출력한다(네트워크 X).
|
|
137
|
+
const update = getUpdateInfo(version, cfg);
|
|
138
|
+
if (update.hasUpdate) {
|
|
139
|
+
const bar = accent(' ┃ ');
|
|
140
|
+
console.log(bar + accent(`⬆ Update available v${update.current} → v${update.latest}`));
|
|
141
|
+
console.log(bar + chalk.gray(`npm install -g ${PACKAGE_NAME}@latest`));
|
|
142
|
+
console.log();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const username = decodeJwtUsername(cfg.token) ?? '(unknown)';
|
|
146
|
+
const lastSync = cfg.lastSyncDate ?? 'never';
|
|
147
|
+
const hookStatus = cfg.hookInstalled
|
|
148
|
+
? chalk.green(' Auto-sync enabled') + chalk.gray(` (${cfg.hookMeta?.scheduleSpec ?? 'daily 04:00'})`)
|
|
149
|
+
: chalk.yellow(' Auto-sync not configured');
|
|
150
|
+
|
|
151
|
+
console.log(chalk.green(' Authenticated as ') + chalk.bold(`@${username}`));
|
|
152
|
+
console.log(chalk.gray(` Last sync: ${lastSync}`));
|
|
153
|
+
console.log(hookStatus);
|
|
154
|
+
console.log();
|
|
155
|
+
|
|
156
|
+
const choices = [
|
|
157
|
+
{ name: 'Sync now', value: 'sync', description: 'Push local token data to the server' },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
// 새 버전이 있으면 최상단에 흐르는 강조(flow) Update 선택지를 추가한다.
|
|
161
|
+
if (update.hasUpdate) {
|
|
162
|
+
choices.unshift({
|
|
163
|
+
name: `⬆ Update to v${update.latest}`,
|
|
164
|
+
value: 'update',
|
|
165
|
+
description: 'Download and install the latest version',
|
|
166
|
+
flow: true,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (cfg.hookInstalled) {
|
|
171
|
+
choices.push({ name: 'Disable auto-sync', value: 'uninstall-hook', flow: true });
|
|
172
|
+
} else {
|
|
173
|
+
choices.push({ name: 'Enable auto-sync', value: 'install-hook', description: 'Run sync daily at 04:00', flow: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
choices.push({ name: 'Advanced settings', value: 'advanced', description: 'Reset data and other options' });
|
|
177
|
+
choices.push({ name: 'Exit', value: 'exit' });
|
|
178
|
+
|
|
179
|
+
const action = await selectWithEsc({ message: 'What would you like to do?', choices });
|
|
180
|
+
return action;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Advanced settings submenu. Esc exits immediately (selectWithEsc behavior),
|
|
185
|
+
* so a 'Back' choice is provided for safe return to the dashboard.
|
|
186
|
+
* @returns {Promise<'reset' | 'back'>}
|
|
187
|
+
*/
|
|
188
|
+
export async function promptAdvanced() {
|
|
189
|
+
printWelcome();
|
|
190
|
+
console.log(chalk.gray(' Advanced settings'));
|
|
191
|
+
console.log();
|
|
192
|
+
return selectWithEsc({
|
|
193
|
+
message: 'Advanced settings',
|
|
194
|
+
choices: [
|
|
195
|
+
{ name: 'Reset all data', value: 'reset', description: 'Delete ALL server-side token data and re-sync from scratch' },
|
|
196
|
+
{ name: 'Back', value: 'back' },
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Confirm a full reset. Because this is irreversible, require typing the GitHub
|
|
203
|
+
* username rather than a single keystroke.
|
|
204
|
+
* @param {{ token: string }} cfg
|
|
205
|
+
* @returns {Promise<boolean>} true when the typed username matches
|
|
206
|
+
*/
|
|
207
|
+
export async function confirmReset(cfg) {
|
|
208
|
+
const username = decodeJwtUsername(cfg.token) ?? '';
|
|
209
|
+
console.log();
|
|
210
|
+
console.log(chalk.red.bold(' ⚠ WARNING: This is irreversible.'));
|
|
211
|
+
console.log(chalk.red(' All token usage data on the server will be permanently deleted,'));
|
|
212
|
+
console.log(chalk.red(' then re-synced from scratch.'));
|
|
213
|
+
console.log(chalk.gray(' Your local Claude Code logs are NOT touched. Allowed once every 24 hours.'));
|
|
214
|
+
console.log();
|
|
215
|
+
const typed = await input({ message: `Type your GitHub username (${username}) to confirm:` });
|
|
216
|
+
return username.length > 0 && typed.trim() === username;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 작업 결과(성공/실패) 메시지를 사용자가 읽을 수 있도록 Enter 입력까지 대기한다.
|
|
221
|
+
* 대시보드로 복귀하면 printWelcome()의 console.clear()로 화면이 지워지므로,
|
|
222
|
+
* 그 전에 결과를 확인시키기 위한 용도다.
|
|
223
|
+
*/
|
|
224
|
+
export async function pauseForEnter() {
|
|
225
|
+
console.log();
|
|
226
|
+
await input({ message: chalk.gray('Press Enter to return to the menu') });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 눈에 띄는 에러 배너를 좌측 빨강 바(blockquote) 스타일로 출력한다.
|
|
231
|
+
* 상태색(빨강)으로 강조하고 제목·사유·다음 행동 힌트를 구조화해 보여준다.
|
|
232
|
+
* 하이라이팅 규칙상 강조는 민트지만, 에러는 의미색(빨강)을 쓴다.
|
|
233
|
+
* @param {string} title 짧은 제목 (예: 'Authentication failed')
|
|
234
|
+
* @param {string} [detail] 상세 사유 (서버/에러 메시지)
|
|
235
|
+
* @param {string} [hint] 다음 행동 안내 (선택)
|
|
236
|
+
*/
|
|
237
|
+
export function showError(title, detail, hint) {
|
|
238
|
+
const bar = chalk.red(' ┃ ');
|
|
239
|
+
const emptyBar = chalk.red(' ┃');
|
|
240
|
+
console.log();
|
|
241
|
+
console.log(bar + chalk.red.bold(`✖ ${title}`));
|
|
242
|
+
if (detail) {
|
|
243
|
+
console.log(emptyBar);
|
|
244
|
+
console.log(bar + detail);
|
|
245
|
+
}
|
|
246
|
+
if (hint) {
|
|
247
|
+
console.log(emptyBar);
|
|
248
|
+
console.log(bar + chalk.gray(`→ ${hint}`));
|
|
249
|
+
}
|
|
250
|
+
console.log();
|
|
251
|
+
}
|