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.
- package/README.md +72 -2
- package/bin/lostvibe.mjs +753 -0
- package/package.json +32 -4
- package/src/collectors.mjs +198 -0
- package/src/connections.mjs +142 -0
- package/src/gitinfo.mjs +176 -0
- package/src/hooks.mjs +338 -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 +256 -0
- package/src/table.mjs +105 -0
package/src/hooks.mjs
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { stateDir, saveJson } from './connections.mjs';
|
|
7
|
+
|
|
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
|
+
|
|
16
|
+
function load(file, fallback = {}) {
|
|
17
|
+
if (!fs.existsSync(file)) return fallback;
|
|
18
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
19
|
+
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
20
|
+
throw new Error(`설정 형식을 읽지 못했어요: ${path.basename(file)}`);
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
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 = {}) {
|
|
31
|
+
const claude = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
32
|
+
const codex = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
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];
|
|
66
|
+
}
|
|
67
|
+
if (changed && !Object.keys(config.hooks).length) delete config.hooks;
|
|
68
|
+
return changed;
|
|
69
|
+
}
|
|
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) {
|
|
109
|
+
const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
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 }] }];
|
|
203
|
+
}
|
|
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: [] };
|
|
230
|
+
}
|
|
231
|
+
registry.entries = [
|
|
232
|
+
...(registry.entries || []).filter((e) => !plans.some((p) => p.file === e.file)),
|
|
233
|
+
...entries,
|
|
234
|
+
];
|
|
235
|
+
saveJson(registryFile, registry);
|
|
236
|
+
return [...new Set(chosen.map((c) => c.client))];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function removeHooks(o = {}) {
|
|
240
|
+
const file = path.join(stateDir(o), 'hooks.json');
|
|
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 */
|
|
282
|
+
}
|
|
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
|
+
});
|
|
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
|
+
});
|
|
314
|
+
}
|
|
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;
|
|
328
|
+
}
|
|
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;
|
|
338
|
+
}
|