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/scheduler.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync, unlinkSync, realpathSync, existsSync } from 'fs';
|
|
3
|
+
import { homedir, tmpdir, userInfo } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
export const TASK_LABEL = 'dev.tokenphage.sync';
|
|
7
|
+
const WIN_TASK_NAME = 'tokenphage-sync';
|
|
8
|
+
const SCHEDULE_HOUR = 4;
|
|
9
|
+
const SCHEDULE_MINUTE = 0;
|
|
10
|
+
|
|
11
|
+
/** ~/.tokenphage/logs 디렉터리를 보장하고 sync.log의 절대경로를 반환한다. */
|
|
12
|
+
export function ensureLogDir() {
|
|
13
|
+
const dir = join(homedir(), '.tokenphage', 'logs');
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
return join(dir, 'sync.log');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 스케줄러가 실행할 node 바이너리와 이 CLI 스크립트의 절대경로를 반환한다(심링크는 realpath로 해소).
|
|
20
|
+
* 예) 전역 설치(npm i -g) 상태:
|
|
21
|
+
* node = /Users/me/.nvm/versions/node/v22.17.1/bin/node (process.execPath)
|
|
22
|
+
* argv[1] = /usr/local/bin/tokenphage (← bin 심링크)
|
|
23
|
+
* script = /usr/local/lib/node_modules/tokenphage/src/index.js (realpath로 해소된 실제 파일)
|
|
24
|
+
* 로컬 개발(node src/index.js) 시엔 argv[1]이 이미 실파일이라 script도 그대로 .../src/index.js.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveTokenphageBinary() {
|
|
27
|
+
const node = process.execPath;
|
|
28
|
+
const script = realpathSync(process.argv[1]);
|
|
29
|
+
return { node, script };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// XML 특수문자(& < > " ')를 엔티티로 치환한다 (plist·Task XML 삽입용).
|
|
33
|
+
function escapeXml(s) {
|
|
34
|
+
return String(s)
|
|
35
|
+
.replace(/&/g, '&')
|
|
36
|
+
.replace(/</g, '<')
|
|
37
|
+
.replace(/>/g, '>')
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/'/g, ''');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// POSIX 셸 single-quote escaping: 경로에 공백·특수문자가 있어도 안전하게 인용한다.
|
|
43
|
+
function shSingleQuote(s) {
|
|
44
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// PowerShell single-quote literal escaping: 내부 작은따옴표(')만 ''로 이중화하면 된다.
|
|
48
|
+
// cmd 파싱을 거치지 않으므로 공백·% ! ^ & 등 메타문자 걱정이 없다.
|
|
49
|
+
function psSingleQuote(s) {
|
|
50
|
+
return `'${String(s).replace(/'/g, `''`)}'`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── macOS (launchd) ────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
// launchd 에이전트 plist 파일 경로 (~/Library/LaunchAgents).
|
|
56
|
+
function plistPath() {
|
|
57
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${TASK_LABEL}.plist`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// macOS용 node 탐색 래퍼 셸 스크립트 경로 (~/.tokenphage/run-sync.sh).
|
|
61
|
+
function syncScriptPathMac() {
|
|
62
|
+
return join(homedir(), '.tokenphage', 'run-sync.sh');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// launchd가 직접 node 절대경로를 들고 있으면 nvm/fnm 등 버전 매니저가
|
|
66
|
+
// 경로를 바꾸는 순간 스케줄이 조용히 깨진다. 대신 실행 시점에 node를
|
|
67
|
+
// 동적으로 탐색하는 래퍼를 거치게 해, 버전이 바뀌어도 알아서 따라가게 한다.
|
|
68
|
+
// 특정 사용자·버전 경로를 plist에 하드코딩하지 않는다.
|
|
69
|
+
function buildSyncShellScript(script) {
|
|
70
|
+
return `#!/bin/sh
|
|
71
|
+
# tokenphage auto-sync launcher — generated by tokenphage-cli.
|
|
72
|
+
# node 바이너리를 실행 시점에 탐색한다 (nvm 등 버전 매니저 변경에도 견고).
|
|
73
|
+
|
|
74
|
+
# ls/sort/tail/command 등 coreutils가 항상 잡히도록 표준 PATH를 보장한다.
|
|
75
|
+
export PATH="/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:$PATH"
|
|
76
|
+
|
|
77
|
+
SCRIPT=${shSingleQuote(script)}
|
|
78
|
+
|
|
79
|
+
resolve_node() {
|
|
80
|
+
# 1) PATH 상의 node (Homebrew / 시스템 / launchd PATH)
|
|
81
|
+
if command -v node >/dev/null 2>&1; then command -v node; return 0; fi
|
|
82
|
+
# 2) nvm: 설치된 버전 중 가장 최신
|
|
83
|
+
nvm_root="$HOME/.nvm/versions/node"
|
|
84
|
+
if [ -d "$nvm_root" ]; then
|
|
85
|
+
newest=$(ls -1 "$nvm_root" 2>/dev/null | sort -V | tail -1)
|
|
86
|
+
if [ -n "$newest" ] && [ -x "$nvm_root/$newest/bin/node" ]; then
|
|
87
|
+
echo "$nvm_root/$newest/bin/node"; return 0
|
|
88
|
+
fi
|
|
89
|
+
fi
|
|
90
|
+
return 1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
NODE=$(resolve_node)
|
|
94
|
+
if [ -z "$NODE" ]; then
|
|
95
|
+
echo "tokenphage: node 바이너리를 찾지 못했습니다 (PATH 및 ~/.nvm 확인)" >&2
|
|
96
|
+
exit 127
|
|
97
|
+
fi
|
|
98
|
+
exec "$NODE" "$SCRIPT" sync
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// launchd plist 문자열을 만든다. 매일 정해진 시각에 /bin/sh로 래퍼를 실행하고 stdout/stderr를 로그로 남기게 정의한다.
|
|
103
|
+
function buildPlist(wrapperPath, logPath) {
|
|
104
|
+
const home = homedir();
|
|
105
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
106
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">
|
|
107
|
+
<plist version="1.0">
|
|
108
|
+
<dict>
|
|
109
|
+
<key>Label</key>
|
|
110
|
+
<string>${escapeXml(TASK_LABEL)}</string>
|
|
111
|
+
<key>ProgramArguments</key>
|
|
112
|
+
<array>
|
|
113
|
+
<string>/bin/sh</string>
|
|
114
|
+
<string>${escapeXml(wrapperPath)}</string>
|
|
115
|
+
</array>
|
|
116
|
+
<key>StartCalendarInterval</key>
|
|
117
|
+
<dict>
|
|
118
|
+
<key>Hour</key><integer>${SCHEDULE_HOUR}</integer>
|
|
119
|
+
<key>Minute</key><integer>${SCHEDULE_MINUTE}</integer>
|
|
120
|
+
</dict>
|
|
121
|
+
<key>RunAtLoad</key>
|
|
122
|
+
<false/>
|
|
123
|
+
<key>StandardOutPath</key>
|
|
124
|
+
<string>${escapeXml(logPath)}</string>
|
|
125
|
+
<key>StandardErrorPath</key>
|
|
126
|
+
<string>${escapeXml(logPath)}</string>
|
|
127
|
+
<key>EnvironmentVariables</key>
|
|
128
|
+
<dict>
|
|
129
|
+
<key>HOME</key>
|
|
130
|
+
<string>${escapeXml(home)}</string>
|
|
131
|
+
<key>PATH</key>
|
|
132
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
|
133
|
+
</dict>
|
|
134
|
+
</dict>
|
|
135
|
+
</plist>
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 기존 launchd 등록을 해제한다 (bootout 실패 시 구버전 unload로 폴백). 성공 여부를 boolean으로 반환.
|
|
140
|
+
function launchctlBootout() {
|
|
141
|
+
const uid = userInfo().uid;
|
|
142
|
+
const path = plistPath();
|
|
143
|
+
try {
|
|
144
|
+
execFileSync('launchctl', ['bootout', `gui/${uid}/${TASK_LABEL}`], { stdio: 'ignore' });
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
try {
|
|
148
|
+
execFileSync('launchctl', ['unload', path], { stdio: 'ignore' });
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// plist를 launchd에 등록한다 (bootstrap 실패 시 구버전 load로 폴백, 둘 다 실패하면 안내 메시지와 함께 throw).
|
|
157
|
+
function launchctlBootstrap() {
|
|
158
|
+
const uid = userInfo().uid;
|
|
159
|
+
const path = plistPath();
|
|
160
|
+
try {
|
|
161
|
+
execFileSync('launchctl', ['bootstrap', `gui/${uid}`, path], { stdio: 'pipe' });
|
|
162
|
+
} catch (err) {
|
|
163
|
+
try {
|
|
164
|
+
execFileSync('launchctl', ['load', path], { stdio: 'pipe' });
|
|
165
|
+
} catch (err2) {
|
|
166
|
+
const msg = (err2.stderr?.toString() || err.stderr?.toString() || err.message || '').trim();
|
|
167
|
+
throw new Error(`launchctl registration failed: ${msg}\n` +
|
|
168
|
+
'If permission was denied, check System Settings → Privacy & Security → Automation.');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// macOS 설치: node 탐색 래퍼 작성 → plist 작성 → launchd 재등록(멱등). 등록 결과 메타(method/logPath 등)를 반환한다.
|
|
174
|
+
function installMac() {
|
|
175
|
+
const { script } = resolveTokenphageBinary();
|
|
176
|
+
const logPath = ensureLogDir();
|
|
177
|
+
const path = plistPath();
|
|
178
|
+
const wrapper = syncScriptPathMac();
|
|
179
|
+
mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
|
|
180
|
+
mkdirSync(join(homedir(), '.tokenphage'), { recursive: true });
|
|
181
|
+
|
|
182
|
+
// node 탐색 래퍼를 먼저 깔고, plist는 이 래퍼를 가리키게 한다.
|
|
183
|
+
writeFileSync(wrapper, buildSyncShellScript(script), { mode: 0o755 });
|
|
184
|
+
|
|
185
|
+
// idempotent: 기존 등록 해제 → 덮어쓰기 → 재등록
|
|
186
|
+
launchctlBootout();
|
|
187
|
+
writeFileSync(path, buildPlist(wrapper, logPath), { mode: 0o644 });
|
|
188
|
+
launchctlBootstrap();
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
platform: 'darwin',
|
|
192
|
+
method: 'launchd',
|
|
193
|
+
logPath,
|
|
194
|
+
scheduleSpec: `daily ${String(SCHEDULE_HOUR).padStart(2, '0')}:${String(SCHEDULE_MINUTE).padStart(2, '0')}`,
|
|
195
|
+
artifactPath: path,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// macOS 제거: launchd 등록 해제 후 plist·래퍼 파일을 삭제한다(best-effort). removed 여부를 반환.
|
|
200
|
+
function uninstallMac() {
|
|
201
|
+
const path = plistPath();
|
|
202
|
+
const wrapper = syncScriptPathMac();
|
|
203
|
+
const fileExisted = existsSync(path);
|
|
204
|
+
// best-effort 해제 — exit code는 신뢰할 수 없다(거짓 양성).
|
|
205
|
+
launchctlBootout();
|
|
206
|
+
if (fileExisted) unlinkSync(path);
|
|
207
|
+
if (existsSync(wrapper)) {
|
|
208
|
+
try { unlinkSync(wrapper); } catch { /* noop */ }
|
|
209
|
+
}
|
|
210
|
+
return { platform: 'darwin', removed: fileExisted };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ─── Windows (Task Scheduler) ────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
// Windows용 node 탐색 래퍼 PowerShell 스크립트 경로 (~/.tokenphage/run-sync.ps1).
|
|
216
|
+
function ps1Path() {
|
|
217
|
+
return join(homedir(), '.tokenphage', 'run-sync.ps1');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 구버전이 만들던 배치 래퍼 — uninstall 시 함께 정리해 잔여물을 남기지 않는다.
|
|
221
|
+
function legacyBatPath() {
|
|
222
|
+
return join(homedir(), '.tokenphage', 'run-sync.bat');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// schtasks가 작업을 실행할 때의 PATH는 대화형 셸과 달리 제한적이라, 설치 시점
|
|
226
|
+
// node 경로를 박아두면 nvm-windows/fnm/volta로 버전을 바꾸는 순간 조용히 깨진다.
|
|
227
|
+
// 그래서 macOS 래퍼(buildSyncShellScript)와 동일하게, 실행 시점에 node를 동적
|
|
228
|
+
// 탐색하는 PowerShell 래퍼를 거치게 한다. PowerShell은 유니코드에 안전하고(한글
|
|
229
|
+
// 홈 경로 mojibake 방지) cmd 메타문자 파싱 문제도 없다.
|
|
230
|
+
export function buildSyncPs1(script, logPath) {
|
|
231
|
+
const body = `# tokenphage auto-sync launcher — generated by tokenphage-cli.
|
|
232
|
+
# node 바이너리를 실행 시점에 탐색한다 (nvm-windows / fnm / volta 변경에도 견고).
|
|
233
|
+
|
|
234
|
+
$SCRIPT = ${psSingleQuote(script)}
|
|
235
|
+
$LOG = ${psSingleQuote(logPath)}
|
|
236
|
+
|
|
237
|
+
# 주어진 루트 하위 디렉터리를 버전 내림차순으로 훑어 첫 node.exe 경로를 반환한다.
|
|
238
|
+
function Get-NewestNodeIn($root, $relative) {
|
|
239
|
+
if (-not (Test-Path -LiteralPath $root)) { return $null }
|
|
240
|
+
$dirs = Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue |
|
|
241
|
+
Sort-Object -Property @{ Expression = { try { [version]($_.Name.TrimStart('v')) } catch { [version]'0.0.0' } } } -Descending
|
|
242
|
+
foreach ($d in $dirs) {
|
|
243
|
+
$candidate = Join-Path $d.FullName $relative
|
|
244
|
+
if (Test-Path -LiteralPath $candidate) { return $candidate }
|
|
245
|
+
}
|
|
246
|
+
return $null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function Resolve-Node {
|
|
250
|
+
# 1) PATH 상의 node — 가장 권위 있고 대부분의 경우 여기서 끝난다.
|
|
251
|
+
$cmd = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
252
|
+
if ($cmd) { return $cmd.Source }
|
|
253
|
+
|
|
254
|
+
# 2) nvm-windows: 심링크 디렉터리 + 버전 설치 루트.
|
|
255
|
+
$nvmRoots = @($env:NVM_SYMLINK, $env:NVM_HOME, (Join-Path $env:APPDATA 'nvm'), 'C:\\nvm4w', 'C:\\nvm')
|
|
256
|
+
foreach ($r in $nvmRoots) {
|
|
257
|
+
if (-not $r) { continue }
|
|
258
|
+
$direct = Join-Path $r 'node.exe'
|
|
259
|
+
if (Test-Path -LiteralPath $direct) { return $direct }
|
|
260
|
+
$found = Get-NewestNodeIn $r 'node.exe'
|
|
261
|
+
if ($found) { return $found }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# 3) fnm: 정적 PATH 항목이 없어 PATH 폴백으로는 절대 못 잡는 핵심 케이스.
|
|
265
|
+
$fnmRoots = @($env:FNM_DIR, (Join-Path $env:USERPROFILE '.fnm'), (Join-Path $env:APPDATA 'fnm'), (Join-Path $env:LOCALAPPDATA 'fnm'))
|
|
266
|
+
foreach ($r in $fnmRoots) {
|
|
267
|
+
if (-not $r) { continue }
|
|
268
|
+
$found = Get-NewestNodeIn (Join-Path $r 'node-versions') 'installation\\node.exe'
|
|
269
|
+
if ($found) { return $found }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
# 4) volta: shim 디렉터리의 node.exe.
|
|
273
|
+
$voltaBases = @($env:VOLTA_HOME, (Join-Path $env:USERPROFILE '.volta'), (Join-Path $env:ProgramFiles 'Volta'))
|
|
274
|
+
foreach ($b in $voltaBases) {
|
|
275
|
+
if (-not $b) { continue }
|
|
276
|
+
$candidate = Join-Path $b 'bin\\node.exe'
|
|
277
|
+
if (Test-Path -LiteralPath $candidate) { return $candidate }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# 5) 표준 설치 경로 (공식 설치본 / scoop / chocolatey).
|
|
281
|
+
$pf86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)')
|
|
282
|
+
$standard = @()
|
|
283
|
+
if ($env:ProgramFiles) { $standard += (Join-Path $env:ProgramFiles 'nodejs\\node.exe') }
|
|
284
|
+
if ($pf86) { $standard += (Join-Path $pf86 'nodejs\\node.exe') }
|
|
285
|
+
if ($env:USERPROFILE) { $standard += (Join-Path $env:USERPROFILE 'scoop\\shims\\node.exe') }
|
|
286
|
+
if ($env:ChocolateyInstall) { $standard += (Join-Path $env:ChocolateyInstall 'bin\\node.exe') }
|
|
287
|
+
$standard += 'C:\\ProgramData\\chocolatey\\bin\\node.exe'
|
|
288
|
+
foreach ($s in $standard) {
|
|
289
|
+
if (Test-Path -LiteralPath $s) { return $s }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return $null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
$node = Resolve-Node
|
|
296
|
+
if (-not $node) {
|
|
297
|
+
Add-Content -LiteralPath $LOG -Value 'tokenphage: node 바이너리를 찾지 못했습니다 (PATH / nvm / fnm / volta 확인)'
|
|
298
|
+
[Console]::Error.WriteLine('tokenphage: node not found')
|
|
299
|
+
exit 127
|
|
300
|
+
}
|
|
301
|
+
if (-not (Test-Path -LiteralPath $SCRIPT)) {
|
|
302
|
+
Add-Content -LiteralPath $LOG -Value ('tokenphage: sync 스크립트를 찾지 못했습니다: ' + $SCRIPT)
|
|
303
|
+
exit 127
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# 해석된 node의 디렉터리를 PATH 앞에 붙여 자식 프로세스도 같은 node를 쓰게 한다.
|
|
307
|
+
$env:Path = (Split-Path -Parent $node) + ';' + $env:Path
|
|
308
|
+
|
|
309
|
+
& $node $SCRIPT sync *>> $LOG
|
|
310
|
+
exit $LASTEXITCODE
|
|
311
|
+
`;
|
|
312
|
+
// PowerShell은 LF/CRLF 모두 읽지만 Windows 관례에 맞춰 CRLF로 통일한다.
|
|
313
|
+
return body.replace(/\r?\n/g, '\r\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// schtasks /XML에 넘길 작업 정의. Exec는 powershell.exe로 위 래퍼를 호출한다.
|
|
317
|
+
// powershell.exe(Windows PowerShell 5.1)는 System32에 상시 존재해 제한 PATH에서도
|
|
318
|
+
// 잡히므로 절대경로 대신 이름만 쓴다. StartBoundary는 과거 날짜 하드코딩 대신
|
|
319
|
+
// 호출 시점(now) 기준으로 만들어 테스트 가능한 순수 함수로 유지한다.
|
|
320
|
+
export function buildTaskXml(ps1FilePath, now = new Date()) {
|
|
321
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
322
|
+
const start =
|
|
323
|
+
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
|
324
|
+
`T${pad(SCHEDULE_HOUR)}:${pad(SCHEDULE_MINUTE)}:00`;
|
|
325
|
+
const psArgs = `-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "${ps1FilePath}"`;
|
|
326
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
327
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
328
|
+
<RegistrationInfo>
|
|
329
|
+
<Description>tokenphage daily sync (managed by tokenphage-cli)</Description>
|
|
330
|
+
</RegistrationInfo>
|
|
331
|
+
<Triggers>
|
|
332
|
+
<CalendarTrigger>
|
|
333
|
+
<StartBoundary>${start}</StartBoundary>
|
|
334
|
+
<Enabled>true</Enabled>
|
|
335
|
+
<ScheduleByDay>
|
|
336
|
+
<DaysInterval>1</DaysInterval>
|
|
337
|
+
</ScheduleByDay>
|
|
338
|
+
</CalendarTrigger>
|
|
339
|
+
</Triggers>
|
|
340
|
+
<Principals>
|
|
341
|
+
<Principal id="Author">
|
|
342
|
+
<LogonType>InteractiveToken</LogonType>
|
|
343
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
344
|
+
</Principal>
|
|
345
|
+
</Principals>
|
|
346
|
+
<Settings>
|
|
347
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
348
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
349
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
350
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
351
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
352
|
+
<RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
|
|
353
|
+
<ExecutionTimeLimit>PT10M</ExecutionTimeLimit>
|
|
354
|
+
<Enabled>true</Enabled>
|
|
355
|
+
<Hidden>true</Hidden>
|
|
356
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
357
|
+
<WakeToRun>false</WakeToRun>
|
|
358
|
+
<Priority>7</Priority>
|
|
359
|
+
</Settings>
|
|
360
|
+
<Actions Context="Author">
|
|
361
|
+
<Exec>
|
|
362
|
+
<Command>powershell.exe</Command>
|
|
363
|
+
<Arguments>${escapeXml(psArgs)}</Arguments>
|
|
364
|
+
</Exec>
|
|
365
|
+
</Actions>
|
|
366
|
+
</Task>
|
|
367
|
+
`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Windows 설치: PS1 래퍼(UTF-8 BOM) 작성 → Task XML(UTF-16 BOM) 생성 → schtasks /Create /F(멱등).
|
|
371
|
+
// 권한 거부 시 관리자 터미널 안내와 함께 throw한다.
|
|
372
|
+
function installWindows() {
|
|
373
|
+
const { script } = resolveTokenphageBinary();
|
|
374
|
+
const logPath = ensureLogDir();
|
|
375
|
+
const ps1 = ps1Path();
|
|
376
|
+
mkdirSync(join(homedir(), '.tokenphage'), { recursive: true });
|
|
377
|
+
|
|
378
|
+
// node 탐색 래퍼를 먼저 깔고, 작업이 이 래퍼를 가리키게 한다.
|
|
379
|
+
// UTF-8 BOM()으로 기록해야 PowerShell 5.1이 비ASCII 경로를 올바로 파싱한다.
|
|
380
|
+
writeFileSync(ps1, '' + buildSyncPs1(script, logPath), 'utf8');
|
|
381
|
+
|
|
382
|
+
// schtasks /XML은 UTF-16 BOM을 요구한다.
|
|
383
|
+
const xmlPath = join(tmpdir(), `tokenphage-task-${Date.now()}.xml`);
|
|
384
|
+
const xmlBody = buildTaskXml(ps1);
|
|
385
|
+
const buf = Buffer.concat([Buffer.from([0xFF, 0xFE]), Buffer.from(xmlBody, 'utf16le')]);
|
|
386
|
+
writeFileSync(xmlPath, buf);
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
// idempotent: /F 로 기존 작업(레거시 .bat 기반 포함)을 덮어쓴다.
|
|
390
|
+
execFileSync('schtasks', ['/Create', '/TN', WIN_TASK_NAME, '/XML', xmlPath, '/F'], { stdio: 'pipe' });
|
|
391
|
+
} catch (err) {
|
|
392
|
+
const msg = (err.stderr?.toString() || err.message || '').trim();
|
|
393
|
+
if (/access is denied|액세스가 거부|0x80070005|requires elevation/i.test(msg)) {
|
|
394
|
+
throw new Error(
|
|
395
|
+
'schtasks 작업 등록이 거부되었습니다. 관리자 권한 터미널에서 다시 실행하세요.\n' +
|
|
396
|
+
'schtasks registration was denied — run this command from an elevated (Administrator) terminal.');
|
|
397
|
+
}
|
|
398
|
+
throw new Error(`schtasks registration failed: ${msg}`);
|
|
399
|
+
} finally {
|
|
400
|
+
try { unlinkSync(xmlPath); } catch { /* noop */ }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
platform: 'win32',
|
|
405
|
+
method: 'schtasks',
|
|
406
|
+
logPath,
|
|
407
|
+
scheduleSpec: `daily ${String(SCHEDULE_HOUR).padStart(2, '0')}:${String(SCHEDULE_MINUTE).padStart(2, '0')}`,
|
|
408
|
+
artifactPath: WIN_TASK_NAME,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Windows 제거: schtasks /Delete 후 현재(.ps1)·레거시(.bat) 래퍼를 정리한다. removed 여부를 반환.
|
|
413
|
+
function uninstallWindows() {
|
|
414
|
+
let removed = false;
|
|
415
|
+
try {
|
|
416
|
+
execFileSync('schtasks', ['/Delete', '/TN', WIN_TASK_NAME, '/F'], { stdio: 'ignore' });
|
|
417
|
+
removed = true;
|
|
418
|
+
} catch { /* 작업이 없으면 무시 */ }
|
|
419
|
+
|
|
420
|
+
// 현재 래퍼(.ps1)와 구버전 래퍼(.bat)를 모두 정리한다.
|
|
421
|
+
for (const p of [ps1Path(), legacyBatPath()]) {
|
|
422
|
+
if (existsSync(p)) {
|
|
423
|
+
try { unlinkSync(p); removed = true; } catch { /* noop */ }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return { platform: 'win32', removed };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
430
|
+
|
|
431
|
+
/** 현재 OS에 맞는 자동 sync 스케줄을 설치한다 (macOS=launchd / Windows=schtasks). 그 외 OS는 throw. */
|
|
432
|
+
export async function installHook() {
|
|
433
|
+
if (process.platform === 'darwin') return installMac();
|
|
434
|
+
if (process.platform === 'win32') return installWindows();
|
|
435
|
+
throw new Error(`Unsupported OS: ${process.platform}. Only macOS and Windows are supported.`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** 현재 OS의 자동 sync 스케줄을 제거한다 (macOS=launchd / Windows=schtasks). 그 외 OS는 throw. */
|
|
439
|
+
export async function uninstallHook() {
|
|
440
|
+
if (process.platform === 'darwin') return uninstallMac();
|
|
441
|
+
if (process.platform === 'win32') return uninstallWindows();
|
|
442
|
+
throw new Error(`Unsupported OS: ${process.platform}. Only macOS and Windows are supported.`);
|
|
443
|
+
}
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 집계된 토큰 사용 기록을 서버에 업로드한다 (POST /api/sync).
|
|
3
|
+
* 사용자는 서버가 JWT(Bearer)에서 식별하고, 본문에 deviceId와 records를 담아 보낸다.
|
|
4
|
+
* @param {string} apiBase API 서버 base URL
|
|
5
|
+
* @param {string} token Bearer JWT
|
|
6
|
+
* @param {string} deviceId 기기 식별 UUID
|
|
7
|
+
* @param {Array<{date:string, model:string, inputTok:number, outputTok:number, cacheReadTok:number, cacheCreateTok:number}>} records 날짜·모델별 집계 레코드
|
|
8
|
+
* @throws {Error} 응답이 2xx가 아니면 status와 응답 본문을 담아 throw
|
|
9
|
+
*/
|
|
10
|
+
export async function syncRecords(apiBase, token, deviceId, records) {
|
|
11
|
+
// Node 18+ 전역 fetch 사용 (node-fetch 의존성 불필요).
|
|
12
|
+
const res = await fetch(`${apiBase}/api/sync`, {
|
|
13
|
+
method: 'POST',
|
|
14
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
|
15
|
+
body: JSON.stringify({ deviceId, records }),
|
|
16
|
+
});
|
|
17
|
+
if (!res.ok) throw new Error(`Server error: ${res.status} ${await res.text()}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 서버에 전체 초기화를 요청한다. 대상은 서버가 JWT에서 식별하므로 본문은 비운다.
|
|
22
|
+
* @param {string} apiBase
|
|
23
|
+
* @param {string} token Bearer JWT
|
|
24
|
+
*/
|
|
25
|
+
export async function resetData(apiBase, token) {
|
|
26
|
+
const res = await fetch(`${apiBase}/api/reset`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
|
29
|
+
});
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const text = await res.text();
|
|
32
|
+
let msg = text;
|
|
33
|
+
try { msg = JSON.parse(text).message ?? text; } catch { }
|
|
34
|
+
throw new Error(res.status === 429 ? msg : `Server error: ${res.status} ${msg}`);
|
|
35
|
+
}
|
|
36
|
+
}
|