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/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lostvibe",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "프로젝트의 객관 지표를 로컬 로그와 git에서 읽는 CLI. 코드와 대화 내용은 보내지 않는다.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"lostvibe",
|
|
7
|
+
"vibe-coding",
|
|
8
|
+
"ai",
|
|
9
|
+
"metrics",
|
|
10
|
+
"cli",
|
|
11
|
+
"claude-code",
|
|
12
|
+
"codex"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://lostvibesociety.com",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"lostvibe": "bin/lostvibe.mjs"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"bin",
|
|
21
|
+
"src",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.15"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test test/*.test.mjs"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { activeConnection, saveJson, selectedProjects, siteOf, request, sendStatus } from './connections.mjs';
|
|
5
|
+
|
|
6
|
+
export function validateCommand(raw) {
|
|
7
|
+
if (
|
|
8
|
+
!Array.isArray(raw) ||
|
|
9
|
+
!raw.length ||
|
|
10
|
+
raw.length > 32 ||
|
|
11
|
+
raw.some((s) => typeof s !== 'string' || !s || s.length > 2000 || s.includes('\0'))
|
|
12
|
+
)
|
|
13
|
+
throw new Error('실행 명령을 JSON 문자열 배열로 지정해 주세요.');
|
|
14
|
+
return raw;
|
|
15
|
+
}
|
|
16
|
+
export function parseCollected(source) {
|
|
17
|
+
let b;
|
|
18
|
+
try {
|
|
19
|
+
b = JSON.parse(source);
|
|
20
|
+
} catch {
|
|
21
|
+
throw new Error('수집 명령은 JSON 하나를 출력해야 해요.');
|
|
22
|
+
}
|
|
23
|
+
if (!b || typeof b.value !== 'number' || !Number.isFinite(b.value) || b.value < 0 || b.value >= 1e15)
|
|
24
|
+
throw new Error('성과를 읽지 못했어요. 이전 값을 유지해요.');
|
|
25
|
+
if (
|
|
26
|
+
b.date !== undefined &&
|
|
27
|
+
(!/^\d{4}-\d{2}-\d{2}$/.test(b.date) ||
|
|
28
|
+
new Date(b.date).toISOString().slice(0, 10) !== b.date ||
|
|
29
|
+
Date.parse(b.date) > Date.now() + 86400000)
|
|
30
|
+
)
|
|
31
|
+
throw new Error('성과 날짜가 맞지 않아요.');
|
|
32
|
+
const now = new Date();
|
|
33
|
+
return {
|
|
34
|
+
value: b.value,
|
|
35
|
+
date:
|
|
36
|
+
b.date ||
|
|
37
|
+
`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`,
|
|
38
|
+
};
|
|
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 || '.'));
|
|
57
|
+
export function addCollector(o) {
|
|
58
|
+
if (!['result', 'result2'].includes(o.slot)) throw new Error('--slot result 또는 result2가 필요해요.');
|
|
59
|
+
let raw;
|
|
60
|
+
try {
|
|
61
|
+
raw = JSON.parse(o.commandJson);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error('--command-json 에 명령 배열이 필요해요.');
|
|
64
|
+
}
|
|
65
|
+
const command = validateCommand(raw);
|
|
66
|
+
const { dir, state, account } = activeConnection(o);
|
|
67
|
+
const p = Object.values(account?.projects || {}).find((p) => p.id === o.project);
|
|
68
|
+
if (!p) throw new Error('먼저 이 프로젝트를 setup으로 연결해 주세요.');
|
|
69
|
+
// Store only an explicitly registered command in user state. Never execute imported/repository config.
|
|
70
|
+
p.collectors ||= {};
|
|
71
|
+
p.collectors[o.slot] = { command, cwd: collectorCwd(p.cwd, o.cwd) };
|
|
72
|
+
saveJson(path.join(dir, 'connections.json'), state);
|
|
73
|
+
}
|
|
74
|
+
export function removeCollector(o) {
|
|
75
|
+
const { dir, state, account } = activeConnection(o);
|
|
76
|
+
const p = Object.values(account?.projects || {}).find((p) => p.id === o.project);
|
|
77
|
+
if (!p || !['result', 'result2'].includes(o.slot)) throw new Error('프로젝트와 성과를 지정해 주세요.');
|
|
78
|
+
delete p.collectors?.[o.slot];
|
|
79
|
+
saveJson(path.join(dir, 'connections.json'), state);
|
|
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
|
+
}
|
|
102
|
+
async function run(command, cwd) {
|
|
103
|
+
const env = { ...process.env };
|
|
104
|
+
for (const k of ['LOSTVIBE_TOKEN', 'LOSTVIBE_METRICS_TOKEN']) delete env[k];
|
|
105
|
+
return new Promise((resolve, reject) =>
|
|
106
|
+
execFile(
|
|
107
|
+
command[0],
|
|
108
|
+
command.slice(1),
|
|
109
|
+
{ cwd, env, timeout: 20000, maxBuffer: 64_000, windowsHide: true },
|
|
110
|
+
(e, stdout, stderr) =>
|
|
111
|
+
e
|
|
112
|
+
? reject(new CollectorError(classifyFailure({ code: e.code, signal: e.signal, killed: e.killed, stderr })))
|
|
113
|
+
: resolve(stdout),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
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
|
+
}
|
|
146
|
+
export async function collect(o, token) {
|
|
147
|
+
const site = siteOf(o),
|
|
148
|
+
{ dir } = activeConnection(o);
|
|
149
|
+
const selected = selectedProjects(o);
|
|
150
|
+
if (!selected.length) return;
|
|
151
|
+
const connection = await request(site, token, '/api/me/connection');
|
|
152
|
+
const ids = new Set(connection.projects.map((p) => p.id));
|
|
153
|
+
const skipped = new Set((connection.skipped || []).map((p) => p.sourceId));
|
|
154
|
+
let failed = 0;
|
|
155
|
+
for (const p of selected.filter((p) => ids.has(p.id) && !skipped.has(p.sourceId))) {
|
|
156
|
+
if (!p.cwd || !Object.keys(p.collectors || {}).length) continue;
|
|
157
|
+
const current = await request(site, token, `/api/projects/${p.id}`);
|
|
158
|
+
for (const [slot, c] of Object.entries(p.collectors || {})) {
|
|
159
|
+
if (!current[slot]?.name) continue;
|
|
160
|
+
const lock = path.join(dir, `collector-${p.id}-${slot}.lock`);
|
|
161
|
+
try {
|
|
162
|
+
const old = Number(fs.readFileSync(lock, 'utf8'));
|
|
163
|
+
try {
|
|
164
|
+
process.kill(old, 0);
|
|
165
|
+
continue;
|
|
166
|
+
} catch {
|
|
167
|
+
fs.rmSync(lock, { force: true });
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
/* no active lock */
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
fs.writeFileSync(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
174
|
+
} catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const sample = await runCollector(c.command, cwdOf(p, c));
|
|
179
|
+
const tokenFile = path.join(p.cwd, '.lostvibe', `metrics-token-${p.id}`);
|
|
180
|
+
const scoped = fs.readFileSync(tokenFile, 'utf8').trim();
|
|
181
|
+
const result = await request(site, scoped, `/api/me/projects/${p.id}/metrics`, 'POST', {
|
|
182
|
+
metrics: [{ slot, ...sample }],
|
|
183
|
+
});
|
|
184
|
+
if (!result.saved || result.refused?.length)
|
|
185
|
+
throw new Error('성과가 연결되어 있지 않거나 제외됐어요.');
|
|
186
|
+
await sendStatus(site, scoped, p.id, slot, true);
|
|
187
|
+
if (!o.quiet) console.log(`${p.name} · ${slot}: ${sample.value} (${sample.date})`);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
failed++;
|
|
190
|
+
await sendStatus(site, token, p.id, slot, false);
|
|
191
|
+
if (!o.quiet) console.error(`${p.name} · ${slot}: ${e.message}`);
|
|
192
|
+
} finally {
|
|
193
|
+
fs.rmSync(lock, { force: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (failed) throw new Error(`성과 ${failed}개를 갱신하지 못했어요. 이전 값은 유지했어요.`);
|
|
198
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { resolveCwd, projectIdentity, sha } from './gitinfo.mjs';
|
|
5
|
+
export const VERSION = '0.8.0';
|
|
6
|
+
export const stateDir = (o = {}) =>
|
|
7
|
+
path.resolve(o.out || process.env.LOSTVIBE_DIR || path.join(os.homedir(), '.lostvibe'));
|
|
8
|
+
export function readState(dir) {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'connections.json'), 'utf8'));
|
|
11
|
+
} catch {
|
|
12
|
+
return { accounts: {} };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function saveJson(file, data) {
|
|
16
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
17
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
18
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
19
|
+
fs.renameSync(tmp, file);
|
|
20
|
+
}
|
|
21
|
+
export function activeConnection(o = {}) {
|
|
22
|
+
const dir = stateDir(o),
|
|
23
|
+
state = readState(dir);
|
|
24
|
+
const account = state.accounts?.[state.active];
|
|
25
|
+
return { dir, state, account };
|
|
26
|
+
}
|
|
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) };
|
|
33
|
+
}
|
|
34
|
+
export function siteOf(o = {}) {
|
|
35
|
+
const { account } = activeConnection(o);
|
|
36
|
+
const site = (
|
|
37
|
+
o.site ||
|
|
38
|
+
process.env.LOSTVIBE_SITE ||
|
|
39
|
+
account?.site ||
|
|
40
|
+
'https://lostvibesociety.com'
|
|
41
|
+
).replace(/\/$/, '');
|
|
42
|
+
const u = new URL(site);
|
|
43
|
+
if (
|
|
44
|
+
u.username ||
|
|
45
|
+
u.password ||
|
|
46
|
+
!['https:', 'http:'].includes(u.protocol) ||
|
|
47
|
+
(u.protocol === 'http:' && !['localhost', '127.0.0.1', '[::1]'].includes(u.hostname))
|
|
48
|
+
)
|
|
49
|
+
throw new Error('HTTPS 사이트 주소가 필요해요.');
|
|
50
|
+
return site;
|
|
51
|
+
}
|
|
52
|
+
export async function request(site, token, url, method = 'GET', body) {
|
|
53
|
+
let res;
|
|
54
|
+
for (let i = 0; ; i++) {
|
|
55
|
+
res = await fetch(site + url, {
|
|
56
|
+
method,
|
|
57
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
58
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
59
|
+
signal: AbortSignal.timeout(30000),
|
|
60
|
+
});
|
|
61
|
+
if (res.status !== 429 || i >= 3) break;
|
|
62
|
+
await new Promise((r) =>
|
|
63
|
+
setTimeout(r, Math.min(5, Math.max(1, Number(res.headers.get('retry-after')) || 2)) * 1000),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const d = await res.json().catch(() => ({}));
|
|
67
|
+
if (!res.ok) throw new Error(`Lostvibe 요청 실패 (${res.status}). 키와 응답 본문은 출력하지 않았어요.`);
|
|
68
|
+
return d;
|
|
69
|
+
}
|
|
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) {
|
|
76
|
+
const dir = stateDir(o),
|
|
77
|
+
site = siteOf(o),
|
|
78
|
+
connection = await request(site, token, '/api/me/connection');
|
|
79
|
+
const existing = connection.projects.find((p) => p.sourceId === current.sourceId);
|
|
80
|
+
if (o.project && (!existing || existing.id !== o.project))
|
|
81
|
+
throw new Error('선택한 프로젝트와 현재 폴더가 달라요. 올바른 폴더에서 다시 실행해 주세요.');
|
|
82
|
+
if (connection.skipped?.some((p) => p.sourceId === current.sourceId))
|
|
83
|
+
throw new Error('넘긴 프로젝트예요. 사이트에서 다시 가져온 뒤 연결해 주세요.');
|
|
84
|
+
const target =
|
|
85
|
+
existing ||
|
|
86
|
+
(await request(site, token, '/api/me/projects', 'POST', {
|
|
87
|
+
name: current.name,
|
|
88
|
+
sourceId: current.sourceId,
|
|
89
|
+
}));
|
|
90
|
+
const state = readState(dir);
|
|
91
|
+
const key = sha(`${site}:${connection.login}`);
|
|
92
|
+
state.accounts ||= {};
|
|
93
|
+
state.accounts[key] ||= { site, login: connection.login, projects: {} };
|
|
94
|
+
const account = state.accounts[key];
|
|
95
|
+
const prev = account.projects[current.sourceId] || {};
|
|
96
|
+
const roots = [...new Set([...(prev.roots || []), ...(current.roots || [])])];
|
|
97
|
+
account.projects[current.sourceId] = {
|
|
98
|
+
...prev,
|
|
99
|
+
sourceId: current.sourceId,
|
|
100
|
+
id: target.id,
|
|
101
|
+
cwd: current.cwd || null,
|
|
102
|
+
name: current.name,
|
|
103
|
+
...(roots.length ? { roots } : {}),
|
|
104
|
+
};
|
|
105
|
+
state.active = key;
|
|
106
|
+
saveJson(path.join(dir, 'connections.json'), state);
|
|
107
|
+
return account.projects[current.sourceId];
|
|
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
|
+
}
|
|
126
|
+
export function selectedProjects(o = {}) {
|
|
127
|
+
const { account } = activeConnection(o);
|
|
128
|
+
if (!account) return [];
|
|
129
|
+
const selected = o.only ? new Set(o.only.split(',').map((x) => x.trim())) : null;
|
|
130
|
+
return Object.values(account.projects).filter(
|
|
131
|
+
(p) =>
|
|
132
|
+
(!o.project || p.id === o.project) &&
|
|
133
|
+
(!selected || selected.has(p.id) || selected.has(p.sourceId) || selected.has(p.name)),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
export async function sendStatus(site, token, id, kind, ok) {
|
|
137
|
+
try {
|
|
138
|
+
await request(site, token, `/api/me/projects/${id}/sync-status`, 'POST', { kind, ok });
|
|
139
|
+
} catch {
|
|
140
|
+
/* A status report must not hide the original result. */
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/gitinfo.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { localDate } from './logs.mjs';
|
|
6
|
+
|
|
7
|
+
function git(dir, args) {
|
|
8
|
+
return execFileSync('git', ['-C', dir, ...args], {
|
|
9
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 * 256, timeout: 120000,
|
|
10
|
+
}).trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sha(s) {
|
|
14
|
+
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 32);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeRemote(url) {
|
|
18
|
+
if (!url) return null;
|
|
19
|
+
let u = url.trim().replace(/\/+$/, '').replace(/\.git$/, '');
|
|
20
|
+
const ssh = u.match(/^git@([^:]+):(.+)$/);
|
|
21
|
+
if (ssh) u = `${ssh[1]}/${ssh[2]}`;
|
|
22
|
+
u = u.replace(/^[a-z+]+:\/\//, '').replace(/^[^@/]+@/, '').replace(/:22\//, '/');
|
|
23
|
+
return u.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nearestExisting(p) {
|
|
27
|
+
let cur = p;
|
|
28
|
+
while (cur && cur !== path.dirname(cur)) {
|
|
29
|
+
try { if (fs.statSync(cur).isDirectory()) return cur; } catch { /* keep walking */ }
|
|
30
|
+
cur = path.dirname(cur);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cache = new Map();
|
|
36
|
+
|
|
37
|
+
// The oldest commit is the same on every computer, and reading it writes nothing into the repo.
|
|
38
|
+
export function rootCommit(repo) {
|
|
39
|
+
try {
|
|
40
|
+
const roots = git(repo, ['rev-list', '--max-parents=0', '--branches', '--tags', '--remotes']).split('\n').filter(Boolean).sort();
|
|
41
|
+
return roots[0] || null;
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A remote names the project, so two forks of one template stay two projects.
|
|
46
|
+
// Without a remote the first commit does, so copies on two computers still join.
|
|
47
|
+
export function projectIdentity(repo, key) {
|
|
48
|
+
if (!repo || key.startsWith('remote:')) return sha(key);
|
|
49
|
+
const root = rootCommit(repo);
|
|
50
|
+
return root ? sha(`root:${root}`) : sha(key);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Other names this project may already be known by. Earlier CLI versions saved one in git config.
|
|
54
|
+
export function projectAliases(repo, key) {
|
|
55
|
+
const out = [sha(key)];
|
|
56
|
+
if (repo) {
|
|
57
|
+
try {
|
|
58
|
+
const saved = git(repo, ['config', '--local', '--get', 'lostvibe.project-id']);
|
|
59
|
+
if (/^[a-f0-9]{32}$/.test(saved)) out.push(saved);
|
|
60
|
+
} catch { /* nothing saved */ }
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
// A working directory resolves to one project: the main repo for worktrees and subfolders,
|
|
67
|
+
// keyed by git remote so the same repo at two paths is one project.
|
|
68
|
+
export function resolveCwd(cwd) {
|
|
69
|
+
if (cache.has(cwd)) return cache.get(cwd);
|
|
70
|
+
const res = resolveUncached(cwd);
|
|
71
|
+
cache.set(cwd, res);
|
|
72
|
+
return res;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveUncached(cwd) {
|
|
76
|
+
const dir = nearestExisting(cwd);
|
|
77
|
+
let root = null;
|
|
78
|
+
if (dir) {
|
|
79
|
+
try {
|
|
80
|
+
const top = git(dir, ['rev-parse', '--show-toplevel']);
|
|
81
|
+
let common = git(dir, ['rev-parse', '--git-common-dir']);
|
|
82
|
+
if (!path.isAbsolute(common)) common = path.resolve(dir, common);
|
|
83
|
+
root = path.basename(common) === '.git' ? path.dirname(common) : top;
|
|
84
|
+
} catch { /* not a repo */ }
|
|
85
|
+
}
|
|
86
|
+
if (root) {
|
|
87
|
+
let remote = null;
|
|
88
|
+
try { remote = normalizeRemote(git(root, ['remote', 'get-url', 'origin'])); } catch { /* no remote */ }
|
|
89
|
+
return { key: remote ? `remote:${remote}` : `path:${root}`, repoPath: root, remote, name: path.basename(root) };
|
|
90
|
+
}
|
|
91
|
+
return { key: `folder:${path.basename(cwd).toLowerCase()}`, repoPath: null, remote: null, name: path.basename(cwd), exists: dir === cwd };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function commitCount(repo) {
|
|
95
|
+
try { return Number(git(repo, ['rev-list', '--count', '--branches', '--tags', '--remotes', '--no-merges'])) || 0; } catch { return 0; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function gitMetrics(repo) {
|
|
99
|
+
let out = '';
|
|
100
|
+
try {
|
|
101
|
+
out = git(repo, ['log', '--branches', '--tags', '--remotes', '--no-merges', '--format=%aI%x09%ae']);
|
|
102
|
+
} catch { /* empty repo */ }
|
|
103
|
+
const daily = new Map();
|
|
104
|
+
const people = new Set();
|
|
105
|
+
let commits = 0;
|
|
106
|
+
for (const line of out.split('\n')) {
|
|
107
|
+
if (!line) continue;
|
|
108
|
+
const [timestamp, email] = line.split('\t');
|
|
109
|
+
const parsed = new Date(timestamp);
|
|
110
|
+
if (Number.isNaN(parsed.getTime())) continue;
|
|
111
|
+
const date = localDate(parsed);
|
|
112
|
+
commits++;
|
|
113
|
+
daily.set(date, (daily.get(date) || 0) + 1);
|
|
114
|
+
if (email && !email.includes('[bot]')) people.add(email.toLowerCase());
|
|
115
|
+
}
|
|
116
|
+
const dates = [...daily.keys()].sort();
|
|
117
|
+
const first = dates[0] || null;
|
|
118
|
+
const last = dates[dates.length - 1] || null;
|
|
119
|
+
const days = first ? Math.round((Date.parse(last) - Date.parse(first)) / 86400000) + 1 : 0;
|
|
120
|
+
return {
|
|
121
|
+
commits, first_commit: first, last_commit: last, days_alive: days, contributors: people.size,
|
|
122
|
+
daily_commits: dates.map((date) => ({ date, n: daily.get(date) })),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const EXCLUDE_DIR = /(^|\/)(node_modules|dist|build|out|\.next|\.nuxt|\.vercel|\.turbo|\.cache|coverage|vendor|Pods|DerivedData|__pycache__|\.venv|venv|target|\.gradle)(\/|$)/;
|
|
127
|
+
const EXCLUDE_FILE = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|Gemfile\.lock|Podfile\.lock|poetry\.lock|composer\.lock|go\.sum|uv\.lock|Package\.resolved)$|\.min\.(js|css)$|\.map$/;
|
|
128
|
+
const BINARY = /\.(png|jpe?g|gif|webp|avif|ico|icns|svg|pdf|zip|gz|tar|tgz|rar|7z|mp3|mp4|mov|wav|m4a|webm|woff2?|ttf|otf|eot|heic|psd|ai|sketch|fig|bin|exe|dll|so|dylib|a|o|class|jar|wasm|sqlite3?|db|xcassets|car|p12|mobileprovision)$/i;
|
|
129
|
+
|
|
130
|
+
// Data and documents are counted separately so a 900k-line JSON catalog does not read as code.
|
|
131
|
+
const NON_CODE = new Set(['JSON', 'CSV', 'Text', 'Markdown', 'XML', 'YAML', 'TOML', 'Other']);
|
|
132
|
+
|
|
133
|
+
const LANGS = {
|
|
134
|
+
ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
|
|
135
|
+
py: 'Python', swift: 'Swift', kt: 'Kotlin', java: 'Java', go: 'Go', rs: 'Rust', rb: 'Ruby', php: 'PHP', c: 'C', h: 'C', cpp: 'C++', cc: 'C++', hpp: 'C++', m: 'Objective-C', mm: 'Objective-C', cs: 'C#', dart: 'Dart', vue: 'Vue', svelte: 'Svelte',
|
|
136
|
+
html: 'HTML', css: 'CSS', scss: 'CSS', sql: 'SQL', sh: 'Shell', bash: 'Shell', zsh: 'Shell', prisma: 'Prisma', md: 'Markdown', mdx: 'Markdown', json: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML', csv: 'CSV', tsv: 'CSV', txt: 'Text', xml: 'XML', plist: 'XML', graphql: 'GraphQL',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export function codeMetrics(repo) {
|
|
140
|
+
let list;
|
|
141
|
+
try {
|
|
142
|
+
list = execFileSync('git', ['-C', repo, 'ls-files', '-z'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 * 256 }).split('\0').filter(Boolean);
|
|
143
|
+
} catch { return null; }
|
|
144
|
+
let lines = 0;
|
|
145
|
+
let nonCode = 0;
|
|
146
|
+
let files = 0;
|
|
147
|
+
const byLang = new Map();
|
|
148
|
+
for (const rel of list) {
|
|
149
|
+
if (EXCLUDE_DIR.test(rel) || EXCLUDE_FILE.test(rel) || BINARY.test(rel)) continue;
|
|
150
|
+
let buf;
|
|
151
|
+
try {
|
|
152
|
+
const st = fs.statSync(path.join(repo, rel));
|
|
153
|
+
if (!st.isFile()) continue;
|
|
154
|
+
buf = fs.readFileSync(path.join(repo, rel));
|
|
155
|
+
} catch { continue; }
|
|
156
|
+
if (buf.subarray(0, 8192).includes(0)) continue;
|
|
157
|
+
let n = 0;
|
|
158
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n++;
|
|
159
|
+
if (buf.length && buf[buf.length - 1] !== 10) n++;
|
|
160
|
+
files++;
|
|
161
|
+
const ext = path.extname(rel).slice(1).toLowerCase();
|
|
162
|
+
const lang = LANGS[ext] || 'Other';
|
|
163
|
+
if (NON_CODE.has(lang)) { nonCode += n; continue; }
|
|
164
|
+
lines += n;
|
|
165
|
+
byLang.set(lang, (byLang.get(lang) || 0) + n);
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
lines, files, non_code_lines: nonCode,
|
|
169
|
+
languages: [...byLang.entries()].sort((a, b) => b[1] - a[1]).map(([name, n]) => ({ name, lines: n })),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function githubSlug(remote) {
|
|
174
|
+
const m = remote && remote.match(/^github\.com\/([^/]+)\/([^/]+)$/);
|
|
175
|
+
return m ? { owner: m[1], repo: m[2] } : { owner: '', repo: '' };
|
|
176
|
+
}
|