lostvibe 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -2
- package/bin/lostvibe.mjs +632 -0
- package/package.json +32 -4
- package/src/collectors.mjs +129 -0
- package/src/connections.mjs +116 -0
- package/src/gitinfo.mjs +176 -0
- package/src/hooks.mjs +98 -0
- package/src/logs.mjs +586 -0
- package/src/prices.mjs +1127 -0
- package/src/push.mjs +56 -0
- package/src/remote.mjs +111 -0
- package/src/reported.mjs +43 -0
- package/src/scan.mjs +212 -0
- package/src/table.mjs +100 -0
package/src/push.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import readline from 'node:readline/promises';
|
|
2
|
+
|
|
3
|
+
// Day rows are small and the server keeps the larger value, so any split or retry is safe.
|
|
4
|
+
export function uploadBatches(projects, maxBytes = 2_800_000) {
|
|
5
|
+
const parts = [];
|
|
6
|
+
for (const p of projects) {
|
|
7
|
+
const base = { ...p, verified: { ...p.verified, ai: null } };
|
|
8
|
+
const rows = p.usage_days || [];
|
|
9
|
+
if (rows.length) for (let i = 0; i < rows.length; i += 4000) parts.push({ ...base, usage_days: rows.slice(i, i + 4000) });
|
|
10
|
+
else parts.push(base);
|
|
11
|
+
}
|
|
12
|
+
const batches = []; let batch = [];
|
|
13
|
+
for (const p of parts) {
|
|
14
|
+
if (Buffer.byteLength(JSON.stringify({ projects: [p] })) > maxBytes) throw new Error(`프로젝트 데이터가 너무 크다: ${p.project.name}`);
|
|
15
|
+
if (batch.length >= 20 || (batch.reduce((s,x) => s+(x.usage_days?.length || 0),0)+(p.usage_days?.length || 0) > 5000) || (batch.length && Buffer.byteLength(JSON.stringify({ projects: [...batch, p] })) > maxBytes)) { batches.push(batch); batch = []; }
|
|
16
|
+
batch.push(p);
|
|
17
|
+
}
|
|
18
|
+
if (batch.length) batches.push(batch);
|
|
19
|
+
return batches;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function push({ projects, token, site, yes, quiet, deviceId, onAccepted = () => {} }) {
|
|
23
|
+
const say = quiet ? () => {} : console.log;
|
|
24
|
+
say(`\n보낼 곳: ${site}`);
|
|
25
|
+
say('날짜별 합계 숫자와 프로젝트 정보만 보낸다. 코드, 대화 내용, 응답별 기록은 보내지 않는다.');
|
|
26
|
+
for (const p of projects) say(` - ${p.project.name} 토큰 ${p.verified.ai?.tokens.total || 0}, 커밋 ${p.verified.git?.commits ?? 0}`);
|
|
27
|
+
if (!yes) {
|
|
28
|
+
if (!process.stdin.isTTY) throw new Error('확인할 수 없는 환경이다. --yes 를 붙여야 보낸다.');
|
|
29
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
const a = await rl.question(`${projects.length}개를 보낼까? (y/n) `); rl.close();
|
|
31
|
+
if (!/^y/i.test(a.trim())) return;
|
|
32
|
+
}
|
|
33
|
+
let created = 0; let updated = 0; let ignored = 0;
|
|
34
|
+
for (const batch of uploadBatches(projects)) {
|
|
35
|
+
let res;
|
|
36
|
+
for (let retry = 0; ; retry++) {
|
|
37
|
+
res = await fetch(`${site.replace(/\/$/, '')}/api/ingest`, {
|
|
38
|
+
method: 'POST', signal: AbortSignal.timeout(120000),
|
|
39
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
40
|
+
body: JSON.stringify({ deviceId, projects: batch }),
|
|
41
|
+
});
|
|
42
|
+
if (res.status !== 429 || retry >= 8) break;
|
|
43
|
+
const wait = Math.max(1, Math.min(30, Number(res.headers.get('retry-after')) || 2));
|
|
44
|
+
await new Promise((r) => setTimeout(r, wait * 1000));
|
|
45
|
+
}
|
|
46
|
+
const body = await res.json().catch(() => ({}));
|
|
47
|
+
if (!res.ok) throw new Error(`업로드 실패 (${res.status}): ${body.error || '알 수 없는 오류'}`);
|
|
48
|
+
const accepted = new Set(body.accepted || []);
|
|
49
|
+
const removed = new Set(body.ignored || []);
|
|
50
|
+
for (const p of batch) if (accepted.has(p.project.id) || removed.has(p.project.id)) await onAccepted(p, removed.has(p.project.id));
|
|
51
|
+
if (body.rejected) throw new Error(`${body.rejected}개 프로젝트를 서버가 받지 않았다. 전송 완료로 처리하지 않았다.`);
|
|
52
|
+
created += body.created || 0; updated += body.updated || 0; ignored += removed.size;
|
|
53
|
+
}
|
|
54
|
+
say(`올렸다. 새로 ${created}개, 갱신 ${updated}개${ignored ? `, 뽑은 프로젝트 ${ignored}개 제외` : ''}.`);
|
|
55
|
+
say(`내 땅: ${site.replace(/\/$/, '')}/`);
|
|
56
|
+
}
|
package/src/remote.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
function onPath(cmd) {
|
|
7
|
+
try { execFileSync(process.platform === 'win32' ? 'where' : 'which', [cmd], { stdio: 'ignore' }); return true; } catch { return false; }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function jsonFetch(url, token) {
|
|
11
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(15000), headers: { Authorization: `Bearer ${token}`, 'User-Agent': 'lostvibe-cli', Accept: 'application/json' } });
|
|
12
|
+
if (!res.ok) return null;
|
|
13
|
+
return res.json();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function cliJson(cmd, args) {
|
|
17
|
+
try {
|
|
18
|
+
const out = execFileSync(cmd, args, { encoding: 'utf8', cwd: os.tmpdir(), stdio: ['ignore', 'pipe', 'ignore'], timeout: 60000, maxBuffer: 1024 * 1024 * 64 });
|
|
19
|
+
return JSON.parse(out);
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hasGh = onPath('gh');
|
|
24
|
+
const hasVercel = onPath('vercel');
|
|
25
|
+
|
|
26
|
+
// Optional source: a GITHUB_TOKEN, or an already signed-in gh. Without either the fields stay null.
|
|
27
|
+
export async function readGithub(owner, repo) {
|
|
28
|
+
if (!owner || !repo) return null;
|
|
29
|
+
let d = null;
|
|
30
|
+
if (process.env.GITHUB_TOKEN) d = await jsonFetch(`https://api.github.com/repos/${owner}/${repo}`, process.env.GITHUB_TOKEN);
|
|
31
|
+
else if (hasGh) d = cliJson('gh', ['api', `repos/${owner}/${repo}`]);
|
|
32
|
+
if (!d) return null;
|
|
33
|
+
return { id: d.id, stars: d.stargazers_count ?? null, forks: d.forks_count ?? null, public: d.private === undefined ? null : !d.private };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function vercelGet(endpoint) {
|
|
37
|
+
if (process.env.VERCEL_TOKEN) return jsonFetch(`https://api.vercel.com${endpoint}`, process.env.VERCEL_TOKEN);
|
|
38
|
+
if (hasVercel) return cliJson('vercel', ['api', endpoint, '--raw']);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let projectsPromise = null;
|
|
43
|
+
|
|
44
|
+
function vercelProjects() {
|
|
45
|
+
if (!projectsPromise) {
|
|
46
|
+
projectsPromise = (async () => {
|
|
47
|
+
const all = [];
|
|
48
|
+
let until = null;
|
|
49
|
+
for (let i = 0; i < 10; i++) {
|
|
50
|
+
const d = await vercelGet(`/v9/projects?limit=100${until ? `&until=${until}` : ''}`);
|
|
51
|
+
if (!d?.projects) break;
|
|
52
|
+
all.push(...d.projects);
|
|
53
|
+
until = d.pagination?.next;
|
|
54
|
+
if (!until) break;
|
|
55
|
+
}
|
|
56
|
+
return all;
|
|
57
|
+
})();
|
|
58
|
+
}
|
|
59
|
+
return projectsPromise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function linkedIds(dirs) {
|
|
63
|
+
const ids = new Set();
|
|
64
|
+
const look = (dir, depth) => {
|
|
65
|
+
try {
|
|
66
|
+
const j = JSON.parse(fs.readFileSync(path.join(dir, '.vercel', 'project.json'), 'utf8'));
|
|
67
|
+
if (j.projectId) ids.add(j.projectId);
|
|
68
|
+
} catch { /* not linked */ }
|
|
69
|
+
if (depth === 0) return;
|
|
70
|
+
let entries = [];
|
|
71
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
72
|
+
for (const e of entries) {
|
|
73
|
+
if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') look(path.join(dir, e.name), depth - 1);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
for (const d of dirs) look(d, 2);
|
|
77
|
+
return ids;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Optional source: VERCEL_TOKEN, or an already signed-in vercel CLI.
|
|
81
|
+
// A repo matches through a linked folder, the project's GitHub link, or the same name.
|
|
82
|
+
export async function readVercel(owner, repo, name, dirs = []) {
|
|
83
|
+
const projects = await vercelProjects();
|
|
84
|
+
if (!projects.length) return null;
|
|
85
|
+
const ids = linkedIds(dirs);
|
|
86
|
+
const lower = String(name || '').toLowerCase();
|
|
87
|
+
const matched = projects.filter((x) => ids.has(x.id)
|
|
88
|
+
|| (owner && x.link?.type === 'github' && (x.link.org || '').toLowerCase() === owner && (x.link.repo || '').toLowerCase() === repo)
|
|
89
|
+
|| (lower && x.name.toLowerCase() === lower));
|
|
90
|
+
if (!matched.length) return null;
|
|
91
|
+
let count = 0;
|
|
92
|
+
let last = null;
|
|
93
|
+
let url = null;
|
|
94
|
+
for (const p of matched) {
|
|
95
|
+
let until = null;
|
|
96
|
+
for (let i = 0; i < 30; i++) {
|
|
97
|
+
const d = await vercelGet(`/v6/deployments?projectId=${p.id}&target=production&limit=100${until ? `&until=${until}` : ''}`);
|
|
98
|
+
if (!d?.deployments) break;
|
|
99
|
+
count += d.deployments.length;
|
|
100
|
+
for (const x of d.deployments) {
|
|
101
|
+
const t = x.created || x.createdAt;
|
|
102
|
+
if (t && (!last || t > last)) last = t;
|
|
103
|
+
}
|
|
104
|
+
until = d.pagination?.next;
|
|
105
|
+
if (!until) break;
|
|
106
|
+
}
|
|
107
|
+
const alias = p.targets?.production?.alias?.[0];
|
|
108
|
+
if (!url && alias) url = `https://${alias}`;
|
|
109
|
+
}
|
|
110
|
+
return { count, last: last ? new Date(last).toISOString().slice(0, 10) : null, provider: 'vercel', url };
|
|
111
|
+
}
|
package/src/reported.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import readline from 'node:readline/promises';
|
|
2
|
+
|
|
3
|
+
export function emptyReported() {
|
|
4
|
+
return {
|
|
5
|
+
status: null, launched: false, launch_date: null, platform: null,
|
|
6
|
+
signups: null, visitors: null, downloads: null, revenue_usd: null, answered_at: null,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function num(s) {
|
|
11
|
+
const v = Number(String(s).replace(/[,\s$]/g, ''));
|
|
12
|
+
return s !== '' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const QUESTIONS = [
|
|
16
|
+
['status', '지금 상태는? 살아 있음(a) / 죽음(d)'],
|
|
17
|
+
['launched', '출시했나? (y/n)'],
|
|
18
|
+
['launch_date', '출시일은? (YYYY-MM-DD)'],
|
|
19
|
+
['platform', '플랫폼은? (web / ios / android / 기타)'],
|
|
20
|
+
['signups', '가입자 수는?'],
|
|
21
|
+
['visitors', '누적 방문자 수는?'],
|
|
22
|
+
['downloads', '앱 다운로드 수는?'],
|
|
23
|
+
['revenue_usd', '지금까지 수익은? (USD)'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export async function askReported(name, prev) {
|
|
27
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
28
|
+
const r = { ...emptyReported(), ...prev };
|
|
29
|
+
console.log(`\n[${name}] 사람만 아는 숫자를 묻는다. 모르면 빈 칸으로 넘긴다.`);
|
|
30
|
+
for (const [k, q] of QUESTIONS) {
|
|
31
|
+
if (!r.launched && ['launch_date', 'platform', 'downloads'].includes(k)) continue;
|
|
32
|
+
const a = (await rl.question(` ${q} `)).trim();
|
|
33
|
+
if (a === '') continue;
|
|
34
|
+
if (k === 'status') r.status = /^d/i.test(a) ? 'dead' : 'alive';
|
|
35
|
+
else if (k === 'launched') r.launched = /^y/i.test(a);
|
|
36
|
+
else if (k === 'launch_date') r.launch_date = /^\d{4}-\d{2}-\d{2}$/.test(a) ? a : null;
|
|
37
|
+
else if (k === 'platform') r.platform = a.toLowerCase();
|
|
38
|
+
else r[k] = num(a);
|
|
39
|
+
}
|
|
40
|
+
r.answered_at = new Date().toISOString();
|
|
41
|
+
rl.close();
|
|
42
|
+
return r;
|
|
43
|
+
}
|
package/src/scan.mjs
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { bucketToAi, mergeBuckets, readAider, readClaude, readCodex, readGemini, readGrok, readOpenCode, readTaskLogs, setZone, usageDays } from './logs.mjs';
|
|
5
|
+
import { codeMetrics, commitCount, gitMetrics, githubSlug, resolveCwd, rootCommit, sha, projectAliases, projectIdentity } from './gitinfo.mjs';
|
|
6
|
+
import { PRICE_TABLE_DATE } from './prices.mjs';
|
|
7
|
+
import { readGithub, readVercel } from './remote.mjs';
|
|
8
|
+
import { emptyReported } from './reported.mjs';
|
|
9
|
+
|
|
10
|
+
const HOME = os.homedir();
|
|
11
|
+
|
|
12
|
+
const COPY_SUFFIX = /[-_.](backup|bak|old|copy)\d*$/i;
|
|
13
|
+
|
|
14
|
+
function isTempPath(cwd) {
|
|
15
|
+
if (cwd === 'unknown' || cwd === HOME || cwd === '/') return true;
|
|
16
|
+
if (/^\/(private\/|tmp\/|var\/folders\/)/.test(cwd)) return true;
|
|
17
|
+
if (cwd.includes('/scratch-workspaces/')) return true;
|
|
18
|
+
return ['.claude', '.codex', '.gemini', '.grok', '.cline', '.aider', '.local', 'Library'].some((d) => cwd.startsWith(path.join(HOME, d)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Folders without git are placed by the workspace that holds the user's repos:
|
|
22
|
+
// the first folder below it is the project, the workspace itself is not one.
|
|
23
|
+
function folderProject(cwd, workspaces, repoNames) {
|
|
24
|
+
for (const w of workspaces) {
|
|
25
|
+
if (cwd === w) return null;
|
|
26
|
+
const rel = path.relative(w, cwd);
|
|
27
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
28
|
+
const name = rel.split(path.sep)[0];
|
|
29
|
+
const lower = name.toLowerCase();
|
|
30
|
+
// A leftover copy or worktree folder such as app-backup belongs to app.
|
|
31
|
+
const base = COPY_SUFFIX.test(lower) ? lower.replace(COPY_SUFFIX, '') : null;
|
|
32
|
+
if (base && repoNames.has(base)) return repoNames.get(base);
|
|
33
|
+
return { key: `folder:${lower}`, name, repoPath: null, remote: null };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function siblings(dirs) {
|
|
39
|
+
const found = new Set();
|
|
40
|
+
for (const parent of dirs) {
|
|
41
|
+
let entries;
|
|
42
|
+
try { entries = fs.readdirSync(parent, { withFileTypes: true }); } catch { continue; }
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
if (!e.isDirectory() || e.name.startsWith('.') || COPY_SUFFIX.test(e.name)) continue;
|
|
45
|
+
const p = path.join(parent, e.name);
|
|
46
|
+
if (fs.existsSync(path.join(p, '.git'))) found.add(p);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Aider writes beside the code, so its history is looked for in every folder the other tools worked in.
|
|
53
|
+
function gitRootOf(dir) {
|
|
54
|
+
let d = dir;
|
|
55
|
+
for (let i = 0; i < 40; i++) {
|
|
56
|
+
if (fs.existsSync(path.join(d, '.git'))) return d;
|
|
57
|
+
const up = path.dirname(d);
|
|
58
|
+
if (up === d) return null;
|
|
59
|
+
d = up;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function aiderDirs(candidates, extraRoots) {
|
|
64
|
+
const out = new Set();
|
|
65
|
+
for (const c of candidates) { if (c === 'unknown' || c === HOME || c === '/') continue; out.add(c); const g = gitRootOf(c); if (g) out.add(g); }
|
|
66
|
+
for (const p of siblings(extraRoots)) out.add(p);
|
|
67
|
+
return [...out];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readJson(p) {
|
|
71
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The zone is fixed the first time this computer scans.
|
|
75
|
+
function deviceZone(dir) {
|
|
76
|
+
if (!dir) return null;
|
|
77
|
+
const file = path.join(dir, 'device-zone');
|
|
78
|
+
try { return fs.readFileSync(file, 'utf8').trim(); } catch { /* first scan */ }
|
|
79
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
80
|
+
try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, zone, { mode: 0o600 }); } catch { /* the current zone still works */ }
|
|
81
|
+
return zone;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function scanAll({ extraRoots = [], onProgress = () => {}, reportedFor = () => null, cacheDir = null, only = null } = {}) {
|
|
85
|
+
setZone(deviceZone(cacheDir));
|
|
86
|
+
const cacheFile = cacheDir ? path.join(cacheDir, 'log-cache-v2.json') : null;
|
|
87
|
+
const cache = (cacheFile && readJson(cacheFile)) || { files: {} };
|
|
88
|
+
const buckets = new Map();
|
|
89
|
+
onProgress('Claude Code 로그 읽는 중');
|
|
90
|
+
const cl = await readClaude(buckets, { cache });
|
|
91
|
+
onProgress('Codex 로그 읽는 중');
|
|
92
|
+
const cx = await readCodex(buckets, { cache });
|
|
93
|
+
const codexScope = cx.files ? 'project' : 'none';
|
|
94
|
+
// Folders the first two tools worked in also name Gemini's older hashed folders and Aider's history files.
|
|
95
|
+
const candidates = () => [...new Set([...buckets.keys(), process.cwd(), ...extraRoots.map((p) => path.resolve(p))])].filter((c) => c !== 'unknown');
|
|
96
|
+
onProgress('Gemini CLI 로그 읽는 중');
|
|
97
|
+
const gm = await readGemini(buckets, { cache, candidates: candidates() });
|
|
98
|
+
onProgress('Grok 로그 읽는 중');
|
|
99
|
+
const gk = await readGrok(buckets, { cache });
|
|
100
|
+
onProgress('Cline / Roo Code / Kilo Code 로그 읽는 중');
|
|
101
|
+
const tl = await readTaskLogs(buckets, { cache });
|
|
102
|
+
onProgress('OpenCode 로그 읽는 중');
|
|
103
|
+
const oc = await readOpenCode(buckets, { cache });
|
|
104
|
+
const kc = await readOpenCode(buckets, { cache, tool: 'kilo-code', app: 'kilo' });
|
|
105
|
+
onProgress('Aider 기록 읽는 중');
|
|
106
|
+
const ad = await readAider(buckets, { cache, dirs: aiderDirs(candidates(), extraRoots.map((p) => path.resolve(p))) });
|
|
107
|
+
if (cacheFile) {
|
|
108
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
109
|
+
const temp = `${cacheFile}.${process.pid}.tmp`;
|
|
110
|
+
fs.writeFileSync(temp, JSON.stringify(cache)); fs.renameSync(temp, cacheFile);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const cwds = [...buckets.keys()];
|
|
114
|
+
const groups = new Map();
|
|
115
|
+
const unattributed = [];
|
|
116
|
+
const place = (cwd, r) => {
|
|
117
|
+
if (!groups.has(r.key)) groups.set(r.key, { key: r.key, remote: r.remote, name: r.name, repos: new Set(), buckets: [], cwds: [] });
|
|
118
|
+
const g = groups.get(r.key);
|
|
119
|
+
if (r.repoPath) g.repos.add(r.repoPath);
|
|
120
|
+
g.buckets.push(buckets.get(cwd));
|
|
121
|
+
g.cwds.push(cwd);
|
|
122
|
+
};
|
|
123
|
+
const noRepo = [];
|
|
124
|
+
const workspaces = new Set(extraRoots.map((p) => path.resolve(p)));
|
|
125
|
+
const current = resolveCwd(process.cwd());
|
|
126
|
+
if (current.repoPath) {
|
|
127
|
+
groups.set(current.key, { key: current.key, remote: current.remote, name: current.name, repos: new Set([current.repoPath]), buckets: [], cwds: [] });
|
|
128
|
+
workspaces.add(path.dirname(current.repoPath));
|
|
129
|
+
}
|
|
130
|
+
const repoNames = new Map();
|
|
131
|
+
for (const cwd of cwds) {
|
|
132
|
+
if (isTempPath(cwd)) { unattributed.push(buckets.get(cwd)); continue; }
|
|
133
|
+
const r = resolveCwd(cwd);
|
|
134
|
+
if (!r.repoPath) { noRepo.push(cwd); continue; }
|
|
135
|
+
place(cwd, r);
|
|
136
|
+
workspaces.add(path.dirname(r.repoPath));
|
|
137
|
+
repoNames.set(r.name.toLowerCase(), r);
|
|
138
|
+
}
|
|
139
|
+
const ordered = [...workspaces].sort((a, b) => b.length - a.length);
|
|
140
|
+
for (const cwd of noRepo) {
|
|
141
|
+
const r = folderProject(cwd, ordered, repoNames);
|
|
142
|
+
if (r) place(cwd, r); else unattributed.push(buckets.get(cwd));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Repos beside the ones with logs are candidates too: their logs may be gone but git remains.
|
|
146
|
+
const parents = new Set(extraRoots.map((p) => path.resolve(p)));
|
|
147
|
+
for (const g of groups.values()) for (const repo of g.repos) parents.add(path.dirname(repo));
|
|
148
|
+
for (const p of siblings(parents)) {
|
|
149
|
+
const r = resolveCwd(p);
|
|
150
|
+
if (!r.repoPath) continue;
|
|
151
|
+
if (!groups.has(r.key)) groups.set(r.key, { key: r.key, remote: r.remote, name: r.name, repos: new Set(), buckets: [], cwds: [] });
|
|
152
|
+
groups.get(r.key).repos.add(r.repoPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
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())) || only.has(projectIdentity([...g.repos][0] || null,g.key)));
|
|
157
|
+
for (let i = 0; i < list.length; i++) {
|
|
158
|
+
const g = list[i];
|
|
159
|
+
onProgress(`프로젝트 ${i + 1}/${list.length}: ${g.name}`);
|
|
160
|
+
const repo = [...g.repos].map((p) => [p, commitCount(p)]).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
|
|
161
|
+
const name = repo ? path.basename(repo) : g.name;
|
|
162
|
+
const id = projectIdentity(repo, g.key);
|
|
163
|
+
const merged = mergeBuckets(g.buckets);
|
|
164
|
+
const slug = githubSlug(g.remote);
|
|
165
|
+
const gh = process.env.LOSTVIBE_SKIP_REMOTE === '1' ? null : await readGithub(slug.owner, slug.repo);
|
|
166
|
+
const vc = process.env.LOSTVIBE_SKIP_REMOTE === '1' ? null : await readVercel(slug.owner, slug.repo, name, [...g.repos]);
|
|
167
|
+
const reported = { ...emptyReported(), ...(reportedFor(id) || {}) };
|
|
168
|
+
const hasLogs = g.buckets.length > 0;
|
|
169
|
+
projects.push({
|
|
170
|
+
schema: 3,
|
|
171
|
+
scanned_at: new Date().toISOString(),
|
|
172
|
+
project: {
|
|
173
|
+
id,
|
|
174
|
+
aliases: [...new Set([...projectAliases(repo, g.key), ...(gh?.id ? [sha(`github-id:${gh.id}`)] : [])])],
|
|
175
|
+
// A repo that gains a remote later can still find the project it was before.
|
|
176
|
+
...(repo && rootCommit(repo) ? { root_alias: sha(`root:${rootCommit(repo)}`) } : {}),
|
|
177
|
+
name,
|
|
178
|
+
path_hash: sha(repo || g.cwds[0] || g.key),
|
|
179
|
+
github: { owner: slug.owner, repo: slug.repo, public: gh ? gh.public : null },
|
|
180
|
+
},
|
|
181
|
+
verified: {
|
|
182
|
+
git: repo ? gitMetrics(repo) : null,
|
|
183
|
+
code: repo ? codeMetrics(repo) : null,
|
|
184
|
+
ai: hasLogs ? bucketToAi(merged, codexScope, PRICE_TABLE_DATE, reported.launch_date) : null,
|
|
185
|
+
github: { stars: gh ? gh.stars : null, forks: gh ? gh.forks : null },
|
|
186
|
+
deploy: { count: vc ? vc.count : null, last: vc ? vc.last : null, provider: vc ? vc.provider : null, url: vc ? vc.url : null },
|
|
187
|
+
},
|
|
188
|
+
reported,
|
|
189
|
+
usage_days: usageDays(merged),
|
|
190
|
+
sessions: merged.sessions.size,
|
|
191
|
+
});
|
|
192
|
+
projects[projects.length - 1]._local = { repo, cwds: g.cwds };
|
|
193
|
+
}
|
|
194
|
+
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
|
+
|
|
196
|
+
const rest = mergeBuckets(unattributed);
|
|
197
|
+
return {
|
|
198
|
+
schema: 3,
|
|
199
|
+
scanned_at: new Date().toISOString(),
|
|
200
|
+
log_files: { claude_code: cl.files, codex: cx.files, gemini_cli: gm.files, grok_cli: gk.files, cline: tl.files.cline, roo_code: tl.files['roo-code'], kilo_code: tl.files['kilo-code'] + kc.files, opencode: oc.files, aider: ad.files },
|
|
201
|
+
log_coverage_from: [...buckets.values()].map((b) => b.first).filter(Boolean).sort()[0] || null,
|
|
202
|
+
unattributed_tokens: rest.tokens.input + rest.tokens.output + rest.tokens.cache_create + rest.tokens.cache_read,
|
|
203
|
+
projects,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function stripLocal(p) {
|
|
208
|
+
const { _local, ...rest } = p;
|
|
209
|
+
return rest;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { readJson };
|
package/src/table.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
function width(s) {
|
|
2
|
+
let w = 0;
|
|
3
|
+
for (const ch of String(s)) w += /[ᄀ-ᅟ⺀-가-힣豈-︰--⦆¢-₩]/.test(ch) ? 2 : 1;
|
|
4
|
+
return w;
|
|
5
|
+
}
|
|
6
|
+
const pad = (s, n) => String(s) + ' '.repeat(Math.max(0, n - width(s)));
|
|
7
|
+
const lpad = (s, n) => ' '.repeat(Math.max(0, n - width(s))) + String(s);
|
|
8
|
+
|
|
9
|
+
export function compact(n) {
|
|
10
|
+
if (n === null || n === undefined) return '-';
|
|
11
|
+
if (n >= 1e8) return `${(n / 1e8).toFixed(n >= 1e10 ? 0 : 1)}억`;
|
|
12
|
+
if (n >= 1e4) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)}만`;
|
|
13
|
+
return n.toLocaleString('en-US');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const v = (x) => (x === null || x === undefined || x === '' ? '-' : `${x} [v]`);
|
|
17
|
+
const r = (x) => (x === null || x === undefined || x === '' ? '-' : `${x} [r]`);
|
|
18
|
+
|
|
19
|
+
function splitTokens(a) {
|
|
20
|
+
if (a?.by_tool) return a.by_tool;
|
|
21
|
+
const split = { claude: 0, gpt: 0 };
|
|
22
|
+
for (const m of a?.by_model || []) split[m.model.startsWith('claude') ? 'claude' : 'gpt'] += m.tokens;
|
|
23
|
+
return split;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function projectTable(p) {
|
|
27
|
+
const g = p.verified.git;
|
|
28
|
+
const c = p.verified.code;
|
|
29
|
+
const a = p.verified.ai;
|
|
30
|
+
const gh = p.verified.github;
|
|
31
|
+
const d = p.verified.deploy;
|
|
32
|
+
const rep = p.reported;
|
|
33
|
+
const left = [
|
|
34
|
+
['토큰 합계', a ? v(compact(a.tokens.total)) : '-'],
|
|
35
|
+
['Claude / GPT', a ? v(`${compact(splitTokens(a).claude)} / ${compact(splitTokens(a).gpt)}`) : '-'],
|
|
36
|
+
['캐시 읽기 제외', a ? v(compact(a.tokens.total - a.tokens.cache_read)) : '-'],
|
|
37
|
+
['주 모델', a ? v(a.main_model) : '-'],
|
|
38
|
+
['세션', a ? v(a.sessions) : '-'],
|
|
39
|
+
['추정 비용', a ? v(`$${a.cost_usd_estimate.toLocaleString('en-US')}`) : '-'],
|
|
40
|
+
['커밋', g ? v(g.commits) : '-'],
|
|
41
|
+
['생존 일수', g ? v(g.days_alive) : '-'],
|
|
42
|
+
['마지막 커밋', g ? v(g.last_commit) : '-'],
|
|
43
|
+
['코드 줄', c ? v(c.lines.toLocaleString('en-US')) : '-'],
|
|
44
|
+
['파일', c ? v(c.files) : '-'],
|
|
45
|
+
['배포', v(d.count)],
|
|
46
|
+
];
|
|
47
|
+
const right = [
|
|
48
|
+
['상태', r(rep.status)],
|
|
49
|
+
['출시', rep.answered_at ? r(rep.launched ? 'y' : 'n') : '-'],
|
|
50
|
+
['출시일', r(rep.launch_date)],
|
|
51
|
+
['플랫폼', r(rep.platform)],
|
|
52
|
+
['가입자', r(rep.signups)],
|
|
53
|
+
['방문자', r(rep.visitors)],
|
|
54
|
+
['다운로드', r(rep.downloads)],
|
|
55
|
+
['수익', r(rep.revenue_usd === null ? null : `$${rep.revenue_usd}`)],
|
|
56
|
+
['스타', v(gh.stars)],
|
|
57
|
+
['포크', v(gh.forks)],
|
|
58
|
+
['공개', v(p.project.github.public === null ? null : p.project.github.public ? 'yes' : 'no')],
|
|
59
|
+
];
|
|
60
|
+
const lw = Math.max(...left.map(([k, x]) => width(k) + width(x) + 2), 34);
|
|
61
|
+
const lines = [`\n${p.project.name}`, `${pad('투자', lw)} | 결과`, `${'-'.repeat(lw)}-+-${'-'.repeat(30)}`];
|
|
62
|
+
for (let i = 0; i < left.length; i++) {
|
|
63
|
+
const [lk, lx] = left[i];
|
|
64
|
+
const [rk, rx] = right[i] || ['', ''];
|
|
65
|
+
lines.push(`${pad(lk, 16)}${pad(lx, lw - 16)} | ${pad(rk, 12)}${rx}`);
|
|
66
|
+
}
|
|
67
|
+
if (a && rep.launch_date) lines.push(`출시 전 토큰 ${compact(a.before_launch_tokens)} / 출시 후 토큰 ${compact(a.after_launch_tokens)}`);
|
|
68
|
+
return lines.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function landTable(land) {
|
|
72
|
+
const rows = land.projects.map((p) => {
|
|
73
|
+
const a = p.verified.ai; const g = p.verified.git; const c = p.verified.code;
|
|
74
|
+
return [
|
|
75
|
+
p.project.name,
|
|
76
|
+
a ? compact(a.tokens.total) : '-',
|
|
77
|
+
a ? compact(splitTokens(a).claude) : '-',
|
|
78
|
+
a ? compact(splitTokens(a).gpt) : '-',
|
|
79
|
+
a ? compact(a.tokens.total - a.tokens.cache_read) : '-',
|
|
80
|
+
a ? `$${Math.round(a.cost_usd_estimate).toLocaleString('en-US')}` : '-',
|
|
81
|
+
a ? String(a.sessions) : '-',
|
|
82
|
+
g ? String(g.commits) : '-',
|
|
83
|
+
g ? String(g.days_alive) : '-',
|
|
84
|
+
c ? c.lines.toLocaleString('en-US') : '-',
|
|
85
|
+
p.verified.deploy.count ?? '-',
|
|
86
|
+
p.project.github.public === null ? '-' : p.project.github.public ? 'pub' : 'priv',
|
|
87
|
+
p.verified.github.stars ?? '-',
|
|
88
|
+
lastActive(p) || '-',
|
|
89
|
+
];
|
|
90
|
+
});
|
|
91
|
+
const head = ['프로젝트', '토큰', 'Claude', 'GPT', '캐시제외', '비용', '세션', '커밋', '일수', '줄', '배포', '공개', '별', '마지막'];
|
|
92
|
+
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 === 0 || i === 11 || i === 13 ? pad(x, ws[i]) : lpad(x, ws[i]))).join(' ');
|
|
94
|
+
return [fmt(head), ws.map((w) => '-'.repeat(w)).join(' '), ...rows.map(fmt)].join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function lastActive(p) {
|
|
98
|
+
const xs = [p.verified.git?.last_commit, p.verified.ai?.last_session].filter(Boolean).sort();
|
|
99
|
+
return xs[xs.length - 1] || null;
|
|
100
|
+
}
|