byuckchon-frontend-cli 1.0.3 → 1.4.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 +186 -8
- package/bin/index.js +157 -1
- package/package.json +32 -6
- package/src/ai/messageContent.js +84 -0
- package/src/ai/models.js +66 -0
- package/src/ai/provider.js +54 -0
- package/src/ai/systemPrompt.js +75 -0
- package/src/ai/tokenMeter.js +65 -0
- package/src/commands/adopt.js +146 -0
- package/src/commands/chat.js +300 -0
- package/src/commands/config.js +142 -0
- package/src/commands/genCmd.js +61 -0
- package/src/commands/indexCmd.js +89 -0
- package/src/commands/init.js +36 -13
- package/src/config/index.js +185 -0
- package/src/context/detect.js +228 -0
- package/src/generators/createBaseFiles.js +211 -190
- package/src/generators/createBcConfig.js +58 -0
- package/src/generators/createPackageJson.js +1 -1
- package/src/generators/createProject.js +2 -0
- package/src/history/store.js +117 -0
- package/src/indexer/chunker.js +42 -0
- package/src/indexer/embed.js +57 -0
- package/src/indexer/search.js +41 -0
- package/src/indexer/store.js +137 -0
- package/src/indexer/walker.js +45 -0
- package/src/openapi/cache.js +60 -0
- package/src/openapi/codegen.js +45 -0
- package/src/openapi/fetch.js +69 -0
- package/src/openapi/summary.js +75 -0
- package/src/prompts/initPrompts.js +24 -0
- package/src/ui/ChatApp.js +812 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 새 프로젝트 루트에 `bc.config.json` 을 만든다.
|
|
6
|
+
*
|
|
7
|
+
* - bc chat / bc gen 등이 이 파일을 읽어 프로젝트 컨텍스트(Figma, API, 컨벤션)를
|
|
8
|
+
* AI 에게 자동 주입한다.
|
|
9
|
+
* - API 키 같은 비밀값은 절대 여기 두지 말 것 (글로벌 설정 또는 .env 사용).
|
|
10
|
+
*/
|
|
11
|
+
export async function createBcConfig(rootDir, config) {
|
|
12
|
+
const bcConfig = {
|
|
13
|
+
$schema: 'https://byuckchon.dev/bc.schema.json',
|
|
14
|
+
ai: {
|
|
15
|
+
// null 이면 글로벌 기본 모델(=~/.bc/config.json) 을 따름.
|
|
16
|
+
model: config.aiModel ?? null,
|
|
17
|
+
},
|
|
18
|
+
design: {
|
|
19
|
+
figma: config.figmaUrl?.trim() || null,
|
|
20
|
+
figmaTokenEnv: 'FIGMA_TOKEN',
|
|
21
|
+
},
|
|
22
|
+
api: {
|
|
23
|
+
openapi: config.openapiUrl?.trim() || null,
|
|
24
|
+
baseUrl: null,
|
|
25
|
+
},
|
|
26
|
+
context: {
|
|
27
|
+
include: ['src/**/*.{ts,tsx,js,jsx}', 'bc.config.json'],
|
|
28
|
+
exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**'],
|
|
29
|
+
maxFiles: 20,
|
|
30
|
+
},
|
|
31
|
+
framework: config.framework,
|
|
32
|
+
// init 단계에선 사용자가 React/Next 중 골랐고 TS/Tailwind 가 항상 들어가니
|
|
33
|
+
// 감지 결과를 미리 채워둔다 (bc adopt 의 detected 와 같은 모양).
|
|
34
|
+
detected: {
|
|
35
|
+
language: 'ts',
|
|
36
|
+
styling: {
|
|
37
|
+
tailwind: true,
|
|
38
|
+
cssModules: false,
|
|
39
|
+
styledComponents: false,
|
|
40
|
+
emotion: false,
|
|
41
|
+
vanillaExtract: false,
|
|
42
|
+
},
|
|
43
|
+
packageManager: 'npm',
|
|
44
|
+
routing: config.framework === 'next' ? 'app-router' : null,
|
|
45
|
+
componentDirs: ['src/components'],
|
|
46
|
+
designTokensFiles: [],
|
|
47
|
+
hasStorybook: false,
|
|
48
|
+
hasTests: null,
|
|
49
|
+
detectedAt: new Date().toISOString(),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
await fs.writeFile(
|
|
54
|
+
path.join(rootDir, 'bc.config.json'),
|
|
55
|
+
JSON.stringify(bcConfig, null, 2) + '\n',
|
|
56
|
+
'utf8',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -4,6 +4,7 @@ import { exec as execCallback } from 'child_process';
|
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
|
|
6
6
|
import { createBaseFiles } from './createBaseFiles.js';
|
|
7
|
+
import { createBcConfig } from './createBcConfig.js';
|
|
7
8
|
import { createFolders } from './createFolders.js';
|
|
8
9
|
import { createPackageJson } from './createPackageJson.js';
|
|
9
10
|
import { createReadme } from './createReadme.js';
|
|
@@ -24,6 +25,7 @@ export async function createProject(config) {
|
|
|
24
25
|
await createPackageJson(rootDir, config);
|
|
25
26
|
await createBaseFiles(rootDir, config);
|
|
26
27
|
await createReadme(rootDir, config);
|
|
28
|
+
await createBcConfig(rootDir, config);
|
|
27
29
|
|
|
28
30
|
// 최신 버전(latest 포함) 의존성을 실제로 설치해 lockfile까지 생성
|
|
29
31
|
await exec('npm install', { cwd: rootDir });
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import { findProjectConfigPath } from '../config/index.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 챗 세션을 디스크에 저장/복구한다.
|
|
10
|
+
*
|
|
11
|
+
* 저장 위치 우선순위:
|
|
12
|
+
* 1) 프로젝트 안: <projectRoot>/.bc/history/<id>.json
|
|
13
|
+
* - bc.config.json 이 발견된 경우 그 옆 .bc 폴더 사용
|
|
14
|
+
* - 자동으로 .gitignore 에 들어감 (사용자 .gitignore 에 .bc/ 박혀있다는 가정)
|
|
15
|
+
* 2) 글로벌: ~/.bc/history/<cwdHash>/<id>.json
|
|
16
|
+
* - bc.config.json 이 없는 경우 (예: 빈 폴더에서 bc chat 실행)
|
|
17
|
+
*
|
|
18
|
+
* 동기 디스크 IO 는 ink 렌더에 끼지 않게 모두 await.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function cwdHash(cwd) {
|
|
22
|
+
return crypto.createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeId(date = new Date()) {
|
|
26
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
27
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(
|
|
28
|
+
date.getHours(),
|
|
29
|
+
)}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getHistoryDir(cwd = process.cwd()) {
|
|
33
|
+
const projectConfig = await findProjectConfigPath(cwd);
|
|
34
|
+
if (projectConfig) {
|
|
35
|
+
return path.join(path.dirname(projectConfig), '.bc', 'history');
|
|
36
|
+
}
|
|
37
|
+
return path.join(os.homedir(), '.bc', 'history', cwdHash(cwd));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function createSession({ model, cwd = process.cwd() } = {}) {
|
|
41
|
+
const dir = await getHistoryDir(cwd);
|
|
42
|
+
await fs.mkdir(dir, { recursive: true });
|
|
43
|
+
const id = makeId();
|
|
44
|
+
const session = {
|
|
45
|
+
id,
|
|
46
|
+
startedAt: new Date().toISOString(),
|
|
47
|
+
updatedAt: new Date().toISOString(),
|
|
48
|
+
model,
|
|
49
|
+
cwd,
|
|
50
|
+
messages: [],
|
|
51
|
+
};
|
|
52
|
+
return { ...session, _file: path.join(dir, `${id}.json`) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function saveSession(session) {
|
|
56
|
+
const file = session._file;
|
|
57
|
+
if (!file) throw new Error('session._file 누락');
|
|
58
|
+
const data = {
|
|
59
|
+
id: session.id,
|
|
60
|
+
startedAt: session.startedAt,
|
|
61
|
+
updatedAt: new Date().toISOString(),
|
|
62
|
+
model: session.model,
|
|
63
|
+
cwd: session.cwd,
|
|
64
|
+
messages: session.messages,
|
|
65
|
+
};
|
|
66
|
+
await fs.writeFile(file, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function listSessions(cwd = process.cwd(), { limit = 20 } = {}) {
|
|
70
|
+
const dir = await getHistoryDir(cwd);
|
|
71
|
+
let entries = [];
|
|
72
|
+
try {
|
|
73
|
+
entries = await fs.readdir(dir);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (e.code === 'ENOENT') return [];
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
const files = entries.filter((f) => f.endsWith('.json')).sort().reverse();
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const name of files.slice(0, limit)) {
|
|
81
|
+
const file = path.join(dir, name);
|
|
82
|
+
try {
|
|
83
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
84
|
+
const data = JSON.parse(raw);
|
|
85
|
+
const firstUser = data.messages?.find((m) => m.role === 'user');
|
|
86
|
+
out.push({
|
|
87
|
+
id: data.id,
|
|
88
|
+
file,
|
|
89
|
+
startedAt: data.startedAt,
|
|
90
|
+
updatedAt: data.updatedAt,
|
|
91
|
+
model: data.model,
|
|
92
|
+
turns: data.messages?.length ?? 0,
|
|
93
|
+
preview: firstUser?.text?.slice(0, 60) ?? '(빈 세션)',
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
/* 잘못된 파일 스킵 */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function loadSession(idOrFile, cwd = process.cwd()) {
|
|
103
|
+
let file = idOrFile;
|
|
104
|
+
if (!file.endsWith('.json') || !path.isAbsolute(file)) {
|
|
105
|
+
const dir = await getHistoryDir(cwd);
|
|
106
|
+
file = path.join(dir, idOrFile.endsWith('.json') ? idOrFile : `${idOrFile}.json`);
|
|
107
|
+
}
|
|
108
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
109
|
+
const data = JSON.parse(raw);
|
|
110
|
+
return { ...data, _file: file };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function loadLatestSession(cwd = process.cwd()) {
|
|
114
|
+
const list = await listSessions(cwd, { limit: 1 });
|
|
115
|
+
if (list.length === 0) return null;
|
|
116
|
+
return loadSession(list[0].id, cwd);
|
|
117
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 파일을 라인 기반 슬라이딩 윈도우로 청크 분할.
|
|
5
|
+
*
|
|
6
|
+
* v1 정책 — 단순하고 결정론적:
|
|
7
|
+
* - 한 청크 ≈ 80 라인, 인접 청크끼리 20 라인 겹침.
|
|
8
|
+
* - 너무 짧은 파일(80라인 이하)은 통째로 1청크.
|
|
9
|
+
* - 청크 텍스트 앞에 "// FILE: <relpath>:start-end" 헤더를 붙여서
|
|
10
|
+
* 임베딩이 파일 정체성에 가중치를 두도록 한다.
|
|
11
|
+
*
|
|
12
|
+
* 더 똑똑한 AST 기반 청킹은 v2 (ts-morph 도입 후) 에서.
|
|
13
|
+
*/
|
|
14
|
+
export function chunkFile({ relPath, content, chunkSize = 80, overlap = 20 }) {
|
|
15
|
+
const lines = content.split('\n');
|
|
16
|
+
const total = lines.length;
|
|
17
|
+
if (total === 0) return [];
|
|
18
|
+
|
|
19
|
+
const chunks = [];
|
|
20
|
+
let start = 0;
|
|
21
|
+
while (start < total) {
|
|
22
|
+
const end = Math.min(start + chunkSize, total);
|
|
23
|
+
const slice = lines.slice(start, end).join('\n');
|
|
24
|
+
const text = `// FILE: ${relPath}:${start + 1}-${end}\n${slice}`;
|
|
25
|
+
chunks.push({
|
|
26
|
+
id: hashId(`${relPath}:${start + 1}-${end}`),
|
|
27
|
+
file: relPath,
|
|
28
|
+
startLine: start + 1,
|
|
29
|
+
endLine: end,
|
|
30
|
+
text,
|
|
31
|
+
hash: crypto.createHash('sha1').update(text).digest('hex'),
|
|
32
|
+
});
|
|
33
|
+
if (end >= total) break;
|
|
34
|
+
start = end - overlap;
|
|
35
|
+
if (start < 0) start = 0;
|
|
36
|
+
}
|
|
37
|
+
return chunks;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hashId(s) {
|
|
41
|
+
return crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { embed, embedMany } from 'ai';
|
|
2
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_EMBED_MODEL = 'text-embedding-3-small';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 임베딩은 OpenAI 만 지원 (Anthropic 은 임베딩 API 가 없다).
|
|
8
|
+
*
|
|
9
|
+
* effective.apiKeys.openai 또는 ENV(OPENAI_API_KEY)/gateway 를 사용.
|
|
10
|
+
* 키 없으면 명확한 에러로 종료.
|
|
11
|
+
*/
|
|
12
|
+
export function makeEmbeddingModel(effective) {
|
|
13
|
+
const apiKey = effective.apiKeys?.openai;
|
|
14
|
+
if (!apiKey && !effective.gateway) {
|
|
15
|
+
const err = new Error(
|
|
16
|
+
'OpenAI API 키가 필요합니다 (임베딩 전용).\n' +
|
|
17
|
+
' - Anthropic 은 임베딩 API 를 제공하지 않습니다.\n' +
|
|
18
|
+
' - `bc config set-key openai <key>` 또는 OPENAI_API_KEY 환경변수로 등록하세요.\n' +
|
|
19
|
+
' - text-embedding-3-small 은 1M 토큰당 약 $0.02 로 매우 저렴합니다.',
|
|
20
|
+
);
|
|
21
|
+
err.code = 'BC_NO_OPENAI_KEY';
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
const openai = createOpenAI({
|
|
25
|
+
apiKey: apiKey ?? 'gateway',
|
|
26
|
+
baseURL: effective.gateway ?? undefined,
|
|
27
|
+
});
|
|
28
|
+
return openai.embedding(DEFAULT_EMBED_MODEL);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 청크 배열을 받아 임베딩 벡터를 추가해서 돌려준다.
|
|
33
|
+
* 100개 단위 배치, 각 배치 내부에선 SDK 가 다시 OpenAI 한도에 맞춰 처리.
|
|
34
|
+
*/
|
|
35
|
+
export async function embedChunks(model, chunks, { batchSize = 100, onBatch } = {}) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (let i = 0; i < chunks.length; i += batchSize) {
|
|
38
|
+
const batch = chunks.slice(i, i + batchSize);
|
|
39
|
+
const { embeddings } = await embedMany({
|
|
40
|
+
model,
|
|
41
|
+
values: batch.map((c) => c.text),
|
|
42
|
+
});
|
|
43
|
+
for (let j = 0; j < batch.length; j++) {
|
|
44
|
+
out.push({ ...batch[j], embedding: embeddings[j] });
|
|
45
|
+
}
|
|
46
|
+
if (onBatch) onBatch({ done: out.length, total: chunks.length });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 단건 — 검색 쿼리 임베딩. */
|
|
52
|
+
export async function embedQuery(model, query) {
|
|
53
|
+
const { embedding } = await embed({ model, value: query });
|
|
54
|
+
return embedding;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { DEFAULT_EMBED_MODEL };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { embedQuery, makeEmbeddingModel } from './embed.js';
|
|
2
|
+
import { loadIndex } from './store.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 쿼리 텍스트와 모든 청크 사이 cosine similarity 를 계산해 top-K 를 돌려준다.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} query
|
|
8
|
+
* @param {object} effective
|
|
9
|
+
* @param {object} [opts]
|
|
10
|
+
* @param {number} [opts.topK=5]
|
|
11
|
+
* @param {number} [opts.minScore=0] 너무 관련 없는 청크 컷오프 (0~1 스케일)
|
|
12
|
+
*/
|
|
13
|
+
export async function searchIndex(query, effective, { topK = 5, minScore = 0 } = {}) {
|
|
14
|
+
const idx = await loadIndex();
|
|
15
|
+
if (!idx || !idx.chunks?.length) return { ok: false, reason: 'no-index' };
|
|
16
|
+
|
|
17
|
+
const model = makeEmbeddingModel(effective);
|
|
18
|
+
const qVec = await embedQuery(model, query);
|
|
19
|
+
|
|
20
|
+
const scored = idx.chunks.map((c) => ({
|
|
21
|
+
chunk: c,
|
|
22
|
+
score: cosine(qVec, c.embedding),
|
|
23
|
+
}));
|
|
24
|
+
scored.sort((a, b) => b.score - a.score);
|
|
25
|
+
const top = scored.filter((s) => s.score >= minScore).slice(0, topK);
|
|
26
|
+
return { ok: true, results: top, total: idx.chunks.length };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cosine(a, b) {
|
|
30
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
31
|
+
let dot = 0;
|
|
32
|
+
let na = 0;
|
|
33
|
+
let nb = 0;
|
|
34
|
+
for (let i = 0; i < a.length; i++) {
|
|
35
|
+
dot += a[i] * b[i];
|
|
36
|
+
na += a[i] * a[i];
|
|
37
|
+
nb += b[i] * b[i];
|
|
38
|
+
}
|
|
39
|
+
if (na === 0 || nb === 0) return 0;
|
|
40
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
41
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { findProjectConfigPath } from '../config/index.js';
|
|
5
|
+
import { walkProjectFiles, relPath } from './walker.js';
|
|
6
|
+
import { chunkFile } from './chunker.js';
|
|
7
|
+
import { embedChunks, DEFAULT_EMBED_MODEL } from './embed.js';
|
|
8
|
+
|
|
9
|
+
const INDEX_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
export async function getIndexPaths(cwd = process.cwd()) {
|
|
12
|
+
const projectConfig = await findProjectConfigPath(cwd);
|
|
13
|
+
const projectRoot = projectConfig ? path.dirname(projectConfig) : cwd;
|
|
14
|
+
const dir = path.join(projectRoot, '.bc', 'index');
|
|
15
|
+
return {
|
|
16
|
+
projectRoot,
|
|
17
|
+
dir,
|
|
18
|
+
manifest: path.join(dir, 'manifest.json'),
|
|
19
|
+
chunks: path.join(dir, 'chunks.json'),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loadIndex(cwd = process.cwd()) {
|
|
24
|
+
const p = await getIndexPaths(cwd);
|
|
25
|
+
try {
|
|
26
|
+
const [manifestRaw, chunksRaw] = await Promise.all([
|
|
27
|
+
fs.readFile(p.manifest, 'utf8'),
|
|
28
|
+
fs.readFile(p.chunks, 'utf8'),
|
|
29
|
+
]);
|
|
30
|
+
return {
|
|
31
|
+
...JSON.parse(manifestRaw),
|
|
32
|
+
chunks: JSON.parse(chunksRaw),
|
|
33
|
+
_paths: p,
|
|
34
|
+
};
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e.code === 'ENOENT') return null;
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function saveIndex(p, manifest, chunks) {
|
|
42
|
+
await fs.mkdir(p.dir, { recursive: true });
|
|
43
|
+
await fs.writeFile(p.chunks, JSON.stringify(chunks), 'utf8');
|
|
44
|
+
await fs.writeFile(
|
|
45
|
+
p.manifest,
|
|
46
|
+
JSON.stringify({ ...manifest, chunkCount: chunks.length }, null, 2) + '\n',
|
|
47
|
+
'utf8',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 인덱스 빌드 — 파일 스캔, 청크 분할, 변경된 청크만 임베딩 후 저장.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* @param {object} opts.effective loadEffectiveConfig().effective
|
|
56
|
+
* @param {object} opts.contextCfg bc.config.json 의 context (include/exclude)
|
|
57
|
+
* @param {boolean} opts.rebuild true 면 기존 임베딩 무시하고 전부 재계산
|
|
58
|
+
* @param {(s:string)=>void} opts.onProgress 진행 메시지 콜백
|
|
59
|
+
*/
|
|
60
|
+
export async function buildIndex({
|
|
61
|
+
effective,
|
|
62
|
+
contextCfg = {},
|
|
63
|
+
rebuild = false,
|
|
64
|
+
onProgress = () => {},
|
|
65
|
+
} = {}) {
|
|
66
|
+
const p = await getIndexPaths();
|
|
67
|
+
const { projectRoot } = p;
|
|
68
|
+
|
|
69
|
+
onProgress(' 파일 목록 수집 중…');
|
|
70
|
+
const files = await walkProjectFiles(projectRoot, contextCfg);
|
|
71
|
+
if (files.length === 0) {
|
|
72
|
+
return { ok: false, reason: '인덱싱할 파일이 없습니다. bc.config.json 의 context.include 를 확인하세요.' };
|
|
73
|
+
}
|
|
74
|
+
onProgress(` 파일 ${files.length}개 발견. 청크 분할 중…`);
|
|
75
|
+
|
|
76
|
+
const fileMaxBytes = 100 * 1024;
|
|
77
|
+
const allChunks = [];
|
|
78
|
+
for (const abs of files) {
|
|
79
|
+
let content;
|
|
80
|
+
try {
|
|
81
|
+
const stat = await fs.stat(abs);
|
|
82
|
+
if (stat.size > fileMaxBytes) continue; // 너무 큰 파일은 스킵
|
|
83
|
+
content = await fs.readFile(abs, 'utf8');
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const r = relPath(projectRoot, abs);
|
|
88
|
+
for (const c of chunkFile({ relPath: r, content })) allChunks.push(c);
|
|
89
|
+
}
|
|
90
|
+
onProgress(` 청크 ${allChunks.length}개 생성.`);
|
|
91
|
+
|
|
92
|
+
// 기존 인덱스에서 hash 일치하는 청크의 임베딩을 재사용 (증분 갱신).
|
|
93
|
+
let reused = 0;
|
|
94
|
+
let toEmbed = allChunks;
|
|
95
|
+
if (!rebuild) {
|
|
96
|
+
const existing = await loadIndex(projectRoot);
|
|
97
|
+
if (existing && existing.embeddingModel === DEFAULT_EMBED_MODEL) {
|
|
98
|
+
const byHash = new Map(existing.chunks.map((c) => [c.hash, c.embedding]));
|
|
99
|
+
toEmbed = [];
|
|
100
|
+
for (const c of allChunks) {
|
|
101
|
+
const cached = byHash.get(c.hash);
|
|
102
|
+
if (cached) {
|
|
103
|
+
c.embedding = cached;
|
|
104
|
+
reused++;
|
|
105
|
+
} else {
|
|
106
|
+
toEmbed.push(c);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
onProgress(` ${reused}개 재사용, ${toEmbed.length}개 신규 임베딩 필요.`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (toEmbed.length > 0) {
|
|
114
|
+
onProgress(` OpenAI 임베딩 중… (model=${DEFAULT_EMBED_MODEL})`);
|
|
115
|
+
const { makeEmbeddingModel } = await import('./embed.js');
|
|
116
|
+
const model = makeEmbeddingModel(effective);
|
|
117
|
+
const embedded = await embedChunks(model, toEmbed, {
|
|
118
|
+
onBatch: ({ done, total }) => onProgress(` 배치 진행: ${done}/${total}`),
|
|
119
|
+
});
|
|
120
|
+
// 새로 임베딩된 청크를 allChunks 에 반영 (hash 일치하는 자리로 머지).
|
|
121
|
+
const newByHash = new Map(embedded.map((e) => [e.hash, e.embedding]));
|
|
122
|
+
for (const c of allChunks) {
|
|
123
|
+
if (!c.embedding) c.embedding = newByHash.get(c.hash);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const manifest = {
|
|
128
|
+
version: INDEX_VERSION,
|
|
129
|
+
embeddingModel: DEFAULT_EMBED_MODEL,
|
|
130
|
+
builtAt: new Date().toISOString(),
|
|
131
|
+
fileCount: files.length,
|
|
132
|
+
reused,
|
|
133
|
+
newlyEmbedded: toEmbed.length - reused < 0 ? toEmbed.length : toEmbed.length,
|
|
134
|
+
};
|
|
135
|
+
await saveIndex(p, manifest, allChunks);
|
|
136
|
+
return { ok: true, manifest, paths: p };
|
|
137
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* bc.config.json 의 context.include / context.exclude 글롭을 따라
|
|
7
|
+
* 인덱싱 대상 파일 절대경로 목록을 돌려준다.
|
|
8
|
+
*
|
|
9
|
+
* - 항상 node_modules / .bc / .git / dist / build / .next 는 제외
|
|
10
|
+
* - 100KB 넘는 파일은 인덱싱하지 않는다 (대부분 minified/generated)
|
|
11
|
+
*/
|
|
12
|
+
export async function walkProjectFiles(projectRoot, contextCfg = {}) {
|
|
13
|
+
const include =
|
|
14
|
+
contextCfg.include?.length
|
|
15
|
+
? contextCfg.include
|
|
16
|
+
: ['src/**/*.{ts,tsx,js,jsx}', 'app/**/*.{ts,tsx,js,jsx}', 'bc.config.json'];
|
|
17
|
+
|
|
18
|
+
const baseExclude = [
|
|
19
|
+
'**/node_modules/**',
|
|
20
|
+
'**/.bc/**',
|
|
21
|
+
'**/.git/**',
|
|
22
|
+
'**/dist/**',
|
|
23
|
+
'**/build/**',
|
|
24
|
+
'**/.next/**',
|
|
25
|
+
'**/.expo/**',
|
|
26
|
+
'**/coverage/**',
|
|
27
|
+
];
|
|
28
|
+
const userExclude = contextCfg.exclude ?? [];
|
|
29
|
+
|
|
30
|
+
const matches = await fg(include, {
|
|
31
|
+
cwd: projectRoot,
|
|
32
|
+
absolute: true,
|
|
33
|
+
onlyFiles: true,
|
|
34
|
+
ignore: [...baseExclude, ...userExclude],
|
|
35
|
+
dot: false,
|
|
36
|
+
suppressErrors: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// 중복 제거 (include 글롭이 겹칠 수 있음).
|
|
40
|
+
return Array.from(new Set(matches));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function relPath(projectRoot, abs) {
|
|
44
|
+
return path.relative(projectRoot, abs).split(path.sep).join('/');
|
|
45
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import { findProjectConfigPath } from '../config/index.js';
|
|
6
|
+
import { loadOpenApi } from './fetch.js';
|
|
7
|
+
|
|
8
|
+
const TTL_MS = 60 * 60 * 1000; // 1시간
|
|
9
|
+
|
|
10
|
+
async function getCacheDir(cwd = process.cwd()) {
|
|
11
|
+
const projectConfig = await findProjectConfigPath(cwd);
|
|
12
|
+
const root = projectConfig ? path.dirname(projectConfig) : cwd;
|
|
13
|
+
return path.join(root, '.bc', 'cache');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function keyFor(input) {
|
|
17
|
+
return crypto.createHash('sha1').update(String(input)).digest('hex').slice(0, 12);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* URL 캐시가 있으면 그걸로, 없거나 만료되었으면 fetch 후 갱신.
|
|
22
|
+
* 결과는 { doc, cached, source } 형태.
|
|
23
|
+
*
|
|
24
|
+
* - 네트워크 실패 시: 만료된 캐시라도 있으면 그걸로 폴백 (offline-friendly).
|
|
25
|
+
* - 캐시는 .bc/cache/openapi-<hash>.json 에 저장.
|
|
26
|
+
*/
|
|
27
|
+
export async function getCachedOpenApi(input) {
|
|
28
|
+
const dir = await getCacheDir();
|
|
29
|
+
const file = path.join(dir, `openapi-${keyFor(input)}.json`);
|
|
30
|
+
|
|
31
|
+
let cached = null;
|
|
32
|
+
let cachedFresh = false;
|
|
33
|
+
try {
|
|
34
|
+
const stat = await fs.stat(file);
|
|
35
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
36
|
+
cached = JSON.parse(raw);
|
|
37
|
+
cachedFresh = Date.now() - stat.mtimeMs < TTL_MS;
|
|
38
|
+
} catch {
|
|
39
|
+
/* miss */
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (cachedFresh && cached) {
|
|
43
|
+
return { doc: cached, cached: true, source: input };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const { doc } = await loadOpenApi(input);
|
|
48
|
+
if (doc) {
|
|
49
|
+
await fs.mkdir(dir, { recursive: true });
|
|
50
|
+
await fs.writeFile(file, JSON.stringify(doc), 'utf8');
|
|
51
|
+
return { doc, cached: false, source: input };
|
|
52
|
+
}
|
|
53
|
+
// doc 가 null (YAML) — 캐시 못 쓰지만 fetch 자체는 성공한 케이스
|
|
54
|
+
return { doc: null, cached: false, source: input };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// 네트워크/파싱 실패 — 만료된 캐시라도 있으면 그걸 돌려준다.
|
|
57
|
+
if (cached) return { doc: cached, cached: true, source: input, stale: true };
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import openapiTS, { astToString } from 'openapi-typescript';
|
|
5
|
+
|
|
6
|
+
const FILE_HEADER = `// AUTO-GENERATED by \`bc gen api-types\`
|
|
7
|
+
// Do not edit manually. Re-run \`bc gen api-types\` to refresh.
|
|
8
|
+
//
|
|
9
|
+
// Source: __SOURCE__
|
|
10
|
+
// Generated: __DATE__
|
|
11
|
+
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OpenAPI 입력으로부터 TypeScript 타입 파일을 생성해 디스크에 쓴다.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} args
|
|
18
|
+
* @param {string|object} args.input URL / 파일 경로 / 이미 파싱된 OpenAPI 객체
|
|
19
|
+
* @param {string} args.outFile 절대/상대 경로
|
|
20
|
+
* @param {string} args.sourceLabel 파일 헤더 주석에 표기할 출처 문자열
|
|
21
|
+
* @returns {{ outFile: string, bytes: number }}
|
|
22
|
+
*/
|
|
23
|
+
export async function generateApiTypes({ input, outFile, sourceLabel }) {
|
|
24
|
+
// openapi-typescript v7: 입력은 URL 객체, 파싱된 객체, 또는 path 문자열을 받음.
|
|
25
|
+
// 우리가 받은 input 이 URL 문자열이면 URL 인스턴스로, 파일 경로면 그대로 넘김.
|
|
26
|
+
let sdkInput = input;
|
|
27
|
+
if (typeof input === 'string' && /^https?:\/\//.test(input)) {
|
|
28
|
+
sdkInput = new URL(input);
|
|
29
|
+
} else if (typeof input === 'string') {
|
|
30
|
+
sdkInput = path.resolve(input);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ast = await openapiTS(sdkInput);
|
|
34
|
+
const code = astToString(ast);
|
|
35
|
+
|
|
36
|
+
const header = FILE_HEADER
|
|
37
|
+
.replace('__SOURCE__', sourceLabel ?? String(input))
|
|
38
|
+
.replace('__DATE__', new Date().toISOString());
|
|
39
|
+
|
|
40
|
+
const final = header + code + (code.endsWith('\n') ? '' : '\n');
|
|
41
|
+
|
|
42
|
+
await fs.mkdir(path.dirname(outFile), { recursive: true });
|
|
43
|
+
await fs.writeFile(outFile, final, 'utf8');
|
|
44
|
+
return { outFile, bytes: Buffer.byteLength(final, 'utf8') };
|
|
45
|
+
}
|