lostvibe 0.7.0 → 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.
- package/README.md +45 -12
- package/bin/lostvibe.mjs +177 -56
- package/package.json +1 -1
- package/src/collectors.mjs +74 -5
- package/src/connections.mjs +35 -9
- package/src/hooks.mjs +309 -69
- package/src/scan.mjs +50 -6
- package/src/table.mjs +8 -3
package/README.md
CHANGED
|
@@ -16,25 +16,58 @@ AI 코딩 도구(Claude Code, Codex 등)의 로컬 로그와 git에서 **날짜
|
|
|
16
16
|
AI가 아래 명령을 대신 실행한다.
|
|
17
17
|
|
|
18
18
|
```
|
|
19
|
-
npx -y lostvibe@0.
|
|
20
|
-
npx -y lostvibe@0.
|
|
21
|
-
npx -y lostvibe@0.
|
|
19
|
+
npx -y lostvibe@0.8.0 setup --token <사이트에서 받은 토큰>
|
|
20
|
+
npx -y lostvibe@0.8.0 draft --file .lostvibe/project.json
|
|
21
|
+
npx -y lostvibe@0.8.0 sync
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
여러 프로젝트를 한 번에 가져올 때:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
lostvibe scan --all --json # 목록(JSON)만 출력. 고를 때 쓴다
|
|
28
|
+
lostvibe setup # setup:"folder" 인 프로젝트: 그 폴더에서 (git 없어도 된다)
|
|
29
|
+
lostvibe setup --source <id> # setup:"source" 인 프로젝트: 로그만 남은 것, 어느 폴더에서나
|
|
30
|
+
lostvibe setup --merge <id>[,<id>] # 같은 프로젝트의 다른 폴더 기록을 합친다 (주 프로젝트 폴더에서)
|
|
31
|
+
lostvibe draft --file .lostvibe/project.json --of <N> # N: 이번에 가져오는 프로젝트 수 (1~200)
|
|
32
|
+
lostvibe draft --file project.json --project <프로젝트 id> --of <N> # 폴더 없는 프로젝트
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`scan --all --json` 출력: `{"projects":[{"n","id","name","tokens","last","path","exists","git","setup","url"}]}`.
|
|
36
|
+
경로가 들어간 전체 목록은 `~/.lostvibe/scan-all.json`(권한 600)에만 저장되고 보내지 않는다.
|
|
37
|
+
|
|
24
38
|
토큰은 비밀이다. 화면·코드·저장소 어디에도 남기지 않는다.
|
|
25
39
|
|
|
40
|
+
## 자동 갱신 훅
|
|
41
|
+
|
|
42
|
+
`hooks install`은 Claude Code와 Codex에 `Stop`, `SessionEnd` 훅을 넣는다. 다른 훅은 건드리지 않고,
|
|
43
|
+
예전 버전이 넣은 Lost Vibe 훅은 찾아서 바꾼다. 훅은 항상 `~/.lostvibe/runtime/current`를 실행하므로
|
|
44
|
+
버전을 올려도 명령이 같아서 Codex를 다시 승인할 필요가 없다. `Stop`은 프로젝트마다 10분에 한 번만 동기화한다.
|
|
45
|
+
|
|
46
|
+
`hooks status`는 클라이언트마다 `미설치` / `설치됨·승인 전` / `승인됨·실행 기록 없음` / `실행됨`을 보여 준다.
|
|
47
|
+
Claude Code는 승인이 필요 없다. Codex는 Codex CLI에서 `/hooks`를 열고 Lost Vibe 항목 2개를 Trust 해야 한다.
|
|
48
|
+
|
|
26
49
|
## 명령
|
|
27
50
|
|
|
28
51
|
```
|
|
29
|
-
lostvibe scan [폴더]
|
|
30
|
-
lostvibe scan --all
|
|
31
|
-
lostvibe setup
|
|
32
|
-
lostvibe
|
|
33
|
-
lostvibe
|
|
34
|
-
lostvibe
|
|
35
|
-
|
|
36
|
-
lostvibe
|
|
37
|
-
lostvibe
|
|
52
|
+
lostvibe scan [폴더] 폴더 하나를 읽어 그 폴더에 lostvibe.json 저장
|
|
53
|
+
lostvibe scan --all [--json] 로그 전체에서 내 프로젝트를 전부 찾아 표(또는 JSON)로 출력
|
|
54
|
+
lostvibe setup [--no-hooks] 현재 폴더의 프로젝트만 비공개로 등록하고 이 컴퓨터와 연결
|
|
55
|
+
lostvibe setup --source ID 폴더 없이 로그만 남은 프로젝트를 등록
|
|
56
|
+
lostvibe setup --merge ID[,ID] scan --all 의 다른 프로젝트를 이 프로젝트에 합친다
|
|
57
|
+
lostvibe draft --file F [--of N] [--project ID]
|
|
58
|
+
AI가 만든 JSON을 검토할 초안으로 보낸다
|
|
59
|
+
lostvibe sync 등록한 프로젝트의 바뀐 기록만 올린다
|
|
60
|
+
lostvibe hooks install 기존 훅을 보존하며 자동 갱신 연결
|
|
61
|
+
lostvibe hooks status 설치·승인·실행 상태 확인
|
|
62
|
+
lostvibe hooks remove Lostvibe가 추가한 항목만 제거
|
|
63
|
+
lostvibe collector add --project ID --slot result --command-json '[...]' [--cwd 폴더]
|
|
64
|
+
성과 조회 명령 등록 (--cwd 는 프로젝트 폴더 안)
|
|
65
|
+
lostvibe collector test --command-json '[...]' [--cwd 폴더]
|
|
66
|
+
보내지 않고 실행만 해서 "값: N (날짜)" 또는 실패 종류를 보여 준다
|
|
67
|
+
lostvibe collect 연결한 성과를 읽어 갱신
|
|
38
68
|
```
|
|
39
69
|
|
|
70
|
+
`collector test` 실패 종류: 환경 변수 없음 / 모듈을 찾지 못함 / 테이블 없음 / DB 연결 실패 / JSON 형식 오류 / 시간 초과 / 알 수 없는 오류.
|
|
71
|
+
명령의 출력과 오류 내용은 화면에 내보내지 않는다.
|
|
72
|
+
|
|
40
73
|
Node 22.15 이상. MIT.
|
package/bin/lostvibe.mjs
CHANGED
|
@@ -4,13 +4,16 @@ import fs from 'node:fs';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { execFileSync, spawn } from 'node:child_process';
|
|
7
|
-
import { scanAll, stripLocal, readJson } from '../src/scan.mjs';
|
|
7
|
+
import { scanAll, stripLocal, readJson, scanEntry } from '../src/scan.mjs';
|
|
8
8
|
import { landTable, projectTable } from '../src/table.mjs';
|
|
9
9
|
import { askReported } from '../src/reported.mjs';
|
|
10
10
|
import { resolveCwd, sha, projectIdentity } from '../src/gitinfo.mjs';
|
|
11
11
|
import { push } from '../src/push.mjs';
|
|
12
12
|
import {
|
|
13
13
|
registerCurrent,
|
|
14
|
+
registerProject,
|
|
15
|
+
setMerge,
|
|
16
|
+
mergeMap,
|
|
14
17
|
activeConnection,
|
|
15
18
|
selectedProjects,
|
|
16
19
|
siteOf,
|
|
@@ -19,8 +22,8 @@ import {
|
|
|
19
22
|
saveJson,
|
|
20
23
|
currentIdentity,
|
|
21
24
|
} from '../src/connections.mjs';
|
|
22
|
-
import { addCollector, removeCollector, collect } from '../src/collectors.mjs';
|
|
23
|
-
import { installHooks, removeHooks, hookStatus } from '../src/hooks.mjs';
|
|
25
|
+
import { addCollector, removeCollector, collect, testCollector, collectorCwd } from '../src/collectors.mjs';
|
|
26
|
+
import { installHooks, removeHooks, hookStatus, statusLines, installLines } from '../src/hooks.mjs';
|
|
24
27
|
|
|
25
28
|
const DEFAULT_SITE = 'https://lostvibesociety.com';
|
|
26
29
|
|
|
@@ -44,6 +47,12 @@ function parse(argv) {
|
|
|
44
47
|
else if (a === '--client') o.client = argv[++i];
|
|
45
48
|
else if (a === '--no-hooks') o.noHooks = true;
|
|
46
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];
|
|
47
56
|
else if (a === '-h' || a === '--help') o.help = true;
|
|
48
57
|
else o._.push(a);
|
|
49
58
|
}
|
|
@@ -54,15 +63,22 @@ const HELP = `lostvibe - 프로젝트의 객관 지표를 뽑는다
|
|
|
54
63
|
|
|
55
64
|
lostvibe scan [폴더] 폴더 하나를 읽어 그 폴더에 lostvibe.json 저장
|
|
56
65
|
lostvibe scan --all 로그 전체에서 내 프로젝트를 전부 찾아 표로 출력
|
|
66
|
+
lostvibe scan --all --json 같은 목록을 JSON 하나로만 출력 (~/.lostvibe/scan-all.json 에도 저장)
|
|
57
67
|
lostvibe login --token T 이 컴퓨터를 내 땅에 연결한다
|
|
58
|
-
lostvibe setup 현재
|
|
59
|
-
lostvibe
|
|
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: 가져오는 프로젝트 수)
|
|
60
74
|
lostvibe sync 등록한 프로젝트의 바뀐 기록만 올린다
|
|
61
|
-
lostvibe hooks install 기존 훅을 보존하며 자동 갱신 연결
|
|
62
|
-
lostvibe hooks status
|
|
63
|
-
lostvibe hooks remove Lostvibe
|
|
64
|
-
lostvibe collector add --project ID --slot result --command-json '["node","script.mjs"]'
|
|
65
|
-
프로젝트 성과 조회 명령을 등록
|
|
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
|
+
보내지 않고 실행만 해서 값을 확인
|
|
66
82
|
lostvibe collector remove --project ID --slot result
|
|
67
83
|
lostvibe collect 연결한 성과를 읽어 갱신
|
|
68
84
|
lostvibe push --all 결과를 확인하고 올린다
|
|
@@ -76,6 +92,7 @@ const HELP = `lostvibe - 프로젝트의 객관 지표를 뽑는다
|
|
|
76
92
|
--root 폴더 이 폴더 바로 아래 레포도 후보에 넣는다 (--all)
|
|
77
93
|
--detail 프로젝트별 상세 표도 출력 (--all)
|
|
78
94
|
--only a,b 등록한 프로젝트 중 선택 (이름 또는 ID)
|
|
95
|
+
--no-hooks setup 에서 훅 설치를 건너뛴다
|
|
79
96
|
--yes push 확인을 건너뛴다
|
|
80
97
|
--quiet 출력 없이 돈다 (sync)
|
|
81
98
|
--site URL push 대상 (기본 ${DEFAULT_SITE})
|
|
@@ -107,20 +124,34 @@ async function scanMany(o) {
|
|
|
107
124
|
const out = path.resolve(o.out || process.env.LOSTVIBE_DIR || path.join(os.homedir(), '.lostvibe'));
|
|
108
125
|
fs.mkdirSync(out, { recursive: true });
|
|
109
126
|
const landFile = path.join(out, 'land.json');
|
|
110
|
-
const
|
|
111
|
-
const prevById = new Map((prev?.projects || []).map((p) => [p.project.id, p.reported]));
|
|
127
|
+
const prevById = previousReported(out);
|
|
112
128
|
const land = await scanAll({
|
|
113
129
|
extraRoots: o.root,
|
|
114
|
-
onProgress: progress,
|
|
130
|
+
onProgress: o.json ? () => {} : progress,
|
|
115
131
|
cacheDir: homeDir(o),
|
|
116
132
|
reportedFor: (id) => prevById.get(id),
|
|
117
133
|
});
|
|
118
134
|
doneProgress();
|
|
119
|
-
if (o.ask) {
|
|
135
|
+
if (o.ask && !o.json) {
|
|
120
136
|
for (const p of land.projects) p.reported = await askReported(p.project.name, p.reported);
|
|
121
137
|
}
|
|
122
138
|
const clean = { ...land, projects: land.projects.map(stripLocal) };
|
|
123
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
|
+
}
|
|
124
155
|
console.log(landTable(land));
|
|
125
156
|
if (o.detail) for (const p of land.projects) console.log(projectTable(p));
|
|
126
157
|
console.log(`\n프로젝트 ${land.projects.length}개. 어느 프로젝트에도 속하지 않는 임시 폴더 토큰은 뺐다.`);
|
|
@@ -128,6 +159,21 @@ async function scanMany(o) {
|
|
|
128
159
|
console.log(`저장: ${landFile}`);
|
|
129
160
|
}
|
|
130
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
|
+
|
|
131
177
|
async function scanOne(o) {
|
|
132
178
|
const dir = path.resolve(o._[1] || '.');
|
|
133
179
|
const file = path.join(dir, 'lostvibe.json');
|
|
@@ -263,18 +309,25 @@ async function syncCmd(o) {
|
|
|
263
309
|
const namespace = sha(`${site}:${token}`);
|
|
264
310
|
const sentFile = path.join(dir, `sent-v4-${namespace}.json`);
|
|
265
311
|
const sent = readJson(sentFile) || {};
|
|
266
|
-
const
|
|
267
|
-
const
|
|
312
|
+
const prevById = previousReported(dir);
|
|
313
|
+
const merge = mergeMap(allowed);
|
|
268
314
|
const land = await scanAll({
|
|
269
|
-
|
|
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
|
+
],
|
|
270
321
|
cacheDir: dir,
|
|
271
|
-
only: new Set(allowed.map((p) => p.sourceId)),
|
|
322
|
+
only: new Set([...allowed.map((p) => p.sourceId), ...merge.keys()]),
|
|
323
|
+
merge,
|
|
272
324
|
onProgress: o.quiet ? () => {} : progress,
|
|
273
325
|
reportedFor: (id) => prevById.get(id),
|
|
274
326
|
});
|
|
275
327
|
doneProgress();
|
|
276
328
|
const clean = { ...land, projects: land.projects.map(stripLocal) };
|
|
277
|
-
|
|
329
|
+
// Kept apart from land.json so a sync never wipes the scan --all result.
|
|
330
|
+
writeAtomic(path.join(dir, 'sync-land.json'), clean);
|
|
278
331
|
if (!clean.projects.length) {
|
|
279
332
|
if (!o.quiet)
|
|
280
333
|
console.log(
|
|
@@ -328,16 +381,31 @@ async function syncCmd(o) {
|
|
|
328
381
|
const child = spawn(
|
|
329
382
|
process.execPath,
|
|
330
383
|
[process.argv[1], 'worker', '--quiet', '--out', dir, '--site', site, '--project', p.id],
|
|
331
|
-
{ cwd: p.cwd, detached: true, stdio: ['ignore', log, log], windowsHide: true },
|
|
384
|
+
{ cwd: p.cwd && fs.existsSync(p.cwd) ? p.cwd : dir, detached: true, stdio: ['ignore', log, log], windowsHide: true },
|
|
332
385
|
);
|
|
333
386
|
child.unref();
|
|
334
387
|
fs.closeSync(log);
|
|
335
388
|
}
|
|
336
389
|
}
|
|
337
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;
|
|
338
405
|
function hookCmd(o) {
|
|
339
406
|
// Detached child survives the short SessionEnd deadline on both clients.
|
|
340
407
|
let cwd = process.cwd();
|
|
408
|
+
let event = null;
|
|
341
409
|
try {
|
|
342
410
|
const payload = o._[1]
|
|
343
411
|
? JSON.parse(o._[1])
|
|
@@ -345,15 +413,30 @@ function hookCmd(o) {
|
|
|
345
413
|
? JSON.parse(fs.readFileSync(0, 'utf8'))
|
|
346
414
|
: {};
|
|
347
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);
|
|
348
417
|
} catch {
|
|
349
418
|
/* no notification payload */
|
|
350
419
|
}
|
|
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
420
|
const dir = homeDir(o);
|
|
356
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 });
|
|
357
440
|
const log = fs.openSync(path.join(dir, 'sync.log'), 'a', 0o600);
|
|
358
441
|
const args = [process.argv[1], 'worker', '--quiet', '--project', linked.id];
|
|
359
442
|
if (o.site) args.push('--site', o.site);
|
|
@@ -491,20 +574,49 @@ async function pushCmd(o) {
|
|
|
491
574
|
async function setupCmd(o) {
|
|
492
575
|
const token = savedToken(o);
|
|
493
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
|
+
});
|
|
494
595
|
// Validate the account before replacing a saved key.
|
|
495
596
|
await request(siteOf(o), token, '/api/me/connection');
|
|
496
597
|
if (o.token) loginCmd(o);
|
|
497
|
-
|
|
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
|
+
}
|
|
498
611
|
await syncCmd({ ...o, project: p.id });
|
|
499
612
|
if (!o.noHooks) {
|
|
500
613
|
try {
|
|
501
|
-
const
|
|
502
|
-
console.log(`자동 갱신 설정: ${clients.join(', ')}. Codex 훅은 /hooks에서 신뢰 검토 후 실행돼요.`);
|
|
614
|
+
for (const line of installLines(o, installHooks(o))) console.log(line);
|
|
503
615
|
} catch (e) {
|
|
504
616
|
console.log(`자동 갱신 설정 필요: ${e.message}`);
|
|
505
617
|
}
|
|
506
618
|
}
|
|
507
|
-
console.log(`프로젝트 연결됨 · 공개 범위는 사이트에서 직접 선택해 주세요. ${siteOf(o)}/?p=${p.id}`);
|
|
619
|
+
console.log(`프로젝트 연결됨 (id ${p.id}) · 공개 범위는 사이트에서 직접 선택해 주세요. ${siteOf(o)}/?p=${p.id}`);
|
|
508
620
|
}
|
|
509
621
|
async function draftCmd(o) {
|
|
510
622
|
if (!o.file) throw new Error('--file 에 프로젝트 JSON 파일을 지정해 주세요.');
|
|
@@ -512,9 +624,17 @@ async function draftCmd(o) {
|
|
|
512
624
|
if (Buffer.byteLength(source) > 64000) throw new Error('파일은 64KB까지예요.');
|
|
513
625
|
const data = JSON.parse(source);
|
|
514
626
|
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('프로젝트 JSON이 필요해요.');
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
if (
|
|
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을 실행해 주세요.');
|
|
518
638
|
// Never send file-provided publication settings or arbitrary fields.
|
|
519
639
|
const keys = [
|
|
520
640
|
'version',
|
|
@@ -537,8 +657,9 @@ async function draftCmd(o) {
|
|
|
537
657
|
'result2Goal',
|
|
538
658
|
];
|
|
539
659
|
const safe = Object.fromEntries(keys.filter((k) => k in data).map((k) => [k, data[k]]));
|
|
660
|
+
if (importOf !== undefined) safe.importOf = importOf;
|
|
540
661
|
await request(siteOf(o), savedToken(o), `/api/me/projects/${p.id}`, 'PATCH', safe);
|
|
541
|
-
console.log(`비공개 검토 초안을
|
|
662
|
+
console.log(`비공개 검토 초안을 보냈어요 (id ${p.id}). ${siteOf(o)}/?p=${p.id}`);
|
|
542
663
|
}
|
|
543
664
|
async function collectorCmd(o) {
|
|
544
665
|
if (o._[1] === 'remove') {
|
|
@@ -546,15 +667,19 @@ async function collectorCmd(o) {
|
|
|
546
667
|
console.log('성과 자동 갱신 연결을 해제했어요.');
|
|
547
668
|
return;
|
|
548
669
|
}
|
|
549
|
-
if (o._[1]
|
|
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가 필요해요.');
|
|
550
676
|
const p = selectedProjects(o).find((p) => p.id === o.project);
|
|
551
677
|
if (!p) throw new Error('먼저 프로젝트를 연결해 주세요.');
|
|
678
|
+
collectorCwd(p.cwd, o.cwd);
|
|
552
679
|
const info = await request(siteOf(o), savedToken(o), `/api/projects/${p.id}`);
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
)
|
|
557
|
-
throw new Error('사이트에서 성과를 먼저 저장해 주세요.');
|
|
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를 먼저 실행해 주세요.`);
|
|
558
683
|
const previous = process.cwd();
|
|
559
684
|
try {
|
|
560
685
|
process.chdir(p.cwd);
|
|
@@ -567,10 +692,12 @@ async function collectorCmd(o) {
|
|
|
567
692
|
}
|
|
568
693
|
async function workerCmd(o) {
|
|
569
694
|
let failed = false;
|
|
695
|
+
let recordsOk = true;
|
|
570
696
|
try {
|
|
571
697
|
await syncCmd(o);
|
|
572
698
|
} catch {
|
|
573
699
|
failed = true;
|
|
700
|
+
recordsOk = false;
|
|
574
701
|
console.error(`${new Date().toISOString()} 작업 기록 갱신 실패`);
|
|
575
702
|
for (const p of selectedProjects(o)) await sendStatus(siteOf(o), savedToken(o), p.id, 'records', false);
|
|
576
703
|
}
|
|
@@ -581,6 +708,14 @@ async function workerCmd(o) {
|
|
|
581
708
|
console.error(`${new Date().toISOString()} 성과 갱신 실패 · 이전 값 유지`);
|
|
582
709
|
}
|
|
583
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
|
+
}
|
|
584
719
|
if (failed) process.exitCode = 1;
|
|
585
720
|
}
|
|
586
721
|
|
|
@@ -601,26 +736,12 @@ try {
|
|
|
601
736
|
else if (cmd === 'collector') await collectorCmd(o);
|
|
602
737
|
else if (cmd === 'worker') await workerCmd(o);
|
|
603
738
|
else if (cmd === 'hooks') {
|
|
604
|
-
if (o._[1] === 'install')
|
|
605
|
-
console.log(
|
|
606
|
-
`설치됨: ${installHooks(o).join(', ')}. Codex는 /hooks에서 신뢰 검토가 필요해요. 실제 이벤트 검증 전에는 연결 완료로 보지 마세요.`,
|
|
607
|
-
);
|
|
739
|
+
if (o._[1] === 'install') for (const line of installLines(o, installHooks(o))) console.log(line);
|
|
608
740
|
else if (o._[1] === 'remove') {
|
|
609
741
|
removeHooks(o);
|
|
610
|
-
console.log('Lostvibe 훅만 제거했어요.');
|
|
611
|
-
} else if (o._[1] === 'status')
|
|
612
|
-
|
|
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');
|
|
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');
|
|
624
745
|
} else {
|
|
625
746
|
console.log(HELP);
|
|
626
747
|
process.exit(1);
|
package/package.json
CHANGED
package/src/collectors.mjs
CHANGED
|
@@ -37,6 +37,23 @@ export function parseCollected(source) {
|
|
|
37
37
|
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`,
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
+
// A collector cwd is stored relative to the project folder and must stay inside it.
|
|
41
|
+
export function collectorCwd(projectDir, dir) {
|
|
42
|
+
if (!projectDir) throw new Error('폴더가 없는 프로젝트에는 성과 명령을 연결할 수 없어요.');
|
|
43
|
+
if (!dir) return '.';
|
|
44
|
+
let base, target;
|
|
45
|
+
try {
|
|
46
|
+
base = fs.realpathSync(projectDir);
|
|
47
|
+
target = fs.realpathSync(path.resolve(dir));
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error('--cwd 폴더를 찾지 못했어요.');
|
|
50
|
+
}
|
|
51
|
+
if (!fs.statSync(target).isDirectory()) throw new Error('--cwd 에는 폴더를 지정해 주세요.');
|
|
52
|
+
const rel = path.relative(base, target);
|
|
53
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error('--cwd 는 프로젝트 폴더 안이어야 해요.');
|
|
54
|
+
return rel || '.';
|
|
55
|
+
}
|
|
56
|
+
const cwdOf = (p, c) => (c.cwd && path.isAbsolute(c.cwd) ? c.cwd : path.join(p.cwd, c.cwd || '.'));
|
|
40
57
|
export function addCollector(o) {
|
|
41
58
|
if (!['result', 'result2'].includes(o.slot)) throw new Error('--slot result 또는 result2가 필요해요.');
|
|
42
59
|
let raw;
|
|
@@ -51,7 +68,7 @@ export function addCollector(o) {
|
|
|
51
68
|
if (!p) throw new Error('먼저 이 프로젝트를 setup으로 연결해 주세요.');
|
|
52
69
|
// Store only an explicitly registered command in user state. Never execute imported/repository config.
|
|
53
70
|
p.collectors ||= {};
|
|
54
|
-
p.collectors[o.slot] = { command, cwd: p.cwd };
|
|
71
|
+
p.collectors[o.slot] = { command, cwd: collectorCwd(p.cwd, o.cwd) };
|
|
55
72
|
saveJson(path.join(dir, 'connections.json'), state);
|
|
56
73
|
}
|
|
57
74
|
export function removeCollector(o) {
|
|
@@ -61,6 +78,27 @@ export function removeCollector(o) {
|
|
|
61
78
|
delete p.collectors?.[o.slot];
|
|
62
79
|
saveJson(path.join(dir, 'connections.json'), state);
|
|
63
80
|
}
|
|
81
|
+
// Failure categories only. stderr and stdout are never printed: they may hold secrets.
|
|
82
|
+
export function classifyFailure({ code, signal, killed, stderr = '' } = {}) {
|
|
83
|
+
const err = String(stderr);
|
|
84
|
+
if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return 'JSON 형식 오류';
|
|
85
|
+
if (killed || signal === 'SIGTERM' || code === 'ETIMEDOUT') return '시간 초과';
|
|
86
|
+
if (code === 'ENOENT' || /cannot find module|err_module_not_found|modulenotfounderror|no module named|command not found|cannot find package/i.test(err))
|
|
87
|
+
return '모듈을 찾지 못함';
|
|
88
|
+
if (/no such table|relation "?[^"\s]*"? does not exist|table '?[^'\s]*'? doesn't exist|undefined_table|42P01|PGRST205|could not find the table/i.test(err))
|
|
89
|
+
return '테이블 없음';
|
|
90
|
+
if (/environment variable|env(ironment)? var|\bis not set\b|is not defined in env|missing required env|keyerror: ['"][A-Z][A-Z0-9_]*['"]|process\.env\.[A-Z_]+ is (undefined|missing)|\b[A-Z][A-Z0-9]*_(URL|KEY|TOKEN|SECRET|DSN)\b.*(missing|required|undefined|not (set|found|defined))/i.test(err))
|
|
91
|
+
return '환경 변수 없음';
|
|
92
|
+
if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT|getaddrinfo|could not connect|connection (refused|terminated|failed|error)|password authentication failed|authentication failed|too many connections|SQLITE_CANTOPEN|unable to open database|database .* does not exist|fetch failed|server closed the connection/i.test(err))
|
|
93
|
+
return 'DB 연결 실패';
|
|
94
|
+
return '알 수 없는 오류';
|
|
95
|
+
}
|
|
96
|
+
class CollectorError extends Error {
|
|
97
|
+
constructor(kind) {
|
|
98
|
+
super(`성과 조회 명령이 실패했어요 (${kind}). 이전 값을 유지해요.`);
|
|
99
|
+
this.kind = kind;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
64
102
|
async function run(command, cwd) {
|
|
65
103
|
const env = { ...process.env };
|
|
66
104
|
for (const k of ['LOSTVIBE_TOKEN', 'LOSTVIBE_METRICS_TOKEN']) delete env[k];
|
|
@@ -69,11 +107,42 @@ async function run(command, cwd) {
|
|
|
69
107
|
command[0],
|
|
70
108
|
command.slice(1),
|
|
71
109
|
{ cwd, env, timeout: 20000, maxBuffer: 64_000, windowsHide: true },
|
|
72
|
-
(e, stdout) =>
|
|
73
|
-
e
|
|
110
|
+
(e, stdout, stderr) =>
|
|
111
|
+
e
|
|
112
|
+
? reject(new CollectorError(classifyFailure({ code: e.code, signal: e.signal, killed: e.killed, stderr })))
|
|
113
|
+
: resolve(stdout),
|
|
74
114
|
),
|
|
75
115
|
);
|
|
76
116
|
}
|
|
117
|
+
// Runs a collector the way collect does and returns { value, date } or throws with .kind.
|
|
118
|
+
export async function runCollector(command, cwd) {
|
|
119
|
+
const out = await run(validateCommand(command), cwd);
|
|
120
|
+
try {
|
|
121
|
+
return parseCollected(out);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new CollectorError('JSON 형식 오류');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export async function testCollector(o) {
|
|
127
|
+
let raw;
|
|
128
|
+
try {
|
|
129
|
+
raw = JSON.parse(o.commandJson);
|
|
130
|
+
} catch {
|
|
131
|
+
throw new Error('--command-json 에 명령 배열이 필요해요.');
|
|
132
|
+
}
|
|
133
|
+
const command = validateCommand(raw);
|
|
134
|
+
const p = o.project ? selectedProjects(o).find((x) => x.id === o.project) : null;
|
|
135
|
+
if (o.project && !p) throw new Error('먼저 이 프로젝트를 setup으로 연결해 주세요.');
|
|
136
|
+
const base = p?.cwd || process.cwd();
|
|
137
|
+
const cwd = o.cwd ? path.join(base, collectorCwd(base, o.cwd)) : base;
|
|
138
|
+
try {
|
|
139
|
+
const sample = await runCollector(command, cwd);
|
|
140
|
+
console.log(`값: ${sample.value} (${sample.date})`);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(`실패: ${e.kind || '알 수 없는 오류'}`);
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
77
146
|
export async function collect(o, token) {
|
|
78
147
|
const site = siteOf(o),
|
|
79
148
|
{ dir } = activeConnection(o);
|
|
@@ -84,7 +153,7 @@ export async function collect(o, token) {
|
|
|
84
153
|
const skipped = new Set((connection.skipped || []).map((p) => p.sourceId));
|
|
85
154
|
let failed = 0;
|
|
86
155
|
for (const p of selected.filter((p) => ids.has(p.id) && !skipped.has(p.sourceId))) {
|
|
87
|
-
if (!Object.keys(p.collectors || {}).length) continue;
|
|
156
|
+
if (!p.cwd || !Object.keys(p.collectors || {}).length) continue;
|
|
88
157
|
const current = await request(site, token, `/api/projects/${p.id}`);
|
|
89
158
|
for (const [slot, c] of Object.entries(p.collectors || {})) {
|
|
90
159
|
if (!current[slot]?.name) continue;
|
|
@@ -106,7 +175,7 @@ export async function collect(o, token) {
|
|
|
106
175
|
continue;
|
|
107
176
|
}
|
|
108
177
|
try {
|
|
109
|
-
const sample =
|
|
178
|
+
const sample = await runCollector(c.command, cwdOf(p, c));
|
|
110
179
|
const tokenFile = path.join(p.cwd, '.lostvibe', `metrics-token-${p.id}`);
|
|
111
180
|
const scoped = fs.readFileSync(tokenFile, 'utf8').trim();
|
|
112
181
|
const result = await request(site, scoped, `/api/me/projects/${p.id}/metrics`, 'POST', {
|
package/src/connections.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { resolveCwd, projectIdentity, sha } from './gitinfo.mjs';
|
|
5
|
-
export const VERSION = '0.
|
|
5
|
+
export const VERSION = '0.8.0';
|
|
6
6
|
export const stateDir = (o = {}) =>
|
|
7
7
|
path.resolve(o.out || process.env.LOSTVIBE_DIR || path.join(os.homedir(), '.lostvibe'));
|
|
8
8
|
export function readState(dir) {
|
|
@@ -24,10 +24,12 @@ export function activeConnection(o = {}) {
|
|
|
24
24
|
const account = state.accounts?.[state.active];
|
|
25
25
|
return { dir, state, account };
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return { ...r, sourceId: projectIdentity(r.repoPath, r.key) };
|
|
27
|
+
// A folder without git is the project `folder:<name>`, the same key scan --all gives it.
|
|
28
|
+
export function currentIdentity(cwd = process.cwd()) {
|
|
29
|
+
const r = resolveCwd(cwd);
|
|
30
|
+
if (r.repoPath) return { ...r, cwd: r.repoPath, sourceId: projectIdentity(r.repoPath, r.key) };
|
|
31
|
+
if (cwd === os.homedir() || cwd === path.dirname(cwd)) throw new Error('프로젝트 폴더 안에서 실행해 주세요.');
|
|
32
|
+
return { ...r, cwd, sourceId: projectIdentity(null, r.key) };
|
|
31
33
|
}
|
|
32
34
|
export function siteOf(o = {}) {
|
|
33
35
|
const { account } = activeConnection(o);
|
|
@@ -66,13 +68,17 @@ export async function request(site, token, url, method = 'GET', body) {
|
|
|
66
68
|
return d;
|
|
67
69
|
}
|
|
68
70
|
export async function registerCurrent(o, token) {
|
|
71
|
+
const current = currentIdentity();
|
|
72
|
+
return registerProject(o, token, { sourceId: current.sourceId, name: current.name, cwd: current.cwd });
|
|
73
|
+
}
|
|
74
|
+
// ident: { sourceId, name, cwd (null for a project known only from logs), roots? }
|
|
75
|
+
export async function registerProject(o, token, current) {
|
|
69
76
|
const dir = stateDir(o),
|
|
70
77
|
site = siteOf(o),
|
|
71
78
|
connection = await request(site, token, '/api/me/connection');
|
|
72
|
-
const current = currentIdentity();
|
|
73
79
|
const existing = connection.projects.find((p) => p.sourceId === current.sourceId);
|
|
74
80
|
if (o.project && (!existing || existing.id !== o.project))
|
|
75
|
-
throw new Error('선택한 프로젝트와 현재
|
|
81
|
+
throw new Error('선택한 프로젝트와 현재 폴더가 달라요. 올바른 폴더에서 다시 실행해 주세요.');
|
|
76
82
|
if (connection.skipped?.some((p) => p.sourceId === current.sourceId))
|
|
77
83
|
throw new Error('넘긴 프로젝트예요. 사이트에서 다시 가져온 뒤 연결해 주세요.');
|
|
78
84
|
const target =
|
|
@@ -86,17 +92,37 @@ export async function registerCurrent(o, token) {
|
|
|
86
92
|
state.accounts ||= {};
|
|
87
93
|
state.accounts[key] ||= { site, login: connection.login, projects: {} };
|
|
88
94
|
const account = state.accounts[key];
|
|
95
|
+
const prev = account.projects[current.sourceId] || {};
|
|
96
|
+
const roots = [...new Set([...(prev.roots || []), ...(current.roots || [])])];
|
|
89
97
|
account.projects[current.sourceId] = {
|
|
90
|
-
...
|
|
98
|
+
...prev,
|
|
91
99
|
sourceId: current.sourceId,
|
|
92
100
|
id: target.id,
|
|
93
|
-
cwd: current.
|
|
101
|
+
cwd: current.cwd || null,
|
|
94
102
|
name: current.name,
|
|
103
|
+
...(roots.length ? { roots } : {}),
|
|
95
104
|
};
|
|
96
105
|
state.active = key;
|
|
97
106
|
saveJson(path.join(dir, 'connections.json'), state);
|
|
98
107
|
return account.projects[current.sourceId];
|
|
99
108
|
}
|
|
109
|
+
// Other scan ids whose logs count as this project's. roots are workspaces that place their folders.
|
|
110
|
+
export function setMerge(o, sourceId, ids, roots = []) {
|
|
111
|
+
const { dir, state, account } = activeConnection(o);
|
|
112
|
+
const p = account?.projects?.[sourceId];
|
|
113
|
+
if (!p) throw new Error('먼저 이 프로젝트를 setup으로 연결해 주세요.');
|
|
114
|
+
p.merge = [...new Set([...(p.merge || []), ...ids])].filter((id) => id !== sourceId);
|
|
115
|
+
const all = [...new Set([...(p.roots || []), ...roots])];
|
|
116
|
+
if (all.length) p.roots = all;
|
|
117
|
+
saveJson(path.join(dir, 'connections.json'), state);
|
|
118
|
+
return p;
|
|
119
|
+
}
|
|
120
|
+
// other sourceId -> main sourceId, for scanAll({ merge }).
|
|
121
|
+
export function mergeMap(projects) {
|
|
122
|
+
const map = new Map();
|
|
123
|
+
for (const p of projects) for (const id of p.merge || []) if (id !== p.sourceId) map.set(id, p.sourceId);
|
|
124
|
+
return map;
|
|
125
|
+
}
|
|
100
126
|
export function selectedProjects(o = {}) {
|
|
101
127
|
const { account } = activeConnection(o);
|
|
102
128
|
if (!account) return [];
|
package/src/hooks.mjs
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import {
|
|
6
|
+
import { stateDir, saveJson } from './connections.mjs';
|
|
7
|
+
|
|
6
8
|
const quote = (s) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
9
|
+
// Any hook that runs `lostvibe.mjs hook` is ours, whichever version or folder installed it.
|
|
10
|
+
export const OUR_HOOK = /lostvibe\.mjs'?\s+hook\b/;
|
|
11
|
+
const isOurs = (h) => typeof h?.command === 'string' && OUR_HOOK.test(h.command);
|
|
12
|
+
const EVENTS = ['Stop', 'SessionEnd'];
|
|
13
|
+
const TEMP = /(^|['"\s])\/(private\/)?tmp\//;
|
|
14
|
+
const snake = (e) => e.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
15
|
+
|
|
7
16
|
function load(file, fallback = {}) {
|
|
8
17
|
if (!fs.existsSync(file)) return fallback;
|
|
9
18
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -11,88 +20,319 @@ function load(file, fallback = {}) {
|
|
|
11
20
|
throw new Error(`설정 형식을 읽지 못했어요: ${path.basename(file)}`);
|
|
12
21
|
return data;
|
|
13
22
|
}
|
|
14
|
-
function
|
|
15
|
-
|
|
23
|
+
function readSafe(file) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function clients(o = {}) {
|
|
16
31
|
const claude = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
17
32
|
const codex = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
const want = o.client?.toLowerCase();
|
|
34
|
+
return [
|
|
35
|
+
{ client: 'Claude Code', from: 'claude', dir: claude, file: path.join(claude, 'settings.json') },
|
|
36
|
+
{ client: 'Codex', from: 'codex', dir: codex, file: path.join(codex, 'hooks.json') },
|
|
37
|
+
].filter((c) => !want || c.from === want || c.client.toLowerCase().startsWith(want));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Removes our hooks from the given events (or every event) and leaves all other hooks as they are.
|
|
41
|
+
function stripOurs(config, events) {
|
|
42
|
+
if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) {
|
|
43
|
+
if (events && config.hooks !== undefined) throw new Error('기존 훅 형식을 읽지 못했어요. 변경하지 않았어요.');
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
let changed = false;
|
|
47
|
+
for (const event of events || Object.keys(config.hooks)) {
|
|
48
|
+
const groups = config.hooks[event];
|
|
49
|
+
if (groups === undefined) continue;
|
|
50
|
+
if (!Array.isArray(groups)) {
|
|
51
|
+
if (events) throw new Error('기존 훅 형식을 읽지 못했어요. 변경하지 않았어요.');
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const next = [];
|
|
55
|
+
for (const g of groups) {
|
|
56
|
+
if (!Array.isArray(g?.hooks) || !g.hooks.some(isOurs)) {
|
|
57
|
+
next.push(g);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
changed = true;
|
|
61
|
+
const rest = g.hooks.filter((h) => !isOurs(h));
|
|
62
|
+
if (rest.length) next.push({ ...g, hooks: rest });
|
|
63
|
+
}
|
|
64
|
+
if (next.length) config.hooks[event] = next;
|
|
65
|
+
else if (groups.length) delete config.hooks[event];
|
|
36
66
|
}
|
|
37
|
-
|
|
67
|
+
if (changed && !Object.keys(config.hooks).length) delete config.hooks;
|
|
68
|
+
return changed;
|
|
38
69
|
}
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
70
|
+
function findOurs(config, event) {
|
|
71
|
+
const groups = config.hooks?.[event];
|
|
72
|
+
if (!Array.isArray(groups)) return null;
|
|
73
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
74
|
+
const hooks = groups[gi]?.hooks;
|
|
75
|
+
if (!Array.isArray(hooks)) continue;
|
|
76
|
+
for (let hi = 0; hi < hooks.length; hi++) if (isOurs(hooks[hi])) return { gi, hi, command: hooks[hi].command };
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
function ourCommands(config) {
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const groups of Object.values(config.hooks || {}))
|
|
83
|
+
if (Array.isArray(groups))
|
|
84
|
+
for (const g of groups) for (const h of Array.isArray(g?.hooks) ? g.hooks : []) if (isOurs(h)) out.push(h.command);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A node path that survives node upgrades (e.g. /opt/homebrew/bin/node instead of the Cellar path).
|
|
89
|
+
function stableNode() {
|
|
90
|
+
let real;
|
|
91
|
+
try {
|
|
92
|
+
real = fs.realpathSync(process.execPath);
|
|
93
|
+
} catch {
|
|
94
|
+
return process.execPath;
|
|
95
|
+
}
|
|
96
|
+
for (const d of (process.env.PATH || '').split(path.delimiter)) {
|
|
97
|
+
if (!d || !path.isAbsolute(d) || TEMP.test(d) || d.includes('/var/folders/') || d.includes('node_modules')) continue;
|
|
98
|
+
const c = path.join(d, process.platform === 'win32' ? 'node.exe' : 'node');
|
|
99
|
+
try {
|
|
100
|
+
if (fs.realpathSync(c) === real) return c;
|
|
101
|
+
} catch {
|
|
102
|
+
/* not here */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return process.execPath;
|
|
106
|
+
}
|
|
107
|
+
// The hook always runs runtime/current, so its command (and Codex's trust hash) stays the same across versions.
|
|
108
|
+
function installRuntime(dir) {
|
|
43
109
|
const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
110
|
+
const base = path.join(dir, 'runtime');
|
|
111
|
+
const current = path.join(base, 'current');
|
|
112
|
+
if (path.resolve(packageRoot) === path.resolve(current)) return current;
|
|
113
|
+
const next = path.join(base, `.next-${process.pid}`);
|
|
114
|
+
fs.rmSync(next, { recursive: true, force: true });
|
|
115
|
+
fs.mkdirSync(next, { recursive: true });
|
|
116
|
+
for (const part of ['src', 'bin']) fs.cpSync(path.join(packageRoot, part), path.join(next, part), { recursive: true });
|
|
117
|
+
fs.copyFileSync(path.join(packageRoot, 'package.json'), path.join(next, 'package.json'));
|
|
118
|
+
fs.rmSync(current, { recursive: true, force: true });
|
|
119
|
+
fs.renameSync(next, current);
|
|
120
|
+
return current;
|
|
121
|
+
}
|
|
122
|
+
export function hookCommand(o, from) {
|
|
123
|
+
const dir = stateDir(o);
|
|
124
|
+
return `${quote(stableNode())} ${quote(path.join(dir, 'runtime', 'current', 'bin', 'lostvibe.mjs'))} hook --from ${from} --out ${quote(dir)}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Codex keeps approvals in config.toml as [hooks.state."<hooks.json>:<event>:<group>:<hook>"] trusted_hash = "...".
|
|
128
|
+
// Read only: approval is the user's step inside Codex.
|
|
129
|
+
export function codexTrust(codexDir) {
|
|
130
|
+
let text;
|
|
131
|
+
try {
|
|
132
|
+
text = fs.readFileSync(path.join(codexDir, 'config.toml'), 'utf8');
|
|
133
|
+
} catch {
|
|
134
|
+
return new Map();
|
|
135
|
+
}
|
|
136
|
+
const unq = (s) => s.replace(/\\(.)/g, '$1');
|
|
137
|
+
const map = new Map();
|
|
138
|
+
let key = null;
|
|
139
|
+
let section = null;
|
|
140
|
+
for (const line of text.split(/\r?\n/)) {
|
|
141
|
+
const h = line.match(/^\s*\[\s*hooks\s*\.\s*state\s*\.\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*\]/);
|
|
142
|
+
if (h) {
|
|
143
|
+
key = h[1] !== undefined ? unq(h[1]) : h[2];
|
|
144
|
+
section = null;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const s = line.match(/^\s*\[\s*([^\]]+?)\s*\]/);
|
|
148
|
+
if (s) {
|
|
149
|
+
key = null;
|
|
150
|
+
section = s[1].replace(/\s/g, '');
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (key) {
|
|
154
|
+
const t = line.match(/^\s*trusted_hash\s*=\s*["']([^"']*)["']/);
|
|
155
|
+
if (t) map.set(key, t[1]);
|
|
156
|
+
} else if (section === 'hooks.state') {
|
|
157
|
+
const t = line.match(/^\s*"((?:[^"\\]|\\.)*)"\s*=\s*\{[^}]*trusted_hash\s*=\s*"([^"]*)"/);
|
|
158
|
+
if (t) map.set(unq(t[1]), t[2]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return map;
|
|
162
|
+
}
|
|
163
|
+
const trustKey = (file, event, pos) => `${file}:${snake(event)}:${pos.gi}:${pos.hi}`;
|
|
164
|
+
|
|
165
|
+
export function codexCli() {
|
|
166
|
+
try {
|
|
167
|
+
const found = execFileSync('/bin/sh', ['-c', 'command -v codex'], {
|
|
168
|
+
encoding: 'utf8',
|
|
169
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
170
|
+
timeout: 5000,
|
|
171
|
+
}).trim();
|
|
172
|
+
if (found) return found;
|
|
173
|
+
} catch {
|
|
174
|
+
/* not on PATH */
|
|
175
|
+
}
|
|
176
|
+
for (const p of [
|
|
177
|
+
'/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex',
|
|
178
|
+
'/Applications/Codex.app/Contents/Resources/codex',
|
|
179
|
+
])
|
|
180
|
+
if (fs.existsSync(p)) return p;
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
export function codexHint(cli = codexCli()) {
|
|
184
|
+
return cli
|
|
185
|
+
? `Codex 승인: 터미널에서 '${cli}' 실행 → /hooks → Lost Vibe 항목 2개만 Trust`
|
|
186
|
+
: 'Codex 승인: Codex CLI를 찾지 못했어요. Codex를 열고 /hooks → Lost Vibe 항목 2개만 Trust';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function installHooks(o = {}) {
|
|
190
|
+
const dir = stateDir(o);
|
|
191
|
+
const chosen = clients(o).filter((c) => fs.existsSync(c.dir));
|
|
192
|
+
if (!chosen.length) throw new Error('연결할 Claude Code 또는 Codex 설정 폴더를 찾지 못했어요.');
|
|
193
|
+
// Validate all files before changing any.
|
|
194
|
+
const plans = chosen.map((c) => {
|
|
195
|
+
const config = load(c.file);
|
|
196
|
+
return { ...c, before: structuredClone(config), config, command: hookCommand(o, c.from) };
|
|
197
|
+
});
|
|
198
|
+
for (const p of plans) {
|
|
199
|
+
stripOurs(p.config, EVENTS);
|
|
200
|
+
p.config.hooks ||= {};
|
|
201
|
+
for (const event of EVENTS)
|
|
202
|
+
p.config.hooks[event] = [...(p.config.hooks[event] || []), { hooks: [{ type: 'command', command: p.command, timeout: 10 }] }];
|
|
60
203
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
204
|
+
installRuntime(dir);
|
|
205
|
+
const entries = [];
|
|
206
|
+
for (const p of plans) {
|
|
207
|
+
fs.mkdirSync(path.dirname(p.file), { recursive: true });
|
|
208
|
+
if (fs.existsSync(p.file) && !fs.existsSync(p.file + '.lostvibe-backup'))
|
|
209
|
+
fs.copyFileSync(p.file, p.file + '.lostvibe-backup');
|
|
210
|
+
saveJson(p.file, p.config);
|
|
211
|
+
const trust = p.from === 'codex' ? codexTrust(p.dir) : null;
|
|
212
|
+
for (const event of EVENTS) {
|
|
213
|
+
const entry = { client: p.client, from: p.from, file: p.file, event, command: p.command };
|
|
214
|
+
if (trust) {
|
|
215
|
+
// Remembers the approval that existed at this position so a stale one is not read as approval later.
|
|
216
|
+
const pos = findOurs(p.config, event);
|
|
217
|
+
entry.trustKey = trustKey(p.file, event, pos);
|
|
218
|
+
entry.preHash = trust.get(entry.trustKey) || null;
|
|
219
|
+
entry.sameCommand = p.before.hooks?.[event]?.[pos.gi]?.hooks?.[pos.hi]?.command === p.command;
|
|
220
|
+
}
|
|
221
|
+
entries.push(entry);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const registryFile = path.join(dir, 'hooks.json');
|
|
225
|
+
let registry;
|
|
226
|
+
try {
|
|
227
|
+
registry = load(registryFile, { entries: [] });
|
|
228
|
+
} catch {
|
|
229
|
+
registry = { entries: [] };
|
|
66
230
|
}
|
|
67
231
|
registry.entries = [
|
|
68
|
-
...registry.entries.filter((e) => !
|
|
69
|
-
...
|
|
232
|
+
...(registry.entries || []).filter((e) => !plans.some((p) => p.file === e.file)),
|
|
233
|
+
...entries,
|
|
70
234
|
];
|
|
71
235
|
saveJson(registryFile, registry);
|
|
72
|
-
return [...new Set(chosen.map((
|
|
236
|
+
return [...new Set(chosen.map((c) => c.client))];
|
|
73
237
|
}
|
|
74
|
-
|
|
238
|
+
|
|
239
|
+
export function removeHooks(o = {}) {
|
|
75
240
|
const file = path.join(stateDir(o), 'hooks.json');
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
241
|
+
let registry;
|
|
242
|
+
try {
|
|
243
|
+
registry = load(file, { entries: [] });
|
|
244
|
+
} catch {
|
|
245
|
+
registry = { entries: [] };
|
|
246
|
+
}
|
|
247
|
+
const chosen = clients(o);
|
|
248
|
+
const files = new Set(chosen.map((c) => c.file));
|
|
249
|
+
const matches = (e) => !o.client || chosen.some((c) => c.client === e.client);
|
|
250
|
+
for (const e of registry.entries || []) if (matches(e)) files.add(e.file);
|
|
251
|
+
for (const f of files) {
|
|
252
|
+
if (!fs.existsSync(f)) continue;
|
|
253
|
+
const config = load(f);
|
|
254
|
+
if (stripOurs(config)) saveJson(f, config);
|
|
255
|
+
}
|
|
256
|
+
registry.entries = (registry.entries || []).filter((e) => !matches(e));
|
|
257
|
+
saveJson(file, registry);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const when = (iso) => {
|
|
261
|
+
const d = new Date(iso);
|
|
262
|
+
if (Number.isNaN(d.getTime())) return '-';
|
|
263
|
+
const z = (n) => String(n).padStart(2, '0');
|
|
264
|
+
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Per client: 미설치 / 설치됨·승인 전 / 승인됨·실행 기록 없음 / 실행됨.
|
|
268
|
+
export function hookStatus(o = {}) {
|
|
269
|
+
const dir = stateDir(o);
|
|
270
|
+
const registry = readSafe(path.join(dir, 'hooks.json')) || { entries: [] };
|
|
271
|
+
const last = readSafe(path.join(dir, 'hook-last.json')) || {};
|
|
272
|
+
const sync = readSafe(path.join(dir, 'last-sync.json'));
|
|
273
|
+
const lastSync = sync ? (sync.ok ? sync.at : sync.lastOkAt) || null : null;
|
|
274
|
+
const out = [];
|
|
275
|
+
for (const c of clients(o)) {
|
|
276
|
+
if (!fs.existsSync(c.dir)) continue;
|
|
277
|
+
let config = {};
|
|
278
|
+
try {
|
|
279
|
+
config = load(c.file);
|
|
280
|
+
} catch {
|
|
281
|
+
/* unreadable config reads as not installed */
|
|
82
282
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
283
|
+
const positions = EVENTS.map((event) => ({ event, pos: findOurs(config, event) }));
|
|
284
|
+
const installed = positions.every((p) => p.pos);
|
|
285
|
+
let approved = installed;
|
|
286
|
+
if (installed && c.from === 'codex') {
|
|
287
|
+
const trust = codexTrust(c.dir);
|
|
288
|
+
approved = positions.every(({ event, pos }) => {
|
|
289
|
+
const key = trustKey(c.file, event, pos);
|
|
290
|
+
const hash = trust.get(key);
|
|
291
|
+
if (!hash) return false;
|
|
292
|
+
const snap = (registry.entries || []).find((e) => e.trustKey === key && e.command === pos.command);
|
|
293
|
+
return !(snap && snap.preHash && snap.preHash === hash && !snap.sameCommand);
|
|
294
|
+
});
|
|
86
295
|
}
|
|
296
|
+
const lastRun = last.clients?.[c.from]?.at || (last.from === c.from ? last.at : null) || null;
|
|
297
|
+
const state = !installed
|
|
298
|
+
? '미설치'
|
|
299
|
+
: !approved
|
|
300
|
+
? '설치됨·승인 전'
|
|
301
|
+
: !lastRun
|
|
302
|
+
? '승인됨·실행 기록 없음'
|
|
303
|
+
: '실행됨';
|
|
304
|
+
out.push({
|
|
305
|
+
client: c.client,
|
|
306
|
+
from: c.from,
|
|
307
|
+
state,
|
|
308
|
+
installed,
|
|
309
|
+
approved,
|
|
310
|
+
lastRun,
|
|
311
|
+
lastSync,
|
|
312
|
+
temp: ourCommands(config).some((cmd) => TEMP.test(cmd)),
|
|
313
|
+
});
|
|
87
314
|
}
|
|
88
|
-
|
|
89
|
-
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
export function statusLines(entries, cli) {
|
|
318
|
+
if (!entries.length) return ['Claude Code와 Codex 설정 폴더를 찾지 못했어요.'];
|
|
319
|
+
const lines = entries.map((e) =>
|
|
320
|
+
e.state === '실행됨'
|
|
321
|
+
? `${e.client}: 실행됨 (마지막 실행 ${when(e.lastRun)}, 마지막 사이트 반영 ${e.lastSync ? when(e.lastSync) : '없음'})`
|
|
322
|
+
: `${e.client}: ${e.state}`,
|
|
323
|
+
);
|
|
324
|
+
if (entries.some((e) => e.from === 'codex' && e.installed && !e.approved)) lines.push(codexHint(cli));
|
|
325
|
+
if (entries.some((e) => e.temp))
|
|
326
|
+
lines.push('주의: 임시 폴더(/tmp)를 가리키는 Lost Vibe 훅이 있어요. hooks install을 다시 실행하면 정리돼요.');
|
|
327
|
+
return lines;
|
|
90
328
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
})
|
|
329
|
+
// What install did and what is left for the user.
|
|
330
|
+
export function installLines(o, installed, cli) {
|
|
331
|
+
const lines = [`자동 갱신 훅을 설치했어요: ${installed.join(', ')} (Stop, SessionEnd).`];
|
|
332
|
+
const entries = hookStatus(o).filter((e) => installed.includes(e.client));
|
|
333
|
+
const pending = entries.some((e) => e.from === 'codex' && !e.approved);
|
|
334
|
+
if (installed.includes('Claude Code')) lines.push('Claude Code는 승인이 필요 없어요. 다음 세션부터 자동으로 갱신돼요.');
|
|
335
|
+
if (pending) lines.push(`남은 일: ${codexHint(cli)}`);
|
|
336
|
+
else if (installed.includes('Codex')) lines.push('Codex는 이미 승인돼 있어요.');
|
|
337
|
+
return lines;
|
|
98
338
|
}
|
package/src/scan.mjs
CHANGED
|
@@ -30,7 +30,7 @@ function folderProject(cwd, workspaces, repoNames) {
|
|
|
30
30
|
// A leftover copy or worktree folder such as app-backup belongs to app.
|
|
31
31
|
const base = COPY_SUFFIX.test(lower) ? lower.replace(COPY_SUFFIX, '') : null;
|
|
32
32
|
if (base && repoNames.has(base)) return repoNames.get(base);
|
|
33
|
-
return { key: `folder:${lower}`, name, repoPath: null, remote: null };
|
|
33
|
+
return { key: `folder:${lower}`, name, repoPath: null, remote: null, folder: path.join(w, name) };
|
|
34
34
|
}
|
|
35
35
|
return null;
|
|
36
36
|
}
|
|
@@ -81,7 +81,25 @@ function deviceZone(dir) {
|
|
|
81
81
|
return zone;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
// Groups named in merge (other id -> main id) join the main group before totals are computed.
|
|
85
|
+
// When the main group is missing, the first group merged into it takes its place.
|
|
86
|
+
export function foldGroups(groups, merge) {
|
|
87
|
+
if (!merge?.size) return groups;
|
|
88
|
+
const byId = new Map(groups.map((g) => [g.id, g]));
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const g of groups) {
|
|
91
|
+
const mainId = merge.get(g.id);
|
|
92
|
+
if (!mainId || mainId === g.id || merge.has(mainId)) { out.push(g); continue; }
|
|
93
|
+
const main = byId.get(mainId);
|
|
94
|
+
if (!main || main === g) { g.id = mainId; byId.set(mainId, g); out.push(g); continue; }
|
|
95
|
+
main.buckets.push(...g.buckets);
|
|
96
|
+
main.cwds.push(...g.cwds);
|
|
97
|
+
if (!main.folder && g.folder) main.folder = g.folder;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function scanAll({ extraRoots = [], onProgress = () => {}, reportedFor = () => null, cacheDir = null, only = null, merge = null } = {}) {
|
|
85
103
|
setZone(deviceZone(cacheDir));
|
|
86
104
|
const cacheFile = cacheDir ? path.join(cacheDir, 'log-cache-v2.json') : null;
|
|
87
105
|
const cache = (cacheFile && readJson(cacheFile)) || { files: {} };
|
|
@@ -117,6 +135,7 @@ export async function scanAll({ extraRoots = [], onProgress = () => {}, reported
|
|
|
117
135
|
if (!groups.has(r.key)) groups.set(r.key, { key: r.key, remote: r.remote, name: r.name, repos: new Set(), buckets: [], cwds: [] });
|
|
118
136
|
const g = groups.get(r.key);
|
|
119
137
|
if (r.repoPath) g.repos.add(r.repoPath);
|
|
138
|
+
if (r.folder && !g.folder) g.folder = r.folder;
|
|
120
139
|
g.buckets.push(buckets.get(cwd));
|
|
121
140
|
g.cwds.push(cwd);
|
|
122
141
|
};
|
|
@@ -152,14 +171,20 @@ export async function scanAll({ extraRoots = [], onProgress = () => {}, reported
|
|
|
152
171
|
groups.get(r.key).repos.add(r.repoPath);
|
|
153
172
|
}
|
|
154
173
|
|
|
174
|
+
// Every group's id is known before merging, so merged groups fold by id and only filters by id.
|
|
175
|
+
for (const g of groups.values()) {
|
|
176
|
+
const repos = [...g.repos];
|
|
177
|
+
g.repo = repos.length > 1 ? repos.map((p) => [p, commitCount(p)]).sort((a, b) => b[1] - a[1])[0][0] : repos[0] || null;
|
|
178
|
+
g.id = projectIdentity(g.repo, g.key);
|
|
179
|
+
}
|
|
155
180
|
const projects = [];
|
|
156
|
-
const list = [...groups.values()].filter((g) => !only || only.has(g.name.toLowerCase()) || [...g.repos].some((p) => only.has(path.basename(p).toLowerCase()))
|
|
181
|
+
const list = foldGroups([...groups.values()], merge).filter((g) => !only || only.has(g.id) || only.has(g.name.toLowerCase()) || [...g.repos].some((p) => only.has(path.basename(p).toLowerCase())));
|
|
157
182
|
for (let i = 0; i < list.length; i++) {
|
|
158
183
|
const g = list[i];
|
|
159
184
|
onProgress(`프로젝트 ${i + 1}/${list.length}: ${g.name}`);
|
|
160
|
-
const repo =
|
|
185
|
+
const repo = g.repo;
|
|
161
186
|
const name = repo ? path.basename(repo) : g.name;
|
|
162
|
-
const id =
|
|
187
|
+
const id = g.id;
|
|
163
188
|
const merged = mergeBuckets(g.buckets);
|
|
164
189
|
const slug = githubSlug(g.remote);
|
|
165
190
|
const gh = process.env.LOSTVIBE_SKIP_REMOTE === '1' ? null : await readGithub(slug.owner, slug.repo);
|
|
@@ -189,7 +214,9 @@ export async function scanAll({ extraRoots = [], onProgress = () => {}, reported
|
|
|
189
214
|
usage_days: usageDays(merged),
|
|
190
215
|
sessions: merged.sessions.size,
|
|
191
216
|
});
|
|
192
|
-
|
|
217
|
+
const folder = g.folder && fs.existsSync(g.folder) ? g.folder : null;
|
|
218
|
+
const local = repo || folder || g.cwds.find((c) => fs.existsSync(c)) || null;
|
|
219
|
+
projects[projects.length - 1]._local = { repo, cwds: g.cwds, folder: g.folder || null, path: local, exists: !!local, git: !!repo };
|
|
193
220
|
}
|
|
194
221
|
projects.sort((a, b) => (b.verified.ai?.tokens.total || 0) - (a.verified.ai?.tokens.total || 0) || (b.verified.git?.commits || 0) - (a.verified.git?.commits || 0));
|
|
195
222
|
|
|
@@ -204,6 +231,23 @@ export async function scanAll({ extraRoots = [], onProgress = () => {}, reported
|
|
|
204
231
|
};
|
|
205
232
|
}
|
|
206
233
|
|
|
234
|
+
// One row of `scan --all --json`. The path stays on this computer (scan-all.json, stdout).
|
|
235
|
+
export function scanEntry(p, i) {
|
|
236
|
+
const l = p._local || {};
|
|
237
|
+
return {
|
|
238
|
+
n: i + 1,
|
|
239
|
+
id: p.project.id,
|
|
240
|
+
name: p.project.name,
|
|
241
|
+
tokens: p.verified.ai?.tokens.total || 0,
|
|
242
|
+
last: p.verified.ai?.last_session || p.verified.git?.last_commit || null,
|
|
243
|
+
path: l.path || null,
|
|
244
|
+
exists: !!l.exists,
|
|
245
|
+
git: !!l.git,
|
|
246
|
+
setup: l.exists ? 'folder' : 'source',
|
|
247
|
+
url: p.verified.deploy?.url || null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
207
251
|
export function stripLocal(p) {
|
|
208
252
|
const { _local, ...rest } = p;
|
|
209
253
|
return rest;
|
package/src/table.mjs
CHANGED
|
@@ -69,10 +69,14 @@ export function projectTable(p) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
export function landTable(land) {
|
|
72
|
-
const
|
|
72
|
+
const yes = (x) => (x === undefined ? '-' : x ? 'O' : 'X');
|
|
73
|
+
const rows = land.projects.map((p, i) => {
|
|
73
74
|
const a = p.verified.ai; const g = p.verified.git; const c = p.verified.code;
|
|
74
75
|
return [
|
|
76
|
+
String(i + 1),
|
|
75
77
|
p.project.name,
|
|
78
|
+
yes(p._local?.exists),
|
|
79
|
+
yes(p._local?.git),
|
|
76
80
|
a ? compact(a.tokens.total) : '-',
|
|
77
81
|
a ? compact(splitTokens(a).claude) : '-',
|
|
78
82
|
a ? compact(splitTokens(a).gpt) : '-',
|
|
@@ -88,9 +92,10 @@ export function landTable(land) {
|
|
|
88
92
|
lastActive(p) || '-',
|
|
89
93
|
];
|
|
90
94
|
});
|
|
91
|
-
const head = ['프로젝트', '토큰', 'Claude', 'GPT', '캐시제외', '비용', '세션', '커밋', '일수', '줄', '배포', '공개', '별', '마지막'];
|
|
95
|
+
const head = ['#', '프로젝트', '폴더', 'Git', '토큰', 'Claude', 'GPT', '캐시제외', '비용', '세션', '커밋', '일수', '줄', '배포', '공개', '별', '마지막'];
|
|
96
|
+
const left = new Set([1, 2, 3, 14, 16]);
|
|
92
97
|
const ws = head.map((h, i) => Math.max(width(h), ...rows.map((row) => width(row[i]))));
|
|
93
|
-
const fmt = (row) => row.map((x, i) => (i
|
|
98
|
+
const fmt = (row) => row.map((x, i) => (left.has(i) ? pad(x, ws[i]) : lpad(x, ws[i]))).join(' ');
|
|
94
99
|
return [fmt(head), ws.map((w) => '-'.repeat(w)).join(' '), ...rows.map(fmt)].join('\n');
|
|
95
100
|
}
|
|
96
101
|
|