tokenphage 2026.7.1-BETA → 2026.8.1
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 +5 -5
- package/package.json +3 -2
- package/src/auth.js +0 -11
- package/src/hook-reconcile.js +53 -0
- package/src/index.js +12 -15
- package/src/lib/config-schema.js +0 -2
- package/src/lib/paths.js +47 -19
- package/src/lib/update-check.js +19 -66
- package/src/parser.js +22 -3
- package/src/parsers/opencode.js +65 -0
- package/src/scheduler.js +65 -2
- package/src/ui.js +14 -7
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
> 먹어치운 토큰, 이젠 README에 기록하세요!
|
|
7
7
|
|
|
8
|
-
Claude Code
|
|
8
|
+
Claude Code, Codex, opencode의 토큰 사용량을 모아 GitHub README에 붙일 수 있는 배지로 만들어 줍니다.
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
@@ -20,7 +20,7 @@ Claude Code와 Codex의 토큰 사용량을 모아 GitHub README에 붙일 수
|
|
|
20
20
|
GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한 줄만 넣으면 됩니다. `kobenlys`를 본인 GitHub 아이디로 바꾸세요.
|
|
21
21
|
|
|
22
22
|
```markdown
|
|
23
|
-
[](https://github.com/TOKENPHAGE
|
|
23
|
+
[](https://github.com/TOKENPHAGE)
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
**결과 ↓**
|
|
@@ -32,11 +32,11 @@ GitHub README(이미지를 붙일 수 있는 곳이면 어디든)에 아래 한
|
|
|
32
32
|
</tr>
|
|
33
33
|
<tr>
|
|
34
34
|
<td align="center">
|
|
35
|
-
<a href="https://github.com/TOKENPHAGE
|
|
35
|
+
<a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=gpu&mode=dark" alt="TokenPhage gpu dark 배지"></a><br/>
|
|
36
36
|
<code>https://api.tokenphage.com/badge/kobenlys?theme=gpu&mode=dark</code>
|
|
37
37
|
</td>
|
|
38
38
|
<td align="center">
|
|
39
|
-
<a href="https://github.com/TOKENPHAGE
|
|
39
|
+
<a href="https://github.com/TOKENPHAGE"><img src="https://api.tokenphage.com/badge/kobenlys?theme=claude&mode=light" alt="TokenPhage claude light 배지"></a><br/>
|
|
40
40
|
<code>https://api.tokenphage.com/badge/kobenlys?theme=claude&mode=light</code>
|
|
41
41
|
</td>
|
|
42
42
|
</tr>
|
|
@@ -54,7 +54,7 @@ npm install -g tokenphage@latest
|
|
|
54
54
|
|
|
55
55
|
### ⬆️ 업데이트
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
대시보드를 열면 새 버전이 있는지 확인합니다. 새 버전이 확인되면 대시보드 맨 위에 안내 배너가 뜨고, 메뉴에 **⬆ Update to …** 항목이 추가됩니다. 이 항목을 고르면 최신 버전이 자동으로 설치됩니다(전역 설치가 아니면 정확한 수동 명령을 안내).
|
|
58
58
|
|
|
59
59
|
직접 올리고 싶다면 언제든 아래 명령을 실행하면 됩니다.
|
|
60
60
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenphage",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.8.1",
|
|
4
4
|
"description": "AI 토큰 사용량 GitHub README 뱃지",
|
|
5
5
|
"author": "kobenlys <kobenlys@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "node src/index.js",
|
|
21
21
|
"dev": "cross-env TOKENPHAGE_API=http://localhost:8080 node src/index.js",
|
|
22
|
-
"test": "node --test test/*.test.js"
|
|
22
|
+
"test": "node --test test/*.test.js",
|
|
23
|
+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-exclude='test/**' --test-coverage-exclude='src/index.js' --test-coverage-exclude='src/ui.js' --test-coverage-exclude='src/animated-select.js' --test-coverage-exclude='src/gradient.js' --test-coverage-exclude='src/auth.js' --test-coverage-exclude='src/scheduler.js' --test-coverage-exclude='src/parser.js' --test-coverage-lines=80 --test-coverage-functions=80 test/*.test.js"
|
|
23
24
|
},
|
|
24
25
|
"dependencies": {
|
|
25
26
|
"@inquirer/prompts": "^8.5.0",
|
package/src/auth.js
CHANGED
|
@@ -93,17 +93,6 @@ export function clearLastSyncDate() {
|
|
|
93
93
|
saveConfig(rest);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/** 업데이트 캐시(npm 최신버전·마지막 조회시각)를 반환한다. 없으면 각각 null. */
|
|
97
|
-
export function getUpdateCache() {
|
|
98
|
-
const cfg = loadConfig();
|
|
99
|
-
return { latestVersion: cfg.latestVersion ?? null, lastUpdateCheck: cfg.lastUpdateCheck ?? null };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** npm 최신버전과 조회시각(ISO)을 다른 설정을 보존한 채 갱신 저장한다. */
|
|
103
|
-
export function saveUpdateCache(latestVersion, lastUpdateCheck) {
|
|
104
|
-
saveConfig({ ...loadConfig(), latestVersion, lastUpdateCheck });
|
|
105
|
-
}
|
|
106
|
-
|
|
107
96
|
// Gist 소유권 검증용 파일명 — 사용자가 이 이름으로 Gist를 만들어야 한다.
|
|
108
97
|
export const VERIFICATION_FILE = 'tokenphage.txt';
|
|
109
98
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-sync 등록 정합성 복구.
|
|
3
|
+
* 설정(config.json)은 켜짐인데 OS 스케줄러에서 예약이 사라진 경우를 감지해 다시 등록한다.
|
|
4
|
+
*
|
|
5
|
+
* index.js가 아니라 별 모듈인 이유: index.js는 최상단에서 program.parse()를 실행하므로
|
|
6
|
+
* 테스트가 import하는 순간 CLI가 시동된다. lib/가 아닌 이유는 ui.js에 의존해
|
|
7
|
+
* 순수 로직 계층 규약에 맞지 않기 때문이다.
|
|
8
|
+
*/
|
|
9
|
+
import { loadConfig, setHookInstalled } from './auth.js';
|
|
10
|
+
import { installHook, probeHookRegistration } from './scheduler.js';
|
|
11
|
+
import { showError, pauseForEnter } from './ui.js';
|
|
12
|
+
|
|
13
|
+
const ISSUE_HINT =
|
|
14
|
+
'Log: ~/.tokenphage/logs/sync.log — please report it: https://github.com/TOKENPHAGE/tokenphage-cli/issues';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 설정은 auto-sync 켜짐인데 OS 스케줄러에 등록이 없으면 재등록한다.
|
|
18
|
+
*
|
|
19
|
+
* 헤드리스에서는 절대 재등록하지 않는다 — installMac()이 첫 단계로
|
|
20
|
+
* launchctlBootout()을 호출하는데, 그 프로세스가 바로 해당 launchd job이라 자기 자신을 내려
|
|
21
|
+
* SIGTERM으로 죽고 plist 작성에 도달하지 못한다. 그래서 호출 위치에만 의존하지 않고
|
|
22
|
+
* 함수 안에서 isTTY를 직접 확인해 구조적으로 막는다.
|
|
23
|
+
*
|
|
24
|
+
* 의존성은 전부 기본값으로 실제 구현이 배선돼 있다 — 호출부는 인자 없이 부르고 테스트만 대체한다.
|
|
25
|
+
* @param {{interactive?: boolean, readConfig?: Function, probe?: Function, install?: Function,
|
|
26
|
+
* persist?: Function, onError?: Function, onPause?: Function}} [deps]
|
|
27
|
+
* @returns {Promise<boolean>} 등록을 초록으로 단정할 수 없으면 true (확인 불가 또는 재등록 실패)
|
|
28
|
+
*/
|
|
29
|
+
export async function reconcileHookRegistration({
|
|
30
|
+
interactive = Boolean(process.stdin.isTTY),
|
|
31
|
+
readConfig = loadConfig,
|
|
32
|
+
probe = probeHookRegistration,
|
|
33
|
+
install = installHook,
|
|
34
|
+
persist = setHookInstalled,
|
|
35
|
+
onError = showError,
|
|
36
|
+
onPause = pauseForEnter,
|
|
37
|
+
} = {}) {
|
|
38
|
+
if (!interactive) return false; // 헤드리스: 자기 예약을 건드리지 않는다
|
|
39
|
+
if (readConfig().hookInstalled !== true) return false; // 꺼짐(사용자 의사): 조회조차 하지 않는다
|
|
40
|
+
|
|
41
|
+
const state = probe();
|
|
42
|
+
if (state === 'registered') return false;
|
|
43
|
+
if (state === 'unknown') return true; // 판정 불가에서 재등록하면 정상 예약을 깬다
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
persist(true, await install()); // installHook은 멱등이라 그대로 재사용한다
|
|
47
|
+
return false;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
onError('Auto-sync re-registration failed', err.message, ISSUE_HINT);
|
|
50
|
+
await onPause();
|
|
51
|
+
return true; // 실패했으므로 초록으로 단정하지 않는다
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { Command } from 'commander';
|
|
8
8
|
import { parseAll } from './parser.js';
|
|
9
|
-
import { loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled, getLastSyncDate, saveLastSyncDate, clearLastSyncDate } from './auth.js';
|
|
9
|
+
import { loadConfig, loadToken, saveToken, getOrCreateDeviceId, gistLogin, setHookInstalled, getLastSyncDate, saveLastSyncDate, clearLastSyncDate } from './auth.js';
|
|
10
10
|
import { syncRecords, resetData } from './sync.js';
|
|
11
11
|
import { installHook, uninstallHook } from './scheduler.js';
|
|
12
|
+
import { reconcileHookRegistration } from './hook-reconcile.js';
|
|
12
13
|
import { promptFirstRun, promptDashboard, promptAdvanced, confirmReset, pauseForEnter, showError } from './ui.js';
|
|
13
14
|
import { accent } from './gradient.js';
|
|
14
15
|
import { localDateOf } from './lib/dates.js';
|
|
15
16
|
import { claudeDirCandidates } from './parser.js';
|
|
16
17
|
import { checkClaudeRetention, applyRetentionSetting, DEFAULT_CLEANUP_DAYS } from './lib/retention.js';
|
|
17
|
-
import {
|
|
18
|
+
import { checkLatestVersion, getUpdateInfo } from './lib/update-check.js';
|
|
18
19
|
import { runUpdate } from './lib/updater.js';
|
|
19
20
|
import { confirm } from '@inquirer/prompts';
|
|
20
21
|
import chalk from 'chalk';
|
|
@@ -136,7 +137,7 @@ async function runSync() {
|
|
|
136
137
|
const lastSyncDate = getLastSyncDate();
|
|
137
138
|
const fromDate = lastSyncDate ?? null;
|
|
138
139
|
|
|
139
|
-
console.log(`Parsing local
|
|
140
|
+
console.log(`Parsing local logs... (${fromDate ?? 'beginning'} ~ ${today})`);
|
|
140
141
|
const records = await parseAll(fromDate);
|
|
141
142
|
console.log(`${records.length} records aggregated (date×model)`);
|
|
142
143
|
if (records.length === 0) {
|
|
@@ -149,10 +150,6 @@ async function runSync() {
|
|
|
149
150
|
await syncRecords(API_BASE, token, deviceId, records);
|
|
150
151
|
saveLastSyncDate(today);
|
|
151
152
|
console.log('Done! Badge will update shortly.');
|
|
152
|
-
|
|
153
|
-
// 이미 네트워크를 쓰는 지점이므로 여기서 npm 최신버전 캐시를 갱신한다(TTL 게이트 내장, 실패 무해).
|
|
154
|
-
// 04:00 자동 sync가 이 경로로 캐시를 채워, 다음 대시보드 진입 시 업데이트 배너가 뜬다.
|
|
155
|
-
await refreshCache(Date.now());
|
|
156
153
|
}
|
|
157
154
|
|
|
158
155
|
/**
|
|
@@ -243,14 +240,14 @@ program.action(async () => {
|
|
|
243
240
|
}
|
|
244
241
|
}
|
|
245
242
|
|
|
246
|
-
//
|
|
247
|
-
const
|
|
248
|
-
//
|
|
249
|
-
|
|
243
|
+
// TUI 진입 시 npm 최신버전을 1회 조회(짧은 타임아웃). 세션 동안 재사용해 배너/메뉴에 반영한다.
|
|
244
|
+
const latest = await checkLatestVersion();
|
|
245
|
+
// 대시보드 진입 전 1회: 설정만 켜져 있고 OS 스케줄러에서 사라졌으면 여기서 되살린다.
|
|
246
|
+
const hookUnverified = await reconcileHookRegistration();
|
|
250
247
|
// 대시보드 루프: 액션 수행 후 메인으로 복귀, Exit/Esc 에서만 종료
|
|
251
248
|
while (true) {
|
|
252
249
|
const cfg = loadConfig();
|
|
253
|
-
const action = await promptDashboard(cfg);
|
|
250
|
+
const action = await promptDashboard(cfg, latest, hookUnverified);
|
|
254
251
|
if (action === 'exit') break;
|
|
255
252
|
if (action === 'sync') await runSync();
|
|
256
253
|
else if (action === 'install-hook') await runInstallHook();
|
|
@@ -258,7 +255,7 @@ program.action(async () => {
|
|
|
258
255
|
else if (action === 'advanced') await runAdvanced(API_BASE, cfg);
|
|
259
256
|
else if (action === 'update') {
|
|
260
257
|
// 업데이트 성공 시 현재 프로세스는 여전히 구버전 코드 → 재실행 안내 후 종료.
|
|
261
|
-
const { hasUpdate
|
|
258
|
+
const { hasUpdate } = getUpdateInfo(version, latest);
|
|
262
259
|
if (hasUpdate) {
|
|
263
260
|
const result = await runUpdate(latest);
|
|
264
261
|
await pauseForEnter();
|
|
@@ -297,9 +294,9 @@ program.command('login [username]')
|
|
|
297
294
|
}
|
|
298
295
|
});
|
|
299
296
|
|
|
300
|
-
// sync: 로컬 Claude/Codex 로그를 파싱해 서버로 업로드한다(배지 갱신). 미인증이면 안내 후 종료.
|
|
297
|
+
// sync: 로컬 Claude Code/Codex/opencode 로그를 파싱해 서버로 업로드한다(배지 갱신). 미인증이면 안내 후 종료.
|
|
301
298
|
program.command('sync')
|
|
302
|
-
.description('Sync local Claude Code
|
|
299
|
+
.description('Sync local token usage (Claude Code, Codex, opencode) to the server')
|
|
303
300
|
.action(async () => {
|
|
304
301
|
try { await runSync(); }
|
|
305
302
|
catch (err) { showError('Sync failed', err.message); process.exitCode = 1; }
|
package/src/lib/config-schema.js
CHANGED
|
@@ -84,9 +84,7 @@ export function normalizeConfig(raw, today) {
|
|
|
84
84
|
dropIf('deviceId', isUuid); // 없으면 getOrCreateDeviceId가 재발급
|
|
85
85
|
dropIf('lastSyncDate', (v) => isYmdDate(v, today)); // 없으면 전체 재파싱
|
|
86
86
|
dropIf('hookInstalledAt', isIsoDate);
|
|
87
|
-
dropIf('lastUpdateCheck', isIsoDate);
|
|
88
87
|
dropIf('hookInstalled', (v) => typeof v === 'boolean');
|
|
89
|
-
dropIf('latestVersion', (v) => typeof v === 'string');
|
|
90
88
|
dropIf('claudePaths', isStringArray);
|
|
91
89
|
dropIf('hookMeta', (v) => v != null && typeof v === 'object' && !Array.isArray(v));
|
|
92
90
|
|
package/src/lib/paths.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { join, resolve } from 'path';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// AI 도구별 로컬 디렉터리 후보를 우선순위 순으로 만든다.
|
|
4
|
+
// - Claude: 설정(config) 디렉터리. 기본(~/.claude) → XDG($XDG_CONFIG_HOME/claude) → 사용자 지정.
|
|
5
|
+
// - opencode: 데이터(data) 디렉터리. XDG($XDG_DATA_HOME, 기본 ~/.local/share)/opencode → 사용자 지정.
|
|
6
|
+
// (opencode는 세션을 <data>/opencode/storage/... 에 저장하므로 config가 아니라 data home 기준이다.)
|
|
5
7
|
|
|
6
8
|
function expandTilde(p, home) {
|
|
7
9
|
if (p === '~') return home;
|
|
@@ -9,8 +11,30 @@ function expandTilde(p, home) {
|
|
|
9
11
|
return p;
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
// 후보 경로들을 절대경로로 normalize하고 순서를 유지한 채 중복을 제거한다.
|
|
15
|
+
function normalizeUnique(candidates) {
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
const dirs = [];
|
|
18
|
+
for (const candidate of candidates) {
|
|
19
|
+
const normalized = resolve(candidate);
|
|
20
|
+
if (seen.has(normalized)) continue;
|
|
21
|
+
seen.add(normalized);
|
|
22
|
+
dirs.push(normalized);
|
|
23
|
+
}
|
|
24
|
+
return dirs;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 외부 입력(config.json 등)의 customPaths를 검증한다: 문자열 배열만 인정, 틸드는 home으로 확장.
|
|
28
|
+
function cleanCustomPaths(customPaths, home) {
|
|
29
|
+
if (!Array.isArray(customPaths)) return [];
|
|
30
|
+
return customPaths
|
|
31
|
+
.filter((p) => typeof p === 'string' && p.trim())
|
|
32
|
+
.map((p) => expandTilde(p.trim(), home));
|
|
33
|
+
}
|
|
34
|
+
|
|
12
35
|
/**
|
|
13
36
|
* Claude 설정 디렉터리 후보 목록을 우선순위 순으로 반환한다.
|
|
37
|
+
* 순서: 기본(~/.claude) → XDG($XDG_CONFIG_HOME/claude, 기본 ~/.config/claude) → 사용자 지정.
|
|
14
38
|
* @param {{ home: string, xdgConfigHome?: string, customPaths?: string[] }} opts
|
|
15
39
|
* home: 사용자 홈, xdgConfigHome: $XDG_CONFIG_HOME, customPaths: config.json의 claudePaths
|
|
16
40
|
* @returns {string[]} normalize·중복 제거된 절대 경로 목록
|
|
@@ -20,24 +44,28 @@ export function resolveClaudeDirs({ home, xdgConfigHome, customPaths } = {}) {
|
|
|
20
44
|
? xdgConfigHome
|
|
21
45
|
: join(home, '.config');
|
|
22
46
|
|
|
23
|
-
|
|
24
|
-
const custom = Array.isArray(customPaths)
|
|
25
|
-
? customPaths.filter((p) => typeof p === 'string' && p.trim())
|
|
26
|
-
: [];
|
|
27
|
-
|
|
28
|
-
const candidates = [
|
|
47
|
+
return normalizeUnique([
|
|
29
48
|
join(home, '.claude'),
|
|
30
49
|
join(xdgBase, 'claude'),
|
|
31
|
-
...
|
|
32
|
-
];
|
|
50
|
+
...cleanCustomPaths(customPaths, home),
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
33
53
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
54
|
+
/**
|
|
55
|
+
* opencode 데이터 디렉터리 후보 목록을 우선순위 순으로 반환한다.
|
|
56
|
+
* 순서: XDG($XDG_DATA_HOME/opencode, 기본 ~/.local/share/opencode) → 사용자 지정.
|
|
57
|
+
* Claude(config home 기준)와 달리 data home 기준임에 주의.
|
|
58
|
+
* @param {{ home: string, xdgDataHome?: string, customPaths?: string[] }} opts
|
|
59
|
+
* home: 사용자 홈, xdgDataHome: $XDG_DATA_HOME, customPaths: 사용자 지정 opencode 디렉터리
|
|
60
|
+
* @returns {string[]} normalize·중복 제거된 절대 경로 목록
|
|
61
|
+
*/
|
|
62
|
+
export function resolveOpencodeDirs({ home, xdgDataHome, customPaths } = {}) {
|
|
63
|
+
const xdgBase = typeof xdgDataHome === 'string' && xdgDataHome.trim()
|
|
64
|
+
? xdgDataHome
|
|
65
|
+
: join(home, '.local', 'share');
|
|
66
|
+
|
|
67
|
+
return normalizeUnique([
|
|
68
|
+
join(xdgBase, 'opencode'),
|
|
69
|
+
...cleanCustomPaths(customPaths, home),
|
|
70
|
+
]);
|
|
43
71
|
}
|
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/parser.js
CHANGED
|
@@ -2,7 +2,8 @@ import { glob } from 'glob';
|
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { parseClaudeFiles } from './parsers/claude.js';
|
|
4
4
|
import { parseCodexFiles } from './parsers/codex.js';
|
|
5
|
-
import {
|
|
5
|
+
import { parseOpencodeFiles } from './parsers/opencode.js';
|
|
6
|
+
import { resolveClaudeDirs, resolveOpencodeDirs } from './lib/paths.js';
|
|
6
7
|
import { getCustomClaudePaths } from './auth.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -41,6 +42,14 @@ export function claudeDirCandidates() {
|
|
|
41
42
|
});
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/** opencode 데이터 디렉터리 후보(XDG data home 기준)를 우선순위 순으로 반환한다. */
|
|
46
|
+
export function opencodeDirCandidates() {
|
|
47
|
+
return resolveOpencodeDirs({
|
|
48
|
+
home: homedir(),
|
|
49
|
+
xdgDataHome: process.env.XDG_DATA_HOME,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
export async function parseAll(fromDate = null) {
|
|
45
54
|
// 경로가 겹쳐도 파일 간 first-wins dedup이 흡수하므로 순서만 보장하면 된다.
|
|
46
55
|
const claudeFiles = [];
|
|
@@ -55,9 +64,19 @@ export async function parseAll(fromDate = null) {
|
|
|
55
64
|
...(await globSorted(`${codexHome}/archived_sessions/**/*.jsonl`)),
|
|
56
65
|
];
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
// opencode는 message(모델·시각)와 part(step-finish 토큰)를 모두 읽어야 조인할 수 있다.
|
|
68
|
+
// parseOpencodeFiles가 내용으로 분류하므로 두 목록을 합쳐 넘긴다(순서 무관).
|
|
69
|
+
const opencodeFiles = [];
|
|
70
|
+
for (const dir of opencodeDirCandidates()) {
|
|
71
|
+
const base = dir.replaceAll('\\', '/');
|
|
72
|
+
opencodeFiles.push(...await globSorted(`${base}/storage/message/**/*.json`));
|
|
73
|
+
opencodeFiles.push(...await globSorted(`${base}/storage/part/**/*.json`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const [claudeEntries, codexEntries, opencodeEntries] = await Promise.all([
|
|
59
77
|
parseClaudeFiles(claudeFiles),
|
|
60
78
|
parseCodexFiles(codexFiles),
|
|
79
|
+
parseOpencodeFiles(opencodeFiles),
|
|
61
80
|
]);
|
|
62
|
-
return aggregateRecords([...claudeEntries, ...codexEntries], fromDate);
|
|
81
|
+
return aggregateRecords([...claudeEntries, ...codexEntries, ...opencodeEntries], fromDate);
|
|
63
82
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { localDateOf } from '../lib/dates.js';
|
|
3
|
+
|
|
4
|
+
// opencode(sst) 세션 저장 파서.
|
|
5
|
+
// opencode는 턴당 assistant 메시지 1개에 스텝마다 tokens를 덮어써(=) 마지막 스텝만 남기므로,
|
|
6
|
+
// 스텝별 토큰이 보존된 step-finish part(storage/part/<messageID>/*.json)를 전부 합산해야 정확하다.
|
|
7
|
+
// 전략:
|
|
8
|
+
// - message(role==='assistant')에서 id→{model, date}를 모으고, step-finish part를 messageID로 조인한다.
|
|
9
|
+
// - 입력 파일 목록에 message/part가 섞여 오고 순서 보장이 없으므로, 두 맵을 모은 뒤 마지막에 조인한다.
|
|
10
|
+
// - 필드 매핑(claude/codex와 정합): opencode getUsage가 input은 캐시 제외로, output은 reasoning 제외로
|
|
11
|
+
// 저장하므로 → inputTok은 그대로, outputTok은 output+reasoning으로 reasoning을 되살린다.
|
|
12
|
+
// cache.write는 cacheCreate로 둔다(opencode는 Anthropic cacheCreation을 여기 담는다).
|
|
13
|
+
// - 손상 파일·부모 없는 part·모델/날짜 없는 메시지는 집계에서 제외한다.
|
|
14
|
+
|
|
15
|
+
// step-finish part의 tokens를 tokenphage 레코드 4필드로 매핑한다. 음수 방어로 max(…,0).
|
|
16
|
+
function entryOf(date, model, tokens) {
|
|
17
|
+
const cache = tokens.cache ?? {};
|
|
18
|
+
return {
|
|
19
|
+
date,
|
|
20
|
+
model,
|
|
21
|
+
inputTok: Math.max(tokens.input ?? 0, 0),
|
|
22
|
+
// opencode는 output에서 reasoning을 미리 뺐으므로 되살려 청구 기준(claude/codex)과 맞춘다.
|
|
23
|
+
outputTok: Math.max(tokens.output ?? 0, 0) + Math.max(tokens.reasoning ?? 0, 0),
|
|
24
|
+
cacheReadTok: Math.max(cache.read ?? 0, 0),
|
|
25
|
+
cacheCreateTok: Math.max(cache.write ?? 0, 0),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* opencode message/part 파일들을 스텝(step-finish) 단위 usage 목록으로 파싱한다.
|
|
31
|
+
* @param {string[]} files 메시지·파트 JSON 파일 경로 목록 (섞여 있어도 됨)
|
|
32
|
+
* @returns {Promise<Array<{date: string, model: string, inputTok: number, outputTok: number, cacheReadTok: number, cacheCreateTok: number}>>}
|
|
33
|
+
*/
|
|
34
|
+
export async function parseOpencodeFiles(files) {
|
|
35
|
+
const messageMeta = new Map(); // messageID → { model, date }
|
|
36
|
+
const stepParts = []; // { messageID, tokens }
|
|
37
|
+
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
let obj;
|
|
40
|
+
try {
|
|
41
|
+
obj = JSON.parse(await readFile(file, 'utf8'));
|
|
42
|
+
} catch { continue; } // 읽기 실패·손상 JSON skip
|
|
43
|
+
if (!obj) continue;
|
|
44
|
+
|
|
45
|
+
if (obj.role === 'assistant') {
|
|
46
|
+
// 모델·날짜를 못 만들면 이 메시지의 파트는 귀속 불가 → 맵에 넣지 않는다.
|
|
47
|
+
if (!obj.id || !obj.modelID) continue;
|
|
48
|
+
const date = localDateOf(obj.time?.created);
|
|
49
|
+
if (!date) continue;
|
|
50
|
+
messageMeta.set(obj.id, { model: obj.modelID, date });
|
|
51
|
+
} else if (obj.type === 'step-finish') {
|
|
52
|
+
if (!obj.messageID || !obj.tokens) continue;
|
|
53
|
+
stepParts.push({ messageID: obj.messageID, tokens: obj.tokens });
|
|
54
|
+
}
|
|
55
|
+
// 그 외(user 메시지, 다른 part, session/project 파일)는 무시
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const entries = [];
|
|
59
|
+
for (const part of stepParts) {
|
|
60
|
+
const meta = messageMeta.get(part.messageID);
|
|
61
|
+
if (!meta) continue; // 부모 메시지 없음/모델·날짜 무효 → skip
|
|
62
|
+
entries.push(entryOf(meta.date, meta.model, part.tokens));
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
package/src/scheduler.js
CHANGED
|
@@ -493,10 +493,11 @@ function hasCrontab() {
|
|
|
493
493
|
// 현재 사용자 crontab 전체를 반환한다. '확정된 빈 crontab'만 ''로 취급하고,
|
|
494
494
|
// 그 외 읽기 실패는 fail-closed로 throw한다 — 이 값이 crontab 전체 재기록의 base가
|
|
495
495
|
// 되므로, 애매한 실패를 ''로 흡수하면 사용자의 기존 항목을 통째로 덮어쓸 수 있다.
|
|
496
|
-
|
|
496
|
+
// run/timeout은 조회 경로와 테스트를 위한 주입 지점이다 — 기본값이 실제 동작이라 설치 경로는 그대로다.
|
|
497
|
+
function readCrontab(run = execFileSync, timeout = undefined) {
|
|
497
498
|
try {
|
|
498
499
|
// maxBuffer 상향: 대형(정상) crontab이 기본 1MB를 넘겨 ENOBUFS로 오인 실패하는 것을 막는다.
|
|
499
|
-
return
|
|
500
|
+
return run('crontab', ['-l'], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout });
|
|
500
501
|
} catch (err) {
|
|
501
502
|
// 빈 crontab: exit≠0 + stderr 'no crontab for <user>' → 이때만 빈 문자열(잃을 항목 없음).
|
|
502
503
|
const stderr = (err.stderr?.toString() || '').toLowerCase();
|
|
@@ -588,6 +589,68 @@ function uninstallLinux() {
|
|
|
588
589
|
return { platform: 'linux', removed };
|
|
589
590
|
}
|
|
590
591
|
|
|
592
|
+
// ─── 등록 상태 조회 ───────────────────────────────────────────────────────────
|
|
593
|
+
|
|
594
|
+
// 조회가 멈추면 CLI 진입이 그대로 블로킹된다 (update-check.js의 FETCH_TIMEOUT_MS와 같은 이유).
|
|
595
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* launchctl 실패를 등록 상태로 분류한다. 113(서비스 없음)만 확정적 미등록이며,
|
|
599
|
+
* 112(도메인 없음 — SSH 등 비 GUI 세션)·ENOENT(명령 부재)·timeout(SIGTERM)은 판정 불가다.
|
|
600
|
+
* 판정 불가를 미등록으로 오해하면 살아있는 예약에 재등록을 걸어 bootout으로 내려버린다.
|
|
601
|
+
* @returns {'missing'|'unknown'}
|
|
602
|
+
*/
|
|
603
|
+
export function classifyLaunchctlProbe(err) {
|
|
604
|
+
return err?.status === 113 ? 'missing' : 'unknown';
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 설치가 bootstrap gui/<uid>이므로 조회도 같은 도메인을 명시한다.
|
|
608
|
+
// launchctl list는 호출자의 암묵 도메인만 조회해 비 GUI 세션에서 오탐한다.
|
|
609
|
+
function probeMac(run) {
|
|
610
|
+
const uid = userInfo().uid;
|
|
611
|
+
try {
|
|
612
|
+
run('launchctl', ['print', `gui/${uid}/${TASK_LABEL}`],
|
|
613
|
+
{ stdio: 'ignore', timeout: PROBE_TIMEOUT_MS });
|
|
614
|
+
return 'registered';
|
|
615
|
+
} catch (err) {
|
|
616
|
+
return classifyLaunchctlProbe(err);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// 종료코드만 판정 근거로 쓴다 — 출력 메시지는 현지화·OEM 코드페이지 탓에 파싱할 수 없다.
|
|
621
|
+
function probeWindows(run) {
|
|
622
|
+
try {
|
|
623
|
+
run('schtasks', ['/Query', '/TN', WIN_TASK_NAME],
|
|
624
|
+
{ stdio: 'ignore', timeout: PROBE_TIMEOUT_MS, windowsHide: true });
|
|
625
|
+
return 'registered';
|
|
626
|
+
} catch (err) {
|
|
627
|
+
return err.status === 1 ? 'missing' : 'unknown';
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// readCrontab()을 재사용하고 그 함수의 fail-closed throw만 판정 불가로 흡수한다
|
|
632
|
+
// 조회는 CLI 시작을 블로킹하므로 설치 경로와 달리 타임아웃을 반드시 건넨다.
|
|
633
|
+
function probeLinux(run) {
|
|
634
|
+
try {
|
|
635
|
+
return readCrontab(run, PROBE_TIMEOUT_MS).includes(CRON_BEGIN) ? 'registered' : 'missing';
|
|
636
|
+
} catch {
|
|
637
|
+
return 'unknown';
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* 자동 sync 스케줄이 OS 스케줄러에 실제로 등록돼 있는지 조회한다(읽기 전용).
|
|
643
|
+
* 라벨·작업명은 완전 일치로만 찾는다 — 같은 홈에 다른 dev.tokenphage.* 작업이 있을 수 있다.
|
|
644
|
+
* @param {{ platform?: string, run?: Function }} [deps]
|
|
645
|
+
* @returns {'registered'|'missing'|'unknown'}
|
|
646
|
+
*/
|
|
647
|
+
export function probeHookRegistration({ platform = process.platform, run = execFileSync } = {}) {
|
|
648
|
+
if (platform === 'darwin') return probeMac(run);
|
|
649
|
+
if (platform === 'win32') return probeWindows(run);
|
|
650
|
+
if (platform === 'linux') return probeLinux(run);
|
|
651
|
+
return 'unknown'; // 미지원 OS는 throw 대신 판정 불가 — 조회 실패로 CLI가 죽지 않게 한다.
|
|
652
|
+
}
|
|
653
|
+
|
|
591
654
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
592
655
|
|
|
593
656
|
/** 현재 OS에 맞는 자동 sync 스케줄을 설치한다 (macOS=launchd / Windows=schtasks / Linux=cron). 그 외 OS는 throw. */
|
package/src/ui.js
CHANGED
|
@@ -127,14 +127,16 @@ 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)
|
|
132
|
+
* @param {boolean} [hookUnverified] OS 스케줄러 등록 여부를 확인할 수 없었으면 true
|
|
131
133
|
* @returns {'update' | 'sync' | 'install-hook' | 'uninstall-hook' | 'advanced' | 'exit'}
|
|
132
134
|
*/
|
|
133
|
-
export async function promptDashboard(cfg) {
|
|
135
|
+
export async function promptDashboard(cfg, latest, hookUnverified = false) {
|
|
134
136
|
printWelcome();
|
|
135
137
|
|
|
136
|
-
//
|
|
137
|
-
const update = getUpdateInfo(version,
|
|
138
|
+
// TUI 진입 시 조회한 npm 최신버전과 현재 버전을 비교해, 다를 때만 민트 배너를 출력한다.
|
|
139
|
+
const update = getUpdateInfo(version, latest);
|
|
138
140
|
if (update.hasUpdate) {
|
|
139
141
|
const bar = accent(' ┃ ');
|
|
140
142
|
console.log(bar + accent(`⬆ Update available v${update.current} → v${update.latest}`));
|
|
@@ -144,9 +146,14 @@ export async function promptDashboard(cfg) {
|
|
|
144
146
|
|
|
145
147
|
const username = decodeJwtUsername(cfg.token) ?? '(unknown)';
|
|
146
148
|
const lastSync = cfg.lastSyncDate ?? 'never';
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
// 설정 상태를 먼저 보고, 켜진 경우에만 검증 결과를 반영한다
|
|
150
|
+
// (순서를 뒤집으면 방금 auto-sync를 끈 사용자에게도 "enabled"가 남는다).
|
|
151
|
+
// 등록을 확인하지 못했거나 재등록이 실패했으면 초록으로 단정하지 않고 노랑으로 유보한다.
|
|
152
|
+
const hookStatus = !cfg.hookInstalled
|
|
153
|
+
? chalk.yellow(' Auto-sync not configured')
|
|
154
|
+
: hookUnverified
|
|
155
|
+
? chalk.yellow(' Auto-sync enabled') + chalk.gray(' (could not verify — see message above)')
|
|
156
|
+
: chalk.green(' Auto-sync enabled') + chalk.gray(` (${cfg.hookMeta?.scheduleSpec ?? 'daily 04:00'})`);
|
|
150
157
|
|
|
151
158
|
console.log(chalk.green(' Authenticated as ') + chalk.bold(`@${username}`));
|
|
152
159
|
console.log(chalk.gray(` Last sync: ${lastSync}`));
|