loopers-token-cli 0.2.0 → 0.3.0
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/dist/api.js +1 -2
- package/dist/commands/init.js +3 -5
- package/dist/commands/install.js +159 -0
- package/dist/index.js +15 -3
- package/dist/schema.js +1 -2
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { IngestPayload } from './schema.js';
|
|
2
|
-
export const CLI_VERSION = '0.
|
|
2
|
+
export const CLI_VERSION = '0.3.0';
|
|
3
3
|
/** 전송 직전 페이로드를 스키마로 build(검증 + 스키마 외 필드 제거) */
|
|
4
4
|
export function buildPayload(config, stats) {
|
|
5
5
|
// parse는 스키마에 정의된 필드만 남기고 나머지를 제거한다 → 개인정보 차단.
|
|
@@ -7,7 +7,6 @@ export function buildPayload(config, stats) {
|
|
|
7
7
|
schemaVersion: 1,
|
|
8
8
|
deviceId: config.deviceId,
|
|
9
9
|
nickname: config.nickname,
|
|
10
|
-
team: config.team,
|
|
11
10
|
cliVersion: CLI_VERSION,
|
|
12
11
|
stats,
|
|
13
12
|
});
|
package/dist/commands/init.js
CHANGED
|
@@ -20,7 +20,7 @@ export async function runInit(opts) {
|
|
|
20
20
|
};
|
|
21
21
|
try {
|
|
22
22
|
console.log('\n루퍼스 토큰 대시보드 — 초기 설정');
|
|
23
|
-
console.log('⚠️
|
|
23
|
+
console.log('⚠️ GitHub username 외 실명·이메일 등 개인정보를 넣지 마세요.\n');
|
|
24
24
|
// GitHub username 확보: 플래그 > 기존값 > gh 자동감지 > 수동입력
|
|
25
25
|
let username = opts.nickname ?? existing?.nickname;
|
|
26
26
|
if (!username) {
|
|
@@ -35,19 +35,17 @@ export async function runInit(opts) {
|
|
|
35
35
|
process.exitCode = 1;
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
const team = opts.team ?? (await ask('팀', existing?.team));
|
|
39
38
|
const endpoint = opts.endpoint ??
|
|
40
39
|
(await ask('서버 endpoint', existing?.endpoint ?? DEFAULT_ENDPOINT));
|
|
41
40
|
const enrollKey = opts.enrollKey ?? (await ask('등록 키(enroll key)', existing?.enrollKey));
|
|
42
|
-
if (!username || !
|
|
43
|
-
console.error('\n❌ GitHub username
|
|
41
|
+
if (!username || !endpoint || !enrollKey) {
|
|
42
|
+
console.error('\n❌ GitHub username/endpoint/등록 키는 모두 필요합니다.');
|
|
44
43
|
process.exitCode = 1;
|
|
45
44
|
return;
|
|
46
45
|
}
|
|
47
46
|
const config = {
|
|
48
47
|
deviceId: existing?.deviceId ?? randomUUID(),
|
|
49
48
|
nickname: username,
|
|
50
|
-
team,
|
|
51
49
|
endpoint,
|
|
52
50
|
enrollKey,
|
|
53
51
|
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { homedir, platform } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { mkdir, writeFile, unlink, readFile, access, } from 'node:fs/promises';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { loadConfig } from '../config.js';
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const LABEL = 'app.loopers.token-cli';
|
|
10
|
+
const AGENT_DIR = join(homedir(), 'Library', 'LaunchAgents');
|
|
11
|
+
const PLIST_PATH = join(AGENT_DIR, `${LABEL}.plist`);
|
|
12
|
+
const LOG_DIR = join(homedir(), '.config', 'loopers-token', 'logs');
|
|
13
|
+
/** 현재 실행 중인 CLI의 절대 진입점 경로 (dist/index.js). */
|
|
14
|
+
function cliEntrypoint() {
|
|
15
|
+
// commands/install.js → dist/index.js
|
|
16
|
+
return fileURLToPath(new URL('../index.js', import.meta.url));
|
|
17
|
+
}
|
|
18
|
+
/** node 실행 파일 경로. launchd는 PATH가 비어 있으므로 절대경로 필요. */
|
|
19
|
+
function nodePath() {
|
|
20
|
+
return process.execPath;
|
|
21
|
+
}
|
|
22
|
+
function buildPlist(intervalSeconds) {
|
|
23
|
+
const node = nodePath();
|
|
24
|
+
const entry = cliEntrypoint();
|
|
25
|
+
const outLog = join(LOG_DIR, 'sync.out.log');
|
|
26
|
+
const errLog = join(LOG_DIR, 'sync.err.log');
|
|
27
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
28
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
29
|
+
<plist version="1.0">
|
|
30
|
+
<dict>
|
|
31
|
+
<key>Label</key>
|
|
32
|
+
<string>${LABEL}</string>
|
|
33
|
+
<key>ProgramArguments</key>
|
|
34
|
+
<array>
|
|
35
|
+
<string>${node}</string>
|
|
36
|
+
<string>${entry}</string>
|
|
37
|
+
<string>sync</string>
|
|
38
|
+
<string>--quiet</string>
|
|
39
|
+
</array>
|
|
40
|
+
<key>StartInterval</key>
|
|
41
|
+
<integer>${intervalSeconds}</integer>
|
|
42
|
+
<key>RunAtLoad</key>
|
|
43
|
+
<true/>
|
|
44
|
+
<key>StandardOutPath</key>
|
|
45
|
+
<string>${outLog}</string>
|
|
46
|
+
<key>StandardErrorPath</key>
|
|
47
|
+
<string>${errLog}</string>
|
|
48
|
+
</dict>
|
|
49
|
+
</plist>
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
async function bootedOut() {
|
|
53
|
+
// 이미 로드돼 있으면 먼저 내린다(중복 등록 방지). 실패는 무시.
|
|
54
|
+
try {
|
|
55
|
+
await exec('launchctl', ['unload', PLIST_PATH]);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* 미등록 상태면 정상 */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export async function runInstall(opts) {
|
|
62
|
+
if (platform() !== 'darwin') {
|
|
63
|
+
console.error('❌ 현재 자동 전송 설치는 macOS(launchd)만 지원합니다. ' +
|
|
64
|
+
'Linux/Windows는 cron 또는 작업 스케줄러로 `loopers-token sync --quiet` 를 등록하세요.');
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const config = await loadConfig();
|
|
69
|
+
if (!config) {
|
|
70
|
+
console.error('❌ 설정이 없습니다. 먼저 `loopers-token init` 을 실행하세요.');
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const minutes = Math.max(5, opts.intervalMinutes ?? 60);
|
|
75
|
+
const intervalSeconds = minutes * 60;
|
|
76
|
+
await mkdir(AGENT_DIR, { recursive: true });
|
|
77
|
+
await mkdir(LOG_DIR, { recursive: true });
|
|
78
|
+
await bootedOut();
|
|
79
|
+
await writeFile(PLIST_PATH, buildPlist(intervalSeconds), 'utf8');
|
|
80
|
+
try {
|
|
81
|
+
await exec('launchctl', ['load', PLIST_PATH]);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
console.error(`❌ launchctl load 실패: ${e.message}`);
|
|
85
|
+
console.error(` plist는 작성됨: ${PLIST_PATH}`);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log('✅ 자동 전송 설치 완료 (launchd)');
|
|
90
|
+
console.log(` 주기: ${minutes}분마다 sync --quiet`);
|
|
91
|
+
console.log(` plist: ${PLIST_PATH}`);
|
|
92
|
+
console.log(` 로그: ${LOG_DIR}`);
|
|
93
|
+
console.log(' 해제하려면 `loopers-token uninstall` 을 실행하세요.');
|
|
94
|
+
}
|
|
95
|
+
export async function runUninstall() {
|
|
96
|
+
if (platform() !== 'darwin') {
|
|
97
|
+
console.error('❌ 자동 전송은 macOS(launchd)만 지원합니다.');
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await access(PLIST_PATH);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
console.log('자동 전송이 설치돼 있지 않습니다. (plist 없음)');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
await bootedOut();
|
|
109
|
+
try {
|
|
110
|
+
await unlink(PLIST_PATH);
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
console.error(`❌ plist 삭제 실패: ${e.message}`);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
console.log('✅ 자동 전송 해제 완료 (launchd unload + plist 삭제)');
|
|
118
|
+
}
|
|
119
|
+
export async function runStatus() {
|
|
120
|
+
if (platform() !== 'darwin') {
|
|
121
|
+
console.log('자동 전송 상태 조회는 macOS(launchd)만 지원합니다.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let installed = false;
|
|
125
|
+
try {
|
|
126
|
+
await access(PLIST_PATH);
|
|
127
|
+
installed = true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* 미설치 */
|
|
131
|
+
}
|
|
132
|
+
if (!installed) {
|
|
133
|
+
console.log('상태: 미설치. `loopers-token install` 로 자동 전송을 설정하세요.');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
let loaded = false;
|
|
137
|
+
try {
|
|
138
|
+
const { stdout } = await exec('launchctl', ['list']);
|
|
139
|
+
loaded = stdout.includes(LABEL);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
/* list 실패 시 미로드로 간주 */
|
|
143
|
+
}
|
|
144
|
+
console.log(`상태: 설치됨 ${loaded ? '(로드됨/활성)' : '(plist만 존재, 미로드)'}`);
|
|
145
|
+
console.log(` plist: ${PLIST_PATH}`);
|
|
146
|
+
// 최근 sync 로그 끝부분 표시
|
|
147
|
+
const errLog = join(LOG_DIR, 'sync.err.log');
|
|
148
|
+
try {
|
|
149
|
+
const raw = await readFile(errLog, 'utf8');
|
|
150
|
+
const tail = raw.trim().split('\n').slice(-5).join('\n');
|
|
151
|
+
if (tail) {
|
|
152
|
+
console.log(`\n최근 에러 로그(${errLog}):`);
|
|
153
|
+
console.log(tail);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
/* 로그 없음 */
|
|
158
|
+
}
|
|
159
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { runInit } from './commands/init.js';
|
|
4
4
|
import { runSync } from './commands/sync.js';
|
|
5
|
+
import { runInstall, runUninstall, runStatus } from './commands/install.js';
|
|
5
6
|
import { CLI_VERSION } from './api.js';
|
|
6
7
|
const program = new Command();
|
|
7
8
|
program
|
|
@@ -10,15 +11,13 @@ program
|
|
|
10
11
|
.version(CLI_VERSION);
|
|
11
12
|
program
|
|
12
13
|
.command('init')
|
|
13
|
-
.description('GitHub username
|
|
14
|
+
.description('GitHub username/서버 설정 (deviceId는 1회 생성 후 고정)')
|
|
14
15
|
.option('--github-username <name>', 'GitHub username (미지정 시 gh CLI 자동 감지)')
|
|
15
16
|
.option('--nickname <name>', '(별칭) --github-username 과 동일')
|
|
16
|
-
.option('--team <team>', '팀 라벨')
|
|
17
17
|
.option('--endpoint <url>', '서버 ingest endpoint')
|
|
18
18
|
.option('--enroll-key <key>', '부트캠프 공통 등록 키')
|
|
19
19
|
.action((opts) => runInit({
|
|
20
20
|
nickname: opts.githubUsername ?? opts.nickname,
|
|
21
|
-
team: opts.team,
|
|
22
21
|
endpoint: opts.endpoint,
|
|
23
22
|
enrollKey: opts.enrollKey,
|
|
24
23
|
}));
|
|
@@ -28,4 +27,17 @@ program
|
|
|
28
27
|
.option('--dry-run', '전송하지 않고 페이로드만 출력 (프라이버시 확인용)')
|
|
29
28
|
.option('--quiet', '로그 최소화 (자동 스케줄용)')
|
|
30
29
|
.action((opts) => runSync({ dryRun: opts.dryRun, quiet: opts.quiet }));
|
|
30
|
+
program
|
|
31
|
+
.command('install')
|
|
32
|
+
.description('자동 전송 설치 (macOS launchd, 기본 60분마다 sync --quiet)')
|
|
33
|
+
.option('--interval <minutes>', '동기화 주기(분, 최소 5)', '60')
|
|
34
|
+
.action((opts) => runInstall({ intervalMinutes: Number.parseInt(opts.interval, 10) }));
|
|
35
|
+
program
|
|
36
|
+
.command('uninstall')
|
|
37
|
+
.description('자동 전송 해제 (launchd unload + plist 삭제)')
|
|
38
|
+
.action(() => runUninstall());
|
|
39
|
+
program
|
|
40
|
+
.command('status')
|
|
41
|
+
.description('자동 전송 설치/로드 상태 및 최근 로그 확인')
|
|
42
|
+
.action(() => runStatus());
|
|
31
43
|
program.parseAsync(process.argv);
|
package/dist/schema.js
CHANGED
|
@@ -22,14 +22,13 @@ export const DailyModelStat = z.object({
|
|
|
22
22
|
cacheReadInputTokens: z.number().int().nonnegative(),
|
|
23
23
|
messageCount: z.number().int().nonnegative(),
|
|
24
24
|
});
|
|
25
|
-
/**
|
|
25
|
+
/** 닉네임 — 자유 라벨. 실명/이메일 입력 금지 */
|
|
26
26
|
export const labelString = z.string().trim().min(1).max(32);
|
|
27
27
|
/** CLI가 중앙 서버 POST /api/ingest 로 보내는 전체 페이로드 */
|
|
28
28
|
export const IngestPayload = z.object({
|
|
29
29
|
schemaVersion: z.literal(1),
|
|
30
30
|
deviceId: z.string().uuid(),
|
|
31
31
|
nickname: labelString,
|
|
32
|
-
team: labelString,
|
|
33
32
|
cliVersion: z.string().max(20),
|
|
34
33
|
stats: z.array(DailyModelStat).max(2000),
|
|
35
34
|
});
|