loopers-token-cli 0.1.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  import { IngestPayload } from './schema.js';
2
- export const CLI_VERSION = '0.1.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
  });
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { stdin, stdout } from 'node:process';
4
4
  import { loadConfig, saveConfig, CONFIG_PATH } from '../config.js';
5
+ import { detectGithubUsername, isValidGithubUsername } from '../github.js';
5
6
  const DEFAULT_ENDPOINT = 'https://loopers-token-dashboard.vercel.app/api/ingest';
6
7
  /** 비대화형(플래그) 또는 대화형으로 config를 생성/갱신한다. deviceId는 1회 생성 후 고정. */
7
8
  export async function runInit(opts) {
@@ -9,31 +10,48 @@ export async function runInit(opts) {
9
10
  const rl = createInterface({ input: stdin, output: stdout });
10
11
  const ask = async (q, fallback) => {
11
12
  const suffix = fallback ? ` [${fallback}]` : '';
12
- const ans = (await rl.question(`${q}${suffix}: `)).trim();
13
- return ans || fallback || '';
13
+ try {
14
+ const ans = (await rl.question(`${q}${suffix}: `)).trim();
15
+ return ans || fallback || '';
16
+ }
17
+ catch {
18
+ return fallback || '';
19
+ }
14
20
  };
15
21
  try {
16
22
  console.log('\n루퍼스 토큰 대시보드 — 초기 설정');
17
- console.log('⚠️ 닉네임/팀에 실명·이메일 등 개인정보를 넣지 마세요. 자유 라벨만 사용합니다.\n');
18
- const nickname = opts.nickname ?? (await ask('닉네임', existing?.nickname));
19
- const team = opts.team ?? (await ask('팀', existing?.team));
23
+ console.log('⚠️ GitHub username 외 실명·이메일 등 개인정보를 넣지 마세요.\n');
24
+ // GitHub username 확보: 플래그 > 기존값 > gh 자동감지 > 수동입력
25
+ let username = opts.nickname ?? existing?.nickname;
26
+ if (!username) {
27
+ const detected = await detectGithubUsername();
28
+ if (detected) {
29
+ console.log(`🔎 gh CLI에서 GitHub username 감지: ${detected}`);
30
+ }
31
+ username = await ask('GitHub username', detected ?? undefined);
32
+ }
33
+ if (username && !isValidGithubUsername(username)) {
34
+ console.error(`\n❌ "${username}" 은(는) 올바른 GitHub username 형식이 아닙니다.`);
35
+ process.exitCode = 1;
36
+ return;
37
+ }
20
38
  const endpoint = opts.endpoint ??
21
39
  (await ask('서버 endpoint', existing?.endpoint ?? DEFAULT_ENDPOINT));
22
40
  const enrollKey = opts.enrollKey ?? (await ask('등록 키(enroll key)', existing?.enrollKey));
23
- if (!nickname || !team || !endpoint || !enrollKey) {
24
- console.error('\n❌ 닉네임/팀/endpoint/등록 키는 모두 필요합니다.');
41
+ if (!username || !endpoint || !enrollKey) {
42
+ console.error('\n❌ GitHub username/endpoint/등록 키는 모두 필요합니다.');
25
43
  process.exitCode = 1;
26
44
  return;
27
45
  }
28
46
  const config = {
29
47
  deviceId: existing?.deviceId ?? randomUUID(),
30
- nickname,
31
- team,
48
+ nickname: username,
32
49
  endpoint,
33
50
  enrollKey,
34
51
  };
35
52
  await saveConfig(config);
36
53
  console.log(`\n✅ 설정 저장 완료: ${CONFIG_PATH}`);
54
+ console.log(` GitHub username: ${config.nickname}`);
37
55
  console.log(` deviceId: ${config.deviceId}`);
38
56
  console.log(' 이제 `loopers-token sync` 로 전송하세요.\n');
39
57
  }
@@ -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/github.js ADDED
@@ -0,0 +1,26 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ /**
5
+ * 로컬 gh CLI(GitHub CLI)로 로그인된 username을 조회한다.
6
+ * gh 미설치/미로그인 등 어떤 이유로든 실패하면 null.
7
+ */
8
+ export async function detectGithubUsername() {
9
+ try {
10
+ const { stdout } = await execFileAsync('gh', ['api', 'user', '--jq', '.login'], {
11
+ timeout: 5000,
12
+ });
13
+ const login = stdout.trim();
14
+ return isValidGithubUsername(login) ? login : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ /**
21
+ * GitHub username 형식 검증.
22
+ * 규칙: 영숫자와 하이픈, 1~39자, 하이픈으로 시작/끝 불가, 연속 하이픈 불가.
23
+ */
24
+ export function isValidGithubUsername(name) {
25
+ return /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}$/.test(name);
26
+ }
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,14 +11,13 @@ program
10
11
  .version(CLI_VERSION);
11
12
  program
12
13
  .command('init')
13
- .description('닉네임/팀/서버 설정 (deviceId는 1회 생성 후 고정)')
14
- .option('--nickname <name>', '닉네임 (실명/이메일 금지)')
15
- .option('--team <team>', ' 라벨')
14
+ .description('GitHub username/서버 설정 (deviceId는 1회 생성 후 고정)')
15
+ .option('--github-username <name>', 'GitHub username (미지정 시 gh CLI 자동 감지)')
16
+ .option('--nickname <name>', '(별칭) --github-username 과 동일')
16
17
  .option('--endpoint <url>', '서버 ingest endpoint')
17
18
  .option('--enroll-key <key>', '부트캠프 공통 등록 키')
18
19
  .action((opts) => runInit({
19
- nickname: opts.nickname,
20
- team: opts.team,
20
+ nickname: opts.githubUsername ?? opts.nickname,
21
21
  endpoint: opts.endpoint,
22
22
  enrollKey: opts.enrollKey,
23
23
  }));
@@ -27,4 +27,17 @@ program
27
27
  .option('--dry-run', '전송하지 않고 페이로드만 출력 (프라이버시 확인용)')
28
28
  .option('--quiet', '로그 최소화 (자동 스케줄용)')
29
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());
30
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopers-token-cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "루퍼스 토큰 대시보드 수집 CLI — ~/.claude 세션의 토큰 집계값만 전송",
5
5
  "type": "module",
6
6
  "bin": {