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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kobenlys
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# TokenPhage CLI
|
|
2
|
+
|
|
3
|
+
> AI 토큰 사용량을 GitHub README 배지로 보여주는 CLI.
|
|
4
|
+
|
|
5
|
+
Claude Code와 Codex의 토큰 사용량을 모아 GitHub README에 붙일 수 있는 배지로 만들어 줍니다.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 요구 사항
|
|
10
|
+
|
|
11
|
+
- **Node.js 20.17 이상**
|
|
12
|
+
- macOS 또는 Windows (자동 동기화 사용 시)
|
|
13
|
+
- GitHub 계정
|
|
14
|
+
|
|
15
|
+
## 설치
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g tokenphage
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
설치 후 `tokenphage` 명령을 쓸 수 있습니다.
|
|
22
|
+
|
|
23
|
+
## 빠른 시작
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
tokenphage # 대화형 화면 실행 (처음이면 로그인부터 안내)
|
|
27
|
+
tokenphage login <github-id> # GitHub 계정 인증
|
|
28
|
+
tokenphage sync # 지금 사용량 동기화
|
|
29
|
+
tokenphage install-hook # 매일 04:00 자동 동기화 켜기
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
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
|
+
## 대화형 사용법
|
|
51
|
+
|
|
52
|
+
`tokenphage`를 그냥 실행하면 메뉴가 뜹니다. **↑/↓로 이동, Enter로 선택, Esc로 종료**합니다.
|
|
53
|
+
|
|
54
|
+
### 처음 실행 (로그인 전)
|
|
55
|
+
```
|
|
56
|
+
❯ Authenticate with Gist ← 로그인 시작
|
|
57
|
+
Exit
|
|
58
|
+
```
|
|
59
|
+
`Authenticate with Gist`를 고르면 인증을 진행하고, 끝나면 자동으로 첫 동기화까지 마친 뒤 대시보드로 들어갑니다.
|
|
60
|
+
|
|
61
|
+
### 대시보드 (로그인 후)
|
|
62
|
+
```
|
|
63
|
+
Authenticated as @your-id
|
|
64
|
+
Last sync: 2026-06-22
|
|
65
|
+
Auto-sync enabled (daily 04:00)
|
|
66
|
+
|
|
67
|
+
❯ Sync now ← 지금 동기화
|
|
68
|
+
Disable auto-sync ← (또는) Enable auto-sync
|
|
69
|
+
Advanced settings
|
|
70
|
+
Exit
|
|
71
|
+
```
|
|
72
|
+
- **Sync now** — 지금 바로 동기화
|
|
73
|
+
- **Enable / Disable auto-sync** — 자동 동기화 켜기/끄기 (현재 상태에 맞춰 표시)
|
|
74
|
+
- **Advanced settings** — 데이터 초기화 등
|
|
75
|
+
- **Exit** — 종료
|
|
76
|
+
|
|
77
|
+
### Advanced settings
|
|
78
|
+
```
|
|
79
|
+
❯ Reset all data ← 데이터 전체 초기화
|
|
80
|
+
Back
|
|
81
|
+
```
|
|
82
|
+
**Reset all data**는 서버에 쌓인 토큰 데이터를 모두 지우고 처음부터 다시 동기화합니다. 되돌릴 수 없어서, 확인을 위해 **본인 GitHub 아이디를 직접 입력**해야 진행됩니다. (로컬 기록은 건드리지 않으며, 24시간에 한 번만 가능)
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 로그인 (Gist 인증)
|
|
87
|
+
|
|
88
|
+
비밀번호 없이, **본인 GitHub 계정으로 공개 Gist를 만들 수 있다는 점**으로 계정을 인증합니다.
|
|
89
|
+
|
|
90
|
+
1. 화면에 인증용 문자열이 표시됩니다.
|
|
91
|
+
2. 안내대로 `https://gist.github.com/new`에서 **공개(public) Gist**를 만듭니다.
|
|
92
|
+
- 파일 이름: `tokenphage.txt`
|
|
93
|
+
- 내용: 화면에 나온 인증 문자열
|
|
94
|
+
3. 만들어진 Gist의 주소(URL)나 ID를 붙여넣습니다.
|
|
95
|
+
4. 인증이 끝나면 로그인이 완료됩니다.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 자동 동기화
|
|
100
|
+
|
|
101
|
+
`install-hook`을 켜면 매일 **오전 4시**에 자동으로 동기화됩니다(macOS·Windows). 컴퓨터를 거의 안 쓰는 시간대라 작업을 방해하지 않습니다.
|
|
102
|
+
|
|
103
|
+
- 켜기: `tokenphage install-hook` (또는 대시보드 → Enable auto-sync)
|
|
104
|
+
- 끄기: `tokenphage uninstall-hook` (또는 대시보드 → Disable auto-sync)
|
|
105
|
+
- 실행 기록은 `~/.tokenphage/logs/sync.log`에서 볼 수 있습니다.
|
|
106
|
+
|
|
107
|
+
처음 설정할 때 권한을 묻는다면:
|
|
108
|
+
- **macOS** — 시스템 설정 → 개인정보 보호 및 보안 → 자동화 허용
|
|
109
|
+
- **Windows** — **관리자 권한 터미널**에서 다시 실행
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 저장되는 곳
|
|
114
|
+
|
|
115
|
+
로그인 정보와 설정은 `~/.tokenphage/` 폴더에 저장됩니다. 로그아웃하거나 초기화하려면 이 폴더를 지우면 됩니다.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## 토큰이 누락되지 않게 하기
|
|
120
|
+
|
|
121
|
+
Claude Code는 오래된 세션 기록을 자동으로 지울 수 있고, 지워진 기록의 토큰은 다시 집계되지 않습니다. 그래서 로그인 직후 CLI가 **"기록을 계속 보관할지"** 물어봅니다. **Yes**로 두면 이후 모든 토큰이 빠짐없이 집계됩니다.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 문제 해결
|
|
126
|
+
|
|
127
|
+
| 증상 | 해결 |
|
|
128
|
+
|------|------|
|
|
129
|
+
| `Not authenticated` 라고 나옴 | 먼저 `tokenphage login <github-id>` 로 로그인 |
|
|
130
|
+
| `login` 이 바로 종료됨 | 일반 터미널(입력 가능한 창)에서 실행 |
|
|
131
|
+
| 자동 동기화 설정 중 권한 거부 | macOS: 자동화 권한 허용 / Windows: 관리자 터미널에서 재실행 |
|
|
132
|
+
| 자동 동기화가 안 도는 것 같음 | `~/.tokenphage/logs/sync.log` 확인 |
|
|
133
|
+
| 토큰이 누락됨 | 로그인 직후 안내된 "기록 보관" 설정을 켰는지 확인 |
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tokenphage",
|
|
3
|
+
"version": "2026.7.0-BETA",
|
|
4
|
+
"description": "AI 토큰 사용량 GitHub README 뱃지",
|
|
5
|
+
"author": "kobenlys <kobenlys@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/TOKENPHAGE/tokenphage-cli.git"
|
|
10
|
+
},
|
|
11
|
+
"main": "src/index.js",
|
|
12
|
+
"bin": {
|
|
13
|
+
"tokenphage": "./src/index.js"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"files": [
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node src/index.js",
|
|
21
|
+
"dev": "cross-env TOKENPHAGE_API=http://localhost:8080 node src/index.js",
|
|
22
|
+
"test": "node --test test/*.test.js"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@inquirer/prompts": "^8.5.0",
|
|
26
|
+
"chalk": "^5.6.2",
|
|
27
|
+
"commander": "^12.1.0",
|
|
28
|
+
"glob": "^11.0.0",
|
|
29
|
+
"gradient-string": "^3.0.0",
|
|
30
|
+
"open": "^10.1.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"cross-env": "^7.0.3"
|
|
34
|
+
},
|
|
35
|
+
"overrides": {
|
|
36
|
+
"mute-stream": "^3.0.0"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": "^20.17.0 || ^22.13.0 || >=23.5.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPrompt,
|
|
3
|
+
useState,
|
|
4
|
+
useEffect,
|
|
5
|
+
useKeypress,
|
|
6
|
+
usePrefix,
|
|
7
|
+
makeTheme,
|
|
8
|
+
isEnterKey,
|
|
9
|
+
isUpKey,
|
|
10
|
+
isDownKey,
|
|
11
|
+
} from '@inquirer/core';
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
import { flowText, accent } from './gradient.js';
|
|
14
|
+
|
|
15
|
+
// 애니메이션 타이밍: PERIOD(ms)마다 한 프레임, CYCLE 프레임에 그라데이션 한 바퀴.
|
|
16
|
+
// 80ms × 24 ≈ 1.9초/바퀴 — PERIOD는 깜빡임/CPU 안전선(80ms)을 유지
|
|
17
|
+
const PERIOD = 80;
|
|
18
|
+
const CYCLE = 24;
|
|
19
|
+
|
|
20
|
+
// 흐르는 애니메이션 활성 조건. 비TTY·NO_COLOR·dumb 터미널·색 미지원이거나
|
|
21
|
+
// TOKENPHAGE_NO_ANIM=1(reduced-motion 옵트아웃)이면 정적으로 폴백한다.
|
|
22
|
+
const ANIM_ENABLED =
|
|
23
|
+
process.stdout.isTTY === true &&
|
|
24
|
+
!process.env.NO_COLOR &&
|
|
25
|
+
process.env.TERM !== 'dumb' &&
|
|
26
|
+
chalk.level > 0 &&
|
|
27
|
+
process.env.TOKENPHAGE_NO_ANIM !== '1';
|
|
28
|
+
|
|
29
|
+
// 메뉴(입력창 없음) 표시 중 터미널 커서를 숨기기 위한 DECTCEM 시퀀스.
|
|
30
|
+
const HIDE_CURSOR = '\x1B[?25l';
|
|
31
|
+
const SHOW_CURSOR = '\x1B[?25h';
|
|
32
|
+
// 커서 복원(비TTY면 무시). 종료/언마운트 모든 경로에서 호출.
|
|
33
|
+
const showCursor = () => { if (process.stdout.isTTY) process.stdout.write(SHOW_CURSOR); };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Esc 즉시 종료 + (선택적) 흐르는 그라데이션을 지원하는 커스텀 select.
|
|
37
|
+
* choice에 `flow: true`가 있으면 커서 위치와 무관하게 항상 그라데이션이 흐른다.
|
|
38
|
+
* pagination/disabled/검색은 메뉴 항목이 적어 의도적으로 생략한다.
|
|
39
|
+
*/
|
|
40
|
+
export const selectWithEsc = createPrompt((config, done) => {
|
|
41
|
+
const { message, choices } = config;
|
|
42
|
+
const theme = makeTheme(config.theme);
|
|
43
|
+
const [status, setStatus] = useState('idle');
|
|
44
|
+
const [active, setActive] = useState(0);
|
|
45
|
+
const [frame, setFrame] = useState(0);
|
|
46
|
+
|
|
47
|
+
const prefix = usePrefix({ status, theme });
|
|
48
|
+
const hasFlow = choices.some((choice) => choice.flow);
|
|
49
|
+
|
|
50
|
+
// 흐르는 그라데이션 타이머. flow 항목이 있고 애니메이션이 켜졌을 때만 가동.
|
|
51
|
+
// 시간 기반 phase라 useState 업데이터 함수 미지원 제약을 피하고 drift도 없다.
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (status === 'done' || !ANIM_ENABLED || !hasFlow) return undefined;
|
|
54
|
+
const id = setInterval(() => {
|
|
55
|
+
setFrame(Math.floor(Date.now() / PERIOD) % CYCLE);
|
|
56
|
+
}, PERIOD);
|
|
57
|
+
return () => clearInterval(id);
|
|
58
|
+
}, [status]);
|
|
59
|
+
|
|
60
|
+
// 메뉴 표시 중에는 입력창이 없으므로 터미널 커서를 숨긴다.
|
|
61
|
+
// done()/정상 종료 시 언마운트 cleanup으로 복원된다. Esc 경로는 키 핸들러에서 별도 복원.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (process.stdout.isTTY) process.stdout.write(HIDE_CURSOR);
|
|
64
|
+
return () => showCursor();
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
useKeypress((key) => {
|
|
68
|
+
if (key.name === 'escape') {
|
|
69
|
+
showCursor(); // process.exit는 useEffect cleanup을 건너뛰므로 명시 복원
|
|
70
|
+
process.exit(0); // 기존 selectWithEsc 동작 유지: Esc는 즉시 종료
|
|
71
|
+
} else if (isEnterKey(key)) {
|
|
72
|
+
setStatus('done');
|
|
73
|
+
done(choices[active].value);
|
|
74
|
+
} else if (isUpKey(key)) {
|
|
75
|
+
setActive((active - 1 + choices.length) % choices.length);
|
|
76
|
+
} else if (isDownKey(key)) {
|
|
77
|
+
setActive((active + 1) % choices.length);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const header = `${prefix} ${theme.style.message(message, status)}`;
|
|
82
|
+
|
|
83
|
+
// 완료 시: 선택값 한 줄만 남기고 리스트/설명은 지운다.
|
|
84
|
+
if (status === 'done') {
|
|
85
|
+
const selected = choices[active];
|
|
86
|
+
return `${header} ${accent(selected.short ?? selected.name)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const phase = frame / CYCLE;
|
|
90
|
+
const lines = choices.map((choice, i) => {
|
|
91
|
+
const isActive = i === active;
|
|
92
|
+
const pointer = isActive ? accent('❯ ') : ' ';
|
|
93
|
+
|
|
94
|
+
let label;
|
|
95
|
+
if (choice.flow && ANIM_ENABLED) {
|
|
96
|
+
label = flowText(choice.name, phase); // 흐르는 그라데이션 (커서 무관)
|
|
97
|
+
} else if (choice.flow || isActive) {
|
|
98
|
+
label = accent(choice.name); // 애니 꺼진 flow 항목 / 커서 항목 → 정적 민트
|
|
99
|
+
} else {
|
|
100
|
+
label = choice.name; // 그 외 → 평문
|
|
101
|
+
}
|
|
102
|
+
return pointer + label;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const activeChoice = choices[active];
|
|
106
|
+
const bottom = activeChoice?.description ? accent(activeChoice.description) : '';
|
|
107
|
+
|
|
108
|
+
return [`${header}\n${lines.join('\n')}`, bottom];
|
|
109
|
+
});
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
|
|
6
|
+
// 로컬 설정 파일 경로: ~/.tokenphage/config.json (JWT·deviceId·sync 상태 보관)
|
|
7
|
+
const CONFIG_DIR = join(homedir(), '.tokenphage');
|
|
8
|
+
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
9
|
+
|
|
10
|
+
/** config.json을 읽어 객체로 반환한다. 파일이 없거나 손상됐으면 빈 객체. */
|
|
11
|
+
export function loadConfig() {
|
|
12
|
+
try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
|
|
13
|
+
catch { return {}; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** config 객체를 config.json에 저장한다. JWT가 담기므로 소유자 전용(0600) 권한으로 기록한다. */
|
|
17
|
+
export function saveConfig(cfg) {
|
|
18
|
+
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 미지원 — 무시 */ }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 저장된 JWT를 반환한다. 없으면 null. */
|
|
24
|
+
export function loadToken() {
|
|
25
|
+
return loadConfig().token ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 다른 설정은 보존한 채 JWT만 갱신해 저장한다. */
|
|
29
|
+
export function saveToken(token) {
|
|
30
|
+
saveConfig({ ...loadConfig(), token });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 기기 식별용 deviceId(UUID)를 반환한다. 없으면 새로 발급·저장한 뒤 반환한다. */
|
|
34
|
+
export function getOrCreateDeviceId() {
|
|
35
|
+
const cfg = loadConfig();
|
|
36
|
+
if (cfg.deviceId) return cfg.deviceId;
|
|
37
|
+
const deviceId = randomUUID();
|
|
38
|
+
saveConfig({ ...cfg, deviceId });
|
|
39
|
+
return deviceId;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 훅 설치 상태를 설정에 기록한다.
|
|
44
|
+
* 설치 시 설치 시각(ISO)과 메타데이터를 함께 저장하고, 해제 시 둘 다 null로 비운다.
|
|
45
|
+
*/
|
|
46
|
+
export function setHookInstalled(installed, meta = {}) {
|
|
47
|
+
const cfg = loadConfig();
|
|
48
|
+
saveConfig({
|
|
49
|
+
...cfg,
|
|
50
|
+
hookInstalled: installed,
|
|
51
|
+
hookInstalledAt: installed ? new Date().toISOString() : null,
|
|
52
|
+
hookMeta: installed ? meta : null,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** config.json의 claudePaths(사용자 지정 Claude 디렉터리 목록)를 반환한다. 비배열이면 빈 배열. */
|
|
57
|
+
export function getCustomClaudePaths() {
|
|
58
|
+
const paths = loadConfig().claudePaths;
|
|
59
|
+
return Array.isArray(paths) ? paths : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 마지막 sync 완료 날짜(YYYY-MM-DD)를 반환한다. 없으면 null. */
|
|
63
|
+
export function getLastSyncDate() { return loadConfig().lastSyncDate ?? null; }
|
|
64
|
+
|
|
65
|
+
/** 마지막 sync 날짜를 저장한다. 다음 sync는 이 날짜 이후만 증분 파싱한다. */
|
|
66
|
+
export function saveLastSyncDate(date) {
|
|
67
|
+
saveConfig({ ...loadConfig(), lastSyncDate: date });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** lastSyncDate를 제거해 다음 sync가 전체 히스토리를 재파싱하도록 한다. token/deviceId는 보존. */
|
|
71
|
+
export function clearLastSyncDate() {
|
|
72
|
+
const { lastSyncDate: _omit, ...rest } = loadConfig();
|
|
73
|
+
saveConfig(rest);
|
|
74
|
+
}
|
|
75
|
+
|
|
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
|
+
// Gist 소유권 검증용 파일명 — 사용자가 이 이름으로 Gist를 만들어야 한다.
|
|
88
|
+
export const VERIFICATION_FILE = 'tokenphage.txt';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Gist URL 또는 원본 ID에서 gist ID만 추출한다.
|
|
92
|
+
* https://gist.github.com/octocat/abc123def456 → abc123def456
|
|
93
|
+
* https://gist.github.com/abc123def456 → abc123def456
|
|
94
|
+
* abc123def456 → abc123def456
|
|
95
|
+
*/
|
|
96
|
+
export function parseGistId(input) {
|
|
97
|
+
if (!input) return '';
|
|
98
|
+
const trimmed = input.trim();
|
|
99
|
+
// URL이면 (사용자명/) 뒤의 ID 토큰만 캡처하고, 그 외에는 앞뒤 슬래시만 제거한다.
|
|
100
|
+
const match = trimmed.match(/gist\.github\.com\/(?:[^/]+\/)?([A-Za-z0-9]+)/);
|
|
101
|
+
if (match) return match[1];
|
|
102
|
+
return trimmed.replace(/^\/+|\/+$/g, '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** url에 JSON body를 POST하고 {ok, status, body}를 반환한다. 응답이 JSON이 아니면 {error: 원문}으로 감싼다. */
|
|
106
|
+
async function postJson(url, body) {
|
|
107
|
+
// Node 18+ 전역 fetch 사용 (node-fetch 의존성 불필요).
|
|
108
|
+
const res = await fetch(url, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
111
|
+
body: JSON.stringify(body),
|
|
112
|
+
});
|
|
113
|
+
const text = await res.text();
|
|
114
|
+
let parsed;
|
|
115
|
+
try { parsed = JSON.parse(text); } catch { parsed = { error: text }; } // 비JSON 응답 방어
|
|
116
|
+
return { ok: res.ok, status: res.status, body: parsed };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 질문을 출력하고 표준입력 한 줄을 받아 trim한 문자열로 resolve한다. (readline 지연 로드) */
|
|
120
|
+
function prompt(question) {
|
|
121
|
+
return import('readline').then(({ default: readline }) => {
|
|
122
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
123
|
+
return new Promise(resolve => {
|
|
124
|
+
rl.question(question, answer => { rl.close(); resolve(answer.trim()); });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** ISO 문자열을 로컬 'YYYY-MM-DD HH:mm'로 포맷한다. */
|
|
130
|
+
function formatExpiry(iso) {
|
|
131
|
+
const d = new Date(iso);
|
|
132
|
+
if (Number.isNaN(d.getTime())) return iso; // 파싱 실패 시 원문 그대로 (방어)
|
|
133
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
134
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Gist 기반 로그인: 챌린지 발급 → 사용자가 Gist 생성 → 검증 → JWT 반환.
|
|
139
|
+
* username이 없으면 입력을 요청한다.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} apiBase API 서버 base URL
|
|
142
|
+
* @param {string} [username] GitHub 사용자명 (생략 시 프롬프트로 입력)
|
|
143
|
+
* @returns {Promise<string>} 발급된 JWT
|
|
144
|
+
*/
|
|
145
|
+
export async function gistLogin(apiBase, username) {
|
|
146
|
+
if (!username) {
|
|
147
|
+
username = await prompt('GitHub username: ');
|
|
148
|
+
}
|
|
149
|
+
if (!username) throw new Error('Username is required.');
|
|
150
|
+
|
|
151
|
+
// 1) 서버에 챌린지 요청 — 사용자가 Gist에 적을 검증 문자열을 받는다.
|
|
152
|
+
const challengeRes = await postJson(`${apiBase}/auth/challenge`, { username });
|
|
153
|
+
if (!challengeRes.ok) {
|
|
154
|
+
throw new Error(`Challenge request failed: ${challengeRes.status} ${JSON.stringify(challengeRes.body)}`);
|
|
155
|
+
}
|
|
156
|
+
const { challenge, expiresAt } = challengeRes.body ?? {};
|
|
157
|
+
if (!challenge) {
|
|
158
|
+
throw new Error(`Unexpected challenge response: ${JSON.stringify(challengeRes.body)}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 2) 사용자에게 public Gist 생성 절차를 안내한다.
|
|
162
|
+
console.log('');
|
|
163
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
164
|
+
console.log(' Create a public Gist to prove ownership of your GitHub account.');
|
|
165
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
166
|
+
console.log('');
|
|
167
|
+
console.log(` 1) Go to https://gist.github.com/new`);
|
|
168
|
+
console.log(` 2) Filename: ${VERIFICATION_FILE}`);
|
|
169
|
+
console.log(` Content:`);
|
|
170
|
+
console.log('');
|
|
171
|
+
console.log(` ${challenge}`);
|
|
172
|
+
console.log('');
|
|
173
|
+
console.log(` 3) Click "Create public gist" (NOT Secret gist!)`);
|
|
174
|
+
console.log(` 4) Paste the generated Gist URL or ID below`);
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(` Expires: ${formatExpiry(expiresAt)}`);
|
|
177
|
+
console.log('');
|
|
178
|
+
|
|
179
|
+
// 브라우저로 Gist 작성 페이지를 자동으로 열어준다 (실패해도 위 안내대로 수동 진행 가능).
|
|
180
|
+
try {
|
|
181
|
+
const { default: open } = await import('open');
|
|
182
|
+
await open('https://gist.github.com/new');
|
|
183
|
+
} catch { /* 브라우저 자동 실행 실패는 무시 */ }
|
|
184
|
+
|
|
185
|
+
// 3) 사용자가 만든 Gist의 URL/ID를 입력받아 ID만 추출한다.
|
|
186
|
+
const rawInput = await prompt('Gist URL or ID: ');
|
|
187
|
+
const gistId = parseGistId(rawInput);
|
|
188
|
+
if (!gistId) throw new Error('Could not parse a Gist ID from the input.');
|
|
189
|
+
|
|
190
|
+
// 4) 서버에 검증 요청 — Gist 내용이 챌린지와 일치하면 JWT를 발급받는다.
|
|
191
|
+
const verifyRes = await postJson(`${apiBase}/auth/verify`, { username, gistId });
|
|
192
|
+
if (!verifyRes.ok) {
|
|
193
|
+
const err = verifyRes.body?.error ?? 'unknown';
|
|
194
|
+
throw new Error(`Verification failed (${verifyRes.status}): ${err}`);
|
|
195
|
+
}
|
|
196
|
+
const { token, githubId, username: confirmedUsername } = verifyRes.body ?? {};
|
|
197
|
+
if (!token) {
|
|
198
|
+
throw new Error(`Verification response missing token: ${JSON.stringify(verifyRes.body)}`);
|
|
199
|
+
}
|
|
200
|
+
console.log('');
|
|
201
|
+
console.log(`[OK] Authenticated as ${confirmedUsername} (github_id=${githubId})`);
|
|
202
|
+
return token;
|
|
203
|
+
}
|
package/src/gradient.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
// 로고 세로 그라데이션 색 스톱 (위: 밝은 라벤더 보라 → 아래: 미디엄 퍼플)
|
|
4
|
+
export const PURPLE_STOPS = [
|
|
5
|
+
[221, 198, 255], // #DDC6FF
|
|
6
|
+
[165, 124, 255], // #A57CFF
|
|
7
|
+
[110, 70, 168], // #6E46A8
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
// 느낌표 강조 그라데이션 색 스톱 (위: 밝은 민트 → 아래: 진한 민트 시안)
|
|
11
|
+
export const ACCENT_STOPS = [
|
|
12
|
+
[94, 234, 212], // #5EEAD4
|
|
13
|
+
[45, 212, 191], // #2DD4BF
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// 흐르는 그라데이션 색 스톱 (쨍한 노랑 → 흰색 → 쨍한 노랑). 양 끝이 같아 매끄럽게 순환한다.
|
|
17
|
+
export const FLOW_STOPS = [
|
|
18
|
+
[255, 214, 0], // #FFD600
|
|
19
|
+
[255, 255, 255], // #FFFFFF
|
|
20
|
+
[255, 214, 0], // #FFD600
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// CLI 공용 하이라이트 색 — 느낌표 강조색(민트 시안)과 통일.
|
|
24
|
+
// 메뉴 선택 하이라이트, 강조 텍스트 등 모든 하이라이팅은 이 색을 사용한다.
|
|
25
|
+
export const ACCENT_HEX = '#5EEAD4';
|
|
26
|
+
export const accent = (text) => chalk.hex(ACCENT_HEX)(text);
|
|
27
|
+
|
|
28
|
+
/** 두 RGB 색 사이를 t(0~1) 비율로 선형 보간해 hex 문자열로 반환. */
|
|
29
|
+
function lerpHex(from, to, t) {
|
|
30
|
+
const ch = (a, b) => Math.round(a + (b - a) * t).toString(16).padStart(2, '0');
|
|
31
|
+
return `#${ch(from[0], to[0])}${ch(from[1], to[1])}${ch(from[2], to[2])}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 0~1 위치를 다색 스톱 위에서 보간해 hex 색을 반환. */
|
|
35
|
+
export function colorAt(t, stops) {
|
|
36
|
+
const seg = t * (stops.length - 1);
|
|
37
|
+
const idx = Math.min(Math.floor(seg), stops.length - 2);
|
|
38
|
+
return lerpHex(stops[idx], stops[idx + 1], seg - idx);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 문자열의 각 문자에 위상(phase) offset을 줘 시간에 따라 흐르는 그라데이션을 입힌다.
|
|
43
|
+
* 문자 위치(i/n)에 phase를 더해 0~1로 wrap한 뒤 colorAt으로 색을 구한다.
|
|
44
|
+
* @param {string} text 대상 문자열
|
|
45
|
+
* @param {number} phase 0~1 위상 offset (프레임/시간 기반으로 증가시키면 흐르는 효과)
|
|
46
|
+
* @param {number[][]} stops 색 스톱 배열 (기본 FLOW_STOPS)
|
|
47
|
+
* @returns {string} ANSI 색이 입혀진 문자열
|
|
48
|
+
*/
|
|
49
|
+
export function flowText(text, phase, stops = FLOW_STOPS) {
|
|
50
|
+
const chars = [...text]; // 유니코드 안전 분해
|
|
51
|
+
const n = Math.max(chars.length, 1);
|
|
52
|
+
return chars
|
|
53
|
+
.map((char, i) => {
|
|
54
|
+
const t = (((i / n) + phase) % 1 + 1) % 1; // 음수 방지 wrap
|
|
55
|
+
return chalk.hex(colorAt(t, stops))(char);
|
|
56
|
+
})
|
|
57
|
+
.join('');
|
|
58
|
+
}
|