lostvibe 0.0.0-stage → 0.8.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.
@@ -0,0 +1,753 @@
1
+ #!/usr/bin/env node
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { execFileSync, spawn } from 'node:child_process';
7
+ import { scanAll, stripLocal, readJson, scanEntry } from '../src/scan.mjs';
8
+ import { landTable, projectTable } from '../src/table.mjs';
9
+ import { askReported } from '../src/reported.mjs';
10
+ import { resolveCwd, sha, projectIdentity } from '../src/gitinfo.mjs';
11
+ import { push } from '../src/push.mjs';
12
+ import {
13
+ registerCurrent,
14
+ registerProject,
15
+ setMerge,
16
+ mergeMap,
17
+ activeConnection,
18
+ selectedProjects,
19
+ siteOf,
20
+ request,
21
+ sendStatus,
22
+ saveJson,
23
+ currentIdentity,
24
+ } from '../src/connections.mjs';
25
+ import { addCollector, removeCollector, collect, testCollector, collectorCwd } from '../src/collectors.mjs';
26
+ import { installHooks, removeHooks, hookStatus, statusLines, installLines } from '../src/hooks.mjs';
27
+
28
+ const DEFAULT_SITE = 'https://lostvibesociety.com';
29
+
30
+ function parse(argv) {
31
+ const o = { _: [], root: [] };
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ if (a === '--all') o.all = true;
35
+ else if (a === '--ask') o.ask = true;
36
+ else if (a === '--yes' || a === '-y') o.yes = true;
37
+ else if (a === '--quiet' || a === '-q') o.quiet = true;
38
+ else if (a === '--detail') o.detail = true;
39
+ else if (a === '--out') o.out = argv[++i];
40
+ else if (a === '--root') o.root.push(argv[++i]);
41
+ else if (a === '--token') o.token = argv[++i];
42
+ else if (a === '--site') o.site = argv[++i];
43
+ else if (a === '--project') o.project = argv[++i];
44
+ else if (a === '--file') o.file = argv[++i];
45
+ else if (a === '--slot') o.slot = argv[++i];
46
+ else if (a === '--command-json') o.commandJson = argv[++i];
47
+ else if (a === '--client') o.client = argv[++i];
48
+ else if (a === '--no-hooks') o.noHooks = true;
49
+ else if (a === '--only') o.only = argv[++i];
50
+ else if (a === '--json') o.json = true;
51
+ else if (a === '--source') o.source = argv[++i];
52
+ else if (a === '--merge') o.merge = argv[++i];
53
+ else if (a === '--of') o.of = argv[++i];
54
+ else if (a === '--from') o.from = argv[++i];
55
+ else if (a === '--cwd') o.cwd = argv[++i];
56
+ else if (a === '-h' || a === '--help') o.help = true;
57
+ else o._.push(a);
58
+ }
59
+ return o;
60
+ }
61
+
62
+ const HELP = `lostvibe - 프로젝트의 객관 지표를 뽑는다
63
+
64
+ lostvibe scan [폴더] 폴더 하나를 읽어 그 폴더에 lostvibe.json 저장
65
+ lostvibe scan --all 로그 전체에서 내 프로젝트를 전부 찾아 표로 출력
66
+ lostvibe scan --all --json 같은 목록을 JSON 하나로만 출력 (~/.lostvibe/scan-all.json 에도 저장)
67
+ lostvibe login --token T 이 컴퓨터를 내 땅에 연결한다
68
+ lostvibe setup 현재 폴더의 프로젝트를 비공개로 등록하고 이 컴퓨터와 연결 (git 없어도 돼요)
69
+ lostvibe setup --source ID 폴더 없이 로그만 남은 프로젝트를 등록 (scan --all 의 id, 어느 폴더에서나)
70
+ lostvibe setup --merge ID[,ID]
71
+ scan --all 의 다른 프로젝트를 이 프로젝트 기록에 합친다
72
+ lostvibe draft --file F [--of N] [--project ID]
73
+ AI가 만든 JSON을 검토할 초안으로 보낸다 (--of: 가져오는 프로젝트 수)
74
+ lostvibe sync 등록한 프로젝트의 바뀐 기록만 올린다
75
+ lostvibe hooks install 기존 훅을 보존하며 자동 갱신 연결 (Stop, SessionEnd)
76
+ lostvibe hooks status 클라이언트별 상태: 미설치 / 설치됨·승인 전 / 승인됨·실행 기록 없음 / 실행됨
77
+ lostvibe hooks remove Lostvibe 훅만 제거
78
+ lostvibe collector add --project ID --slot result --command-json '["node","script.mjs"]' [--cwd 폴더]
79
+ 프로젝트 성과 조회 명령을 등록 (--cwd 는 프로젝트 폴더 안)
80
+ lostvibe collector test --command-json '[...]' [--cwd 폴더] [--project ID]
81
+ 보내지 않고 실행만 해서 값을 확인
82
+ lostvibe collector remove --project ID --slot result
83
+ lostvibe collect 연결한 성과를 읽어 갱신
84
+ lostvibe push --all 결과를 확인하고 올린다
85
+ lostvibe hook 세션 종료 훅에서 동기화를 백그라운드로 실행한다
86
+ lostvibe metrics-token --project ID --file .lostvibe/metrics-token
87
+ 지표 전용 키를 출력 없이 저장한다
88
+
89
+ 옵션
90
+ --ask 사람 입력 항목을 다시 묻는다
91
+ --out 폴더 --all 결과 저장 위치 (기본 ~/.lostvibe)
92
+ --root 폴더 이 폴더 바로 아래 레포도 후보에 넣는다 (--all)
93
+ --detail 프로젝트별 상세 표도 출력 (--all)
94
+ --only a,b 등록한 프로젝트 중 선택 (이름 또는 ID)
95
+ --no-hooks setup 에서 훅 설치를 건너뛴다
96
+ --yes push 확인을 건너뛴다
97
+ --quiet 출력 없이 돈다 (sync)
98
+ --site URL push 대상 (기본 ${DEFAULT_SITE})
99
+
100
+ 환경변수 GITHUB_TOKEN, VERCEL_TOKEN 은 선택이다. 없으면 로그인된 gh, vercel 을 쓰고, 그것도 없으면 건너뛴다.`;
101
+
102
+ const progress = (m) => {
103
+ if (process.stderr.isTTY) process.stderr.write(`\r\x1b[K${m}`);
104
+ };
105
+ const doneProgress = () => {
106
+ if (process.stderr.isTTY) process.stderr.write('\r\x1b[K');
107
+ };
108
+
109
+ function logRetentionHint(land) {
110
+ try {
111
+ const f = path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'settings.json');
112
+ const s = readJson(f);
113
+ if (!s || s.cleanupPeriodDays === undefined) {
114
+ console.log('\n주의: Claude Code는 기본 설정이면 오래된 로그를 지운다. 사라진 토큰은 복구할 수 없다.');
115
+ console.log(' ~/.claude/settings.json 의 cleanupPeriodDays 를 크게 늘려 두는 편이 안전하다.');
116
+ }
117
+ } catch {
118
+ /* hint only */
119
+ }
120
+ if (land.log_coverage_from) console.log(`로그가 남아 있는 가장 오래된 날: ${land.log_coverage_from}`);
121
+ }
122
+
123
+ async function scanMany(o) {
124
+ const out = path.resolve(o.out || process.env.LOSTVIBE_DIR || path.join(os.homedir(), '.lostvibe'));
125
+ fs.mkdirSync(out, { recursive: true });
126
+ const landFile = path.join(out, 'land.json');
127
+ const prevById = previousReported(out);
128
+ const land = await scanAll({
129
+ extraRoots: o.root,
130
+ onProgress: o.json ? () => {} : progress,
131
+ cacheDir: homeDir(o),
132
+ reportedFor: (id) => prevById.get(id),
133
+ });
134
+ doneProgress();
135
+ if (o.ask && !o.json) {
136
+ for (const p of land.projects) p.reported = await askReported(p.project.name, p.reported);
137
+ }
138
+ const clean = { ...land, projects: land.projects.map(stripLocal) };
139
+ fs.writeFileSync(landFile, `${JSON.stringify(clean, null, 2)}\n`);
140
+ // The full list with local paths stays on this computer. setup --source / --merge read it.
141
+ const entries = land.projects.map(scanEntry);
142
+ const scanFile = path.join(out, 'scan-all.json');
143
+ saveJson(scanFile, {
144
+ scanned_at: land.scanned_at,
145
+ projects: entries.map((e, i) => ({
146
+ ...e,
147
+ folder: land.projects[i]._local?.folder || null,
148
+ cwds: land.projects[i]._local?.cwds || [],
149
+ })),
150
+ });
151
+ if (o.json) {
152
+ process.stdout.write(`${JSON.stringify({ projects: entries })}\n`);
153
+ return;
154
+ }
155
+ console.log(landTable(land));
156
+ if (o.detail) for (const p of land.projects) console.log(projectTable(p));
157
+ console.log(`\n프로젝트 ${land.projects.length}개. 어느 프로젝트에도 속하지 않는 임시 폴더 토큰은 뺐다.`);
158
+ logRetentionHint(land);
159
+ console.log(`저장: ${landFile}`);
160
+ }
161
+
162
+ // reported answers from the last scan --all and the last sync, sync winning.
163
+ function previousReported(dir) {
164
+ const map = new Map();
165
+ for (const f of ['land.json', 'sync-land.json'])
166
+ for (const p of readJson(path.join(dir, f))?.projects || []) if (p?.project?.id) map.set(p.project.id, p.reported);
167
+ return map;
168
+ }
169
+
170
+ function readScanAll(o) {
171
+ const scan = readJson(path.join(homeDir(o), 'scan-all.json'));
172
+ if (!scan?.projects) throw new Error('먼저 scan --all 을 실행해 주세요.');
173
+ return scan.projects;
174
+ }
175
+ const rootsOf = (e) => (e.folder ? [path.dirname(e.folder)] : []);
176
+
177
+ async function scanOne(o) {
178
+ const dir = path.resolve(o._[1] || '.');
179
+ const file = path.join(dir, 'lostvibe.json');
180
+ const prev = readJson(file);
181
+ const target = resolveCwd(dir);
182
+ const land = await scanAll({
183
+ extraRoots: [path.dirname(dir)],
184
+ onProgress: progress,
185
+ cacheDir: homeDir(o),
186
+ reportedFor: () => prev?.reported,
187
+ });
188
+ doneProgress();
189
+ const id = projectIdentity(target.repoPath, target.key);
190
+ let p = land.projects.find((x) => x.project.id === id);
191
+ if (!p) {
192
+ console.error('이 폴더에서 읽을 수 있는 git 기록과 AI 로그를 찾지 못했다.');
193
+ process.exit(1);
194
+ }
195
+ const shouldAsk = o.ask || (!prev?.reported?.answered_at && process.stdin.isTTY);
196
+ if (shouldAsk) {
197
+ p.reported = await askReported(p.project.name, p.reported);
198
+ if (p.verified.ai && p.reported.launch_date) {
199
+ let before = 0;
200
+ let after = 0;
201
+ for (const d of p.verified.ai.daily_tokens) {
202
+ if (d.date < p.reported.launch_date) before += d.tokens;
203
+ else after += d.tokens;
204
+ }
205
+ p.verified.ai.before_launch_tokens = before;
206
+ p.verified.ai.after_launch_tokens = after;
207
+ }
208
+ }
209
+ p = stripLocal(p);
210
+ fs.writeFileSync(file, `${JSON.stringify(p, null, 2)}\n`);
211
+ console.log(projectTable(p));
212
+ console.log(`\n저장: ${file}`);
213
+ try {
214
+ execFileSync('git', ['-C', dir, 'check-ignore', '-q', 'lostvibe.json'], { stdio: 'ignore' });
215
+ } catch {
216
+ console.log('이 파일은 .gitignore 에 없다. 올리지 않으려면 추가해 두는 편이 낫다.');
217
+ }
218
+ }
219
+
220
+ const homeDir = (o) =>
221
+ path.resolve(o.out || process.env.LOSTVIBE_DIR || path.join(os.homedir(), '.lostvibe'));
222
+
223
+ function savedToken(o) {
224
+ if (o.token || process.env.LOSTVIBE_TOKEN) return o.token || process.env.LOSTVIBE_TOKEN;
225
+ try {
226
+ return fs.readFileSync(path.join(homeDir(o), 'token'), 'utf8').trim();
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ const NO_TOKEN =
233
+ '연결되어 있지 않다. 사이트 You 탭의 연결 설정에서 "다른 컴퓨터용 토큰 받기"를 눌러 받은 명령을 실행한다.';
234
+
235
+ function loginCmd(o) {
236
+ if (!o.token) {
237
+ console.error('--token 이 필요하다.');
238
+ process.exit(1);
239
+ }
240
+ const dir = homeDir(o);
241
+ fs.mkdirSync(dir, { recursive: true });
242
+ fs.writeFileSync(path.join(dir, 'token'), o.token, { mode: 0o600 });
243
+ fs.chmodSync(path.join(dir, 'token'), 0o600);
244
+ console.log('이 컴퓨터를 연결했다. 이제 lostvibe sync 로 올린다.');
245
+ }
246
+
247
+ // Fingerprints upload payloads; unchanged data is omitted, while connection/status checks still run.
248
+ function fingerprint(p) {
249
+ const { scanned_at: _ignored, ...rest } = p;
250
+ return crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex').slice(0, 24);
251
+ }
252
+
253
+ function writeAtomic(file, data) {
254
+ const tmp = `${file}.${process.pid}.tmp`;
255
+ fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
256
+ fs.renameSync(tmp, file);
257
+ }
258
+ function deviceId(dir) {
259
+ const file = path.join(dir, 'device-id');
260
+ try {
261
+ return fs.readFileSync(file, 'utf8').trim();
262
+ } catch {
263
+ /* first connection */
264
+ }
265
+ const id = crypto.randomBytes(16).toString('hex');
266
+ try {
267
+ fs.writeFileSync(file, id, { mode: 0o600, flag: 'wx' });
268
+ } catch {
269
+ return fs.readFileSync(file, 'utf8').trim();
270
+ }
271
+ return id;
272
+ }
273
+ async function syncCmd(o) {
274
+ const token = savedToken(o);
275
+ if (!token) throw new Error(NO_TOKEN);
276
+ const linked = selectedProjects(o);
277
+ if (!linked.length) {
278
+ if (!o.quiet) console.log('먼저 이 프로젝트 폴더에서 lostvibe setup을 실행해 주세요.');
279
+ return;
280
+ }
281
+ const site = siteOf(o);
282
+ const connection = await request(site, token, '/api/me/connection');
283
+ const remoteIds = new Set(connection.projects.map((p) => p.id));
284
+ const skipped = new Set((connection.skipped || []).map((p) => p.sourceId));
285
+ const allowed = linked.filter((p) => remoteIds.has(p.id) && !skipped.has(p.sourceId));
286
+ if (!allowed.length) return;
287
+ const dir = homeDir(o);
288
+ fs.mkdirSync(dir, { recursive: true });
289
+ const lock = path.join(dir, 'sync.lock');
290
+ try {
291
+ fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
292
+ } catch {
293
+ let running = false;
294
+ try {
295
+ process.kill(Number(fs.readFileSync(lock, 'utf8')), 0);
296
+ running = true;
297
+ } catch {
298
+ /* stale lock */
299
+ }
300
+ if (running) {
301
+ for (const p of allowed) fs.writeFileSync(path.join(dir, `sync-pending-${p.id}`), '1', { mode: 0o600 });
302
+ if (!o.quiet) console.log('실행 중인 동기화 뒤에 다시 갱신해요.');
303
+ return;
304
+ }
305
+ fs.rmSync(lock, { force: true });
306
+ fs.writeFileSync(lock, String(process.pid), { flag: 'wx' });
307
+ }
308
+ try {
309
+ const namespace = sha(`${site}:${token}`);
310
+ const sentFile = path.join(dir, `sent-v4-${namespace}.json`);
311
+ const sent = readJson(sentFile) || {};
312
+ const prevById = previousReported(dir);
313
+ const merge = mergeMap(allowed);
314
+ const land = await scanAll({
315
+ // Folders without git are placed by their parent folder, so every known parent is a root.
316
+ extraRoots: [
317
+ ...o.root,
318
+ ...allowed.filter((p) => p.cwd).map((p) => path.dirname(p.cwd)),
319
+ ...allowed.flatMap((p) => p.roots || []),
320
+ ],
321
+ cacheDir: dir,
322
+ only: new Set([...allowed.map((p) => p.sourceId), ...merge.keys()]),
323
+ merge,
324
+ onProgress: o.quiet ? () => {} : progress,
325
+ reportedFor: (id) => prevById.get(id),
326
+ });
327
+ doneProgress();
328
+ const clean = { ...land, projects: land.projects.map(stripLocal) };
329
+ // Kept apart from land.json so a sync never wipes the scan --all result.
330
+ writeAtomic(path.join(dir, 'sync-land.json'), clean);
331
+ if (!clean.projects.length) {
332
+ if (!o.quiet)
333
+ console.log(
334
+ '프로젝트를 찾지 못했다. 프로젝트 폴더에서 다시 실행하거나 --root 로 작업 폴더를 지정해 줘.',
335
+ );
336
+ return;
337
+ }
338
+ const allowedIds = new Set(allowed.map((p) => p.sourceId));
339
+ const selected = clean.projects.filter((p) => allowedIds.has(p.project.id));
340
+ const changed = [];
341
+ const rowKey = (r) => `${r.date}|${r.tool}|${r.model}`;
342
+ const metaOf = (p) => {
343
+ const { usage_days: _rows, ...metadata } = p;
344
+ return fingerprint({ ...metadata, verified: { ...metadata.verified, ai: null } });
345
+ };
346
+ for (const p of selected) {
347
+ const previous = sent[p.project.id] || {};
348
+ const rows = p.usage_days.filter((r) => previous.rows?.[rowKey(r)] !== fingerprint(r));
349
+ if (rows.length || previous.meta !== metaOf(p)) changed.push({ ...p, usage_days: rows });
350
+ }
351
+ if (!changed.length) {
352
+ for (const p of allowed) await sendStatus(site, token, p.id, 'records', true);
353
+ if (!o.quiet) console.log('바뀐 것이 없다. 보내지 않았다.');
354
+ return;
355
+ }
356
+ await push({
357
+ projects: changed,
358
+ token,
359
+ site,
360
+ yes: true,
361
+ quiet: o.quiet,
362
+ deviceId: deviceId(dir),
363
+ onAccepted: async (p) => {
364
+ const record = sent[p.project.id] || { rows: {} };
365
+ record.meta = metaOf(p);
366
+ for (const r of p.usage_days || []) record.rows[rowKey(r)] = fingerprint(r);
367
+ sent[p.project.id] = record;
368
+ writeAtomic(sentFile, sent);
369
+ const linkedProject = allowed.find((x) => x.sourceId === p.project.id);
370
+ if (linkedProject) await sendStatus(site, token, linkedProject.id, 'records', true);
371
+ },
372
+ });
373
+ } finally {
374
+ fs.rmSync(lock, { force: true });
375
+ for (const file of fs.readdirSync(dir).filter((n) => /^sync-pending-[a-f0-9]{12,32}$/.test(n))) {
376
+ fs.rmSync(path.join(dir, file), { force: true });
377
+ const id = file.slice('sync-pending-'.length);
378
+ const p = selectedProjects({ ...o, project: undefined, only: undefined }).find((p) => p.id === id);
379
+ if (!p) continue;
380
+ const log = fs.openSync(path.join(dir, 'sync.log'), 'a', 0o600);
381
+ const child = spawn(
382
+ process.execPath,
383
+ [process.argv[1], 'worker', '--quiet', '--out', dir, '--site', site, '--project', p.id],
384
+ { cwd: p.cwd && fs.existsSync(p.cwd) ? p.cwd : dir, detached: true, stdio: ['ignore', log, log], windowsHide: true },
385
+ );
386
+ child.unref();
387
+ fs.closeSync(log);
388
+ }
389
+ }
390
+ }
391
+ // The registered project a session folder belongs to: its own id, a merged id, or (without git) a parent folder.
392
+ function linkedFor(cwd, projects) {
393
+ const target = resolveCwd(cwd);
394
+ const ids = [projectIdentity(target.repoPath, target.key)];
395
+ if (!target.repoPath)
396
+ for (let d = path.dirname(cwd); d !== path.dirname(d) && d !== os.homedir(); d = path.dirname(d))
397
+ ids.push(projectIdentity(null, `folder:${path.basename(d).toLowerCase()}`));
398
+ for (const id of ids) {
399
+ const p = projects.find((x) => x.sourceId === id) || projects.find((x) => (x.merge || []).includes(id));
400
+ if (p) return p;
401
+ }
402
+ return null;
403
+ }
404
+ const STOP_GAP_MS = 10 * 60 * 1000;
405
+ function hookCmd(o) {
406
+ // Detached child survives the short SessionEnd deadline on both clients.
407
+ let cwd = process.cwd();
408
+ let event = null;
409
+ try {
410
+ const payload = o._[1]
411
+ ? JSON.parse(o._[1])
412
+ : !process.stdin.isTTY
413
+ ? JSON.parse(fs.readFileSync(0, 'utf8'))
414
+ : {};
415
+ if (payload.cwd && fs.existsSync(payload.cwd)) cwd = payload.cwd;
416
+ if (typeof payload.hook_event_name === 'string') event = payload.hook_event_name.slice(0, 40);
417
+ } catch {
418
+ /* no notification payload */
419
+ }
420
+ const dir = homeDir(o);
421
+ fs.mkdirSync(dir, { recursive: true });
422
+ const from = ['claude', 'codex'].includes(o.from) ? o.from : null;
423
+ const at = new Date().toISOString();
424
+ try {
425
+ const last = readJson(path.join(dir, 'hook-last.json')) || {};
426
+ const clients = { ...(last.clients || {}), ...(from ? { [from]: { event, at } } : {}) };
427
+ saveJson(path.join(dir, 'hook-last.json'), { from, event, at, clients });
428
+ } catch {
429
+ /* a record must not stop the sync */
430
+ }
431
+ const linked = linkedFor(cwd, selectedProjects(o));
432
+ if (!linked) return;
433
+ // Stop fires after every turn; one sync per 10 minutes is enough. SessionEnd always syncs.
434
+ const stamp = path.join(dir, `hook-spawn-${linked.id}`);
435
+ if (event === 'Stop') {
436
+ const prev = Number(readJson(stamp)?.at) || 0;
437
+ if (Date.now() - prev < STOP_GAP_MS) return;
438
+ }
439
+ fs.writeFileSync(stamp, JSON.stringify({ at: Date.now() }), { mode: 0o600 });
440
+ const log = fs.openSync(path.join(dir, 'sync.log'), 'a', 0o600);
441
+ const args = [process.argv[1], 'worker', '--quiet', '--project', linked.id];
442
+ if (o.site) args.push('--site', o.site);
443
+ if (o.out) args.push('--out', o.out);
444
+ if (o.only) args.push('--only', o.only);
445
+ const child = spawn(process.execPath, args, {
446
+ cwd,
447
+ detached: true,
448
+ stdio: ['ignore', log, log],
449
+ windowsHide: true,
450
+ });
451
+ child.unref();
452
+ fs.closeSync(log);
453
+ }
454
+
455
+ async function metricsTokenCmd(o) {
456
+ if (!/^[a-f0-9]{12,32}$/.test(o.project || '')) throw new Error('--project 에 프로젝트 ID가 필요하다.');
457
+ const token = savedToken(o);
458
+ if (!token) throw new Error(NO_TOKEN);
459
+ const file = path.resolve(o.file || '.lostvibe/metrics-token');
460
+ const relative = path.relative(process.cwd(), file);
461
+ if (relative.startsWith('..') || path.isAbsolute(relative) || /[\r\n]/.test(relative))
462
+ throw new Error('지표 토큰 파일은 현재 프로젝트 안에 저장해야 한다.');
463
+ try {
464
+ if (fs.lstatSync(file).isSymbolicLink()) throw new Error('토큰 파일에는 심볼릭 링크를 쓸 수 없다.');
465
+ } catch (e) {
466
+ if (e.code !== 'ENOENT') throw e;
467
+ }
468
+ let inGit = false;
469
+ try {
470
+ execFileSync('git', ['rev-parse', '--show-toplevel'], { stdio: 'ignore' });
471
+ inGit = true;
472
+ } catch {
473
+ /* no git */
474
+ }
475
+ if (inGit) {
476
+ let tracked = false;
477
+ try {
478
+ execFileSync('git', ['ls-files', '--error-unmatch', relative], { stdio: 'ignore' });
479
+ tracked = true;
480
+ } catch {
481
+ /* untracked */
482
+ }
483
+ if (tracked) throw new Error('git에 올라가는 파일에는 토큰을 저장하지 않는다. 다른 파일을 지정해 줘.');
484
+ try {
485
+ execFileSync('git', ['check-ignore', '-q', relative], { stdio: 'ignore' });
486
+ } catch {
487
+ fs.appendFileSync(
488
+ '.gitignore',
489
+ `\n/${relative
490
+ .split(path.sep)
491
+ .join('/')
492
+ .replace(/([*?\[\]\\!# ])/g, '\\$1')}\n`,
493
+ );
494
+ }
495
+ }
496
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
497
+ const parent = fs.realpathSync(path.dirname(file));
498
+ const cwd = fs.realpathSync(process.cwd());
499
+ if (parent !== cwd && !parent.startsWith(cwd + path.sep))
500
+ throw new Error('프로젝트 밖을 가리키는 폴더에는 토큰을 저장하지 않는다.');
501
+ const site = siteOf(o);
502
+ if (fs.existsSync(file)) {
503
+ const current = fs.readFileSync(file, 'utf8').trim();
504
+ const check = await fetch(`${site}/api/projects/${o.project}`, {
505
+ headers: { Authorization: `Bearer ${current}` },
506
+ signal: AbortSignal.timeout(15000),
507
+ });
508
+ if (
509
+ check.ok &&
510
+ (check.headers.get('x-lostvibe-token-scope') !== 'metrics' ||
511
+ check.headers.get('x-lostvibe-token-project') !== o.project)
512
+ )
513
+ throw new Error('기존 파일은 이 프로젝트의 지표 전용 토큰이 아니다. 다른 파일을 지정해 줘.');
514
+ if (check.ok) {
515
+ fs.chmodSync(file, 0o600);
516
+ console.log('기존 지표 전용 토큰을 재사용한다. 값은 출력하지 않았다.');
517
+ return;
518
+ }
519
+ if (check.status !== 401) throw new Error('기존 토큰 상태를 확인하지 못했다. 덮어쓰지 않았다.');
520
+ }
521
+ let res;
522
+ for (let retry = 0; ; retry++) {
523
+ res = await fetch(`${site}/api/me/token`, {
524
+ method: 'POST',
525
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
526
+ body: JSON.stringify({ projectId: o.project }),
527
+ signal: AbortSignal.timeout(15000),
528
+ });
529
+ if (res.status !== 429 || retry >= 3) break;
530
+ await new Promise((r) =>
531
+ setTimeout(r, Math.max(1, Math.min(30, Number(res.headers.get('retry-after')) || 2)) * 1000),
532
+ );
533
+ }
534
+ if (!res.ok) throw new Error(`지표 전용 토큰 발급 실패 (${res.status}). 응답 본문은 출력하지 않았다.`);
535
+ const body = await res.json();
536
+ if (typeof body.token !== 'string' || !/^lv_[A-Za-z0-9_-]+$/.test(body.token))
537
+ throw new Error('발급 응답을 읽지 못했다.');
538
+ fs.writeFileSync(file, body.token, {
539
+ mode: 0o600,
540
+ flag:
541
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0),
542
+ });
543
+ fs.chmodSync(file, 0o600);
544
+ console.log('이 프로젝트의 지표 전용 토큰을 권한 600으로 저장했다. 값은 출력하지 않았다.');
545
+ }
546
+
547
+ async function pushCmd(o) {
548
+ const token = savedToken(o);
549
+ if (!token) {
550
+ console.error(NO_TOKEN);
551
+ process.exit(1);
552
+ }
553
+ const out = homeDir(o);
554
+ const land = readJson(path.join(out, 'land.json'));
555
+ if (!land) {
556
+ console.error('먼저 lostvibe scan --all 을 실행해야 한다.');
557
+ process.exit(1);
558
+ }
559
+ const names = o.only ? new Set(o.only.split(',').map((s) => s.trim().toLowerCase())) : null;
560
+ const projects = land.projects.filter((p) => !names || names.has(p.project.name.toLowerCase()));
561
+ if (!projects.length) {
562
+ console.error('보낼 프로젝트가 없다.');
563
+ process.exit(1);
564
+ }
565
+ await push({
566
+ projects,
567
+ token,
568
+ deviceId: deviceId(out),
569
+ site: o.site || process.env.LOSTVIBE_SITE || DEFAULT_SITE,
570
+ yes: o.yes,
571
+ });
572
+ }
573
+
574
+ async function setupCmd(o) {
575
+ const token = savedToken(o);
576
+ if (!token) throw new Error(NO_TOKEN);
577
+ const ids = (o.merge || '')
578
+ .split(',')
579
+ .map((x) => x.trim())
580
+ .filter(Boolean);
581
+ // Read the scan list before touching anything, so a wrong id changes nothing.
582
+ const scan = o.source || ids.length ? readScanAll(o) : [];
583
+ let entry = null;
584
+ if (o.source) {
585
+ entry = scan.find((e) => e.id === o.source);
586
+ if (!entry) throw new Error('scan --all 목록에 없는 id예요. scan --all --json 을 다시 실행해 확인해 주세요.');
587
+ if (entry.setup === 'folder' && entry.path)
588
+ throw new Error(`이 프로젝트는 폴더가 있어요. 그 폴더에서 setup을 실행해 주세요: ${entry.path}`);
589
+ }
590
+ const merged = ids.map((id) => {
591
+ const e = scan.find((x) => x.id === id);
592
+ if (!e) throw new Error(`scan --all 목록에 없는 id예요: ${id}`);
593
+ return e;
594
+ });
595
+ // Validate the account before replacing a saved key.
596
+ await request(siteOf(o), token, '/api/me/connection');
597
+ if (o.token) loginCmd(o);
598
+ let p = entry
599
+ ? await registerProject(o, token, { sourceId: entry.id, name: entry.name, cwd: null, roots: rootsOf(entry) })
600
+ : await registerCurrent(o, token);
601
+ if (merged.length) {
602
+ const own = selectedProjects({ ...o, project: undefined, only: undefined });
603
+ for (const e of merged) {
604
+ if (e.id === p.sourceId) throw new Error('자기 자신은 합칠 수 없어요.');
605
+ const other = own.find((x) => x.sourceId === e.id);
606
+ if (other) throw new Error(`이미 따로 연결한 프로젝트는 합칠 수 없어요: ${other.name}`);
607
+ }
608
+ p = setMerge(o, p.sourceId, merged.map((e) => e.id), merged.flatMap(rootsOf));
609
+ console.log(`합친 프로젝트: ${merged.map((e) => e.name).join(', ')}`);
610
+ }
611
+ await syncCmd({ ...o, project: p.id });
612
+ if (!o.noHooks) {
613
+ try {
614
+ for (const line of installLines(o, installHooks(o))) console.log(line);
615
+ } catch (e) {
616
+ console.log(`자동 갱신 설정 필요: ${e.message}`);
617
+ }
618
+ }
619
+ console.log(`프로젝트 연결됨 (id ${p.id}) · 공개 범위는 사이트에서 직접 선택해 주세요. ${siteOf(o)}/?p=${p.id}`);
620
+ }
621
+ async function draftCmd(o) {
622
+ if (!o.file) throw new Error('--file 에 프로젝트 JSON 파일을 지정해 주세요.');
623
+ const source = fs.readFileSync(path.resolve(o.file), 'utf8');
624
+ if (Buffer.byteLength(source) > 64000) throw new Error('파일은 64KB까지예요.');
625
+ const data = JSON.parse(source);
626
+ if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('프로젝트 JSON이 필요해요.');
627
+ const validOf = (v) => Number.isInteger(v) && v >= 1 && v <= 200;
628
+ let importOf = data.importOf;
629
+ if (o.of !== undefined) {
630
+ importOf = /^\d+$/.test(o.of) ? Number(o.of) : NaN;
631
+ if (!validOf(importOf)) throw new Error('--of 에는 1부터 200까지의 정수를 넣어 주세요.');
632
+ } else if (importOf !== undefined && !validOf(importOf)) throw new Error('importOf 는 1부터 200까지의 정수예요.');
633
+ // --project picks a linked project by id from any folder (needed for setup --source projects).
634
+ const p = o.project
635
+ ? selectedProjects(o).find((x) => x.id === o.project)
636
+ : selectedProjects(o).find((x) => x.sourceId === currentIdentity().sourceId);
637
+ if (!p) throw new Error(o.project ? '연결한 프로젝트 중에 이 id가 없어요.' : '이 폴더에서 먼저 setup을 실행해 주세요.');
638
+ // Never send file-provided publication settings or arbitrary fields.
639
+ const keys = [
640
+ 'version',
641
+ 'name',
642
+ 'oneLiner',
643
+ 'tags',
644
+ 'idea',
645
+ 'design',
646
+ 'texts',
647
+ 'url',
648
+ 'kind',
649
+ 'look',
650
+ 'launchDate',
651
+ 'launched',
652
+ 'resultName',
653
+ 'resultValue',
654
+ 'resultGoal',
655
+ 'result2Name',
656
+ 'result2Value',
657
+ 'result2Goal',
658
+ ];
659
+ const safe = Object.fromEntries(keys.filter((k) => k in data).map((k) => [k, data[k]]));
660
+ if (importOf !== undefined) safe.importOf = importOf;
661
+ await request(siteOf(o), savedToken(o), `/api/me/projects/${p.id}`, 'PATCH', safe);
662
+ console.log(`비공개 검토 초안을 보냈어요 (id ${p.id}). ${siteOf(o)}/?p=${p.id}`);
663
+ }
664
+ async function collectorCmd(o) {
665
+ if (o._[1] === 'remove') {
666
+ removeCollector(o);
667
+ console.log('성과 자동 갱신 연결을 해제했어요.');
668
+ return;
669
+ }
670
+ if (o._[1] === 'test') {
671
+ await testCollector(o);
672
+ return;
673
+ }
674
+ if (o._[1] !== 'add') throw new Error('collector add, test 또는 remove를 지정해 주세요.');
675
+ if (!['result', 'result2'].includes(o.slot)) throw new Error('--slot result 또는 result2가 필요해요.');
676
+ const p = selectedProjects(o).find((p) => p.id === o.project);
677
+ if (!p) throw new Error('먼저 프로젝트를 연결해 주세요.');
678
+ collectorCwd(p.cwd, o.cwd);
679
+ const info = await request(siteOf(o), savedToken(o), `/api/projects/${p.id}`);
680
+ const nameKey = o.slot === 'result' ? 'resultName' : 'result2Name';
681
+ if (!(info[o.slot]?.name || info.pendingDraft?.[nameKey]))
682
+ throw new Error(`성과 이름이 아직 없어요. project.json에 ${nameKey}을 넣고 draft를 먼저 실행해 주세요.`);
683
+ const previous = process.cwd();
684
+ try {
685
+ process.chdir(p.cwd);
686
+ await metricsTokenCmd({ ...o, file: `.lostvibe/metrics-token-${p.id}` });
687
+ } finally {
688
+ process.chdir(previous);
689
+ }
690
+ addCollector(o);
691
+ console.log('성과 조회 명령을 등록했어요. collect로 실제 값을 확인할 수 있어요.');
692
+ }
693
+ async function workerCmd(o) {
694
+ let failed = false;
695
+ let recordsOk = true;
696
+ try {
697
+ await syncCmd(o);
698
+ } catch {
699
+ failed = true;
700
+ recordsOk = false;
701
+ console.error(`${new Date().toISOString()} 작업 기록 갱신 실패`);
702
+ for (const p of selectedProjects(o)) await sendStatus(siteOf(o), savedToken(o), p.id, 'records', false);
703
+ }
704
+ try {
705
+ await collect(o, savedToken(o));
706
+ } catch {
707
+ failed = true;
708
+ console.error(`${new Date().toISOString()} 성과 갱신 실패 · 이전 값 유지`);
709
+ }
710
+ console.log(`${new Date().toISOString()} 자동 갱신 ${failed ? '일부 실패' : '완료'}`);
711
+ try {
712
+ const file = path.join(homeDir(o), 'last-sync.json');
713
+ const at = new Date().toISOString();
714
+ // lastOkAt: the last time records reached the site.
715
+ saveJson(file, { at, ok: !failed, lastOkAt: recordsOk ? at : readJson(file)?.lastOkAt || null });
716
+ } catch {
717
+ /* status file only */
718
+ }
719
+ if (failed) process.exitCode = 1;
720
+ }
721
+
722
+ const o = parse(process.argv.slice(2));
723
+ const cmd = o._[0];
724
+ try {
725
+ if (o.help || !cmd) console.log(HELP);
726
+ else if (cmd === 'scan' && o.all) await scanMany(o);
727
+ else if (cmd === 'scan') await scanOne(o);
728
+ else if (cmd === 'push') await pushCmd(o);
729
+ else if (cmd === 'login') loginCmd(o);
730
+ else if (cmd === 'sync') await syncCmd(o);
731
+ else if (cmd === 'hook') hookCmd(o);
732
+ else if (cmd === 'metrics-token') await metricsTokenCmd(o);
733
+ else if (cmd === 'setup') await setupCmd(o);
734
+ else if (cmd === 'draft') await draftCmd(o);
735
+ else if (cmd === 'collect') await collect(o, savedToken(o));
736
+ else if (cmd === 'collector') await collectorCmd(o);
737
+ else if (cmd === 'worker') await workerCmd(o);
738
+ else if (cmd === 'hooks') {
739
+ if (o._[1] === 'install') for (const line of installLines(o, installHooks(o))) console.log(line);
740
+ else if (o._[1] === 'remove') {
741
+ removeHooks(o);
742
+ console.log('Lostvibe 훅만 제거했어요. 다른 훅은 그대로예요.');
743
+ } else if (o._[1] === 'status') for (const line of statusLines(hookStatus(o))) console.log(line);
744
+ else throw new Error('hooks install / status / remove');
745
+ } else {
746
+ console.log(HELP);
747
+ process.exit(1);
748
+ }
749
+ } catch (e) {
750
+ doneProgress();
751
+ console.error(e.message);
752
+ process.exit(1);
753
+ }