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