commit-ai-agent 1.0.8 → 1.0.9

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.
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pre-push 훅 스크립트 — Secret / 자격증명 유출 탐지
4
+ * .git/hooks/pre-push 에서 실행됩니다.
5
+ *
6
+ * 사용: node pre-push.js <projectPath>
7
+ * exit 0 → push 허용 / exit 1 → push 차단
8
+ */
9
+ import { execSync } from 'child_process';
10
+ import readline from 'readline';
11
+ import path from 'path';
12
+ import fs from 'fs';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ // 프로젝트 .env 로드 (GEMINI_API_KEY, PORT 등)
16
+ // Git Bash에서 $(pwd)는 /c/dev/... 형식으로 넘어옴 → Windows 경로로 변환
17
+ const rawPath = process.argv[2] || process.cwd();
18
+ const projectPath = rawPath.replace(/^\/([a-z])\//i, '$1:/');
19
+ try {
20
+ const envPath = path.join(projectPath, '.env');
21
+ if (fs.existsSync(envPath)) {
22
+ const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
23
+ for (const line of lines) {
24
+ const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
25
+ if (match && !process.env[match[1]]) {
26
+ process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
27
+ }
28
+ }
29
+ }
30
+ } catch {}
31
+
32
+ // SKIP_SECRET_SCAN=1 환경변수로 스캔 우회 가능
33
+ if (process.env.SKIP_SECRET_SCAN === '1') {
34
+ process.stderr.write(
35
+ '⚠️ commit-ai-agent: Secret 스캔 스킵 (SKIP_SECRET_SCAN=1)\n'
36
+ );
37
+ process.exit(0);
38
+ }
39
+
40
+ // ────────────────────────────────────────────────────────
41
+ // 탐지 패턴 목록
42
+ // ────────────────────────────────────────────────────────
43
+ const SECRET_PATTERNS = [
44
+ { name: 'AWS Access Key', pattern: /AKIA[0-9A-Z]{16}/g, severity: 'critical' },
45
+ { name: 'AWS Secret Key (40-char)', pattern: /(?<![A-Za-z0-9/+])([A-Za-z0-9/+]{40})(?![A-Za-z0-9/+])/g, severity: 'high' },
46
+ { name: 'Google API Key', pattern: /AIza[0-9A-Za-z\-_]{35}/g, severity: 'critical' },
47
+ { name: 'GitHub Personal Token', pattern: /ghp_[A-Za-z0-9]{36}/g, severity: 'critical' },
48
+ { name: 'GitHub OAuth Token', pattern: /gho_[A-Za-z0-9]{36}/g, severity: 'critical' },
49
+ { name: 'GitHub Actions Token', pattern: /github_pat_[A-Za-z0-9_]{82}/g, severity: 'critical' },
50
+ { name: 'Slack Token', pattern: /xox[baprs]-[0-9a-zA-Z\-]{10,48}/g, severity: 'critical' },
51
+ { name: 'Stripe Secret Key', pattern: /sk_(?:test|live)_[0-9a-zA-Z]{24}/g, severity: 'critical' },
52
+ { name: 'Stripe Public Key', pattern: /pk_(?:test|live)_[0-9a-zA-Z]{24}/g, severity: 'high' },
53
+ { name: 'Private Key PEM', pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY/g, severity: 'critical' },
54
+ { name: 'JWT Token', pattern: /eyJ[A-Za-z0-9\-_]{10,}\.eyJ[A-Za-z0-9\-_]{10,}\.[A-Za-z0-9\-_.+/]{20,}/g, severity: 'high' },
55
+ { name: 'Password Assignment', pattern: /(?:password|passwd|pwd)\s*[:=]\s*['"]?(?!(?:your_|example|placeholder|changeme|dummy|test|sample|xxx)[A-Za-z0-9])[A-Za-z0-9!@#$%^&*_\-]{8,}/gi, severity: 'medium' },
56
+ { name: 'API Key Assignment', pattern: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*['"]?(?!your_|example|placeholder)[A-Za-z0-9_\-]{16,}/gi, severity: 'medium' },
57
+ { name: 'Generic Token Assignment', pattern: /(?:access[_-]?token|auth[_-]?token)\s*[:=]\s*['"]?[A-Za-z0-9_\-]{20,}/gi, severity: 'medium' },
58
+ ];
59
+
60
+ // 알려진 공개 상수 — secret이 아님 (git 내부 SHA 등)
61
+ const SKIP_VALUES = new Set([
62
+ '0000000000000000000000000000000000000000', // git null SHA (새 브랜치)
63
+ '4b825dc642cb6eb9a060e54bf8d69288fbee4904', // git 빈 트리 SHA
64
+ ]);
65
+
66
+ // 스캔 제외 파일/경로
67
+ const SKIP_FILENAMES = new Set([
68
+ '.gitignore', '.env.example', '.env.sample', '.env.template', '.env.test',
69
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
70
+ 'CHANGELOG.md', 'README.md', 'LICENSE',
71
+ ]);
72
+ const SKIP_EXTENSIONS = new Set([
73
+ '.min.js', '.map', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.ico',
74
+ '.pdf', '.woff', '.woff2', '.ttf', '.eot', '.zip', '.gz',
75
+ ]);
76
+ const SKIP_DIRS = new Set([
77
+ 'node_modules', '.git', 'dist', 'build', '.next', 'out',
78
+ 'coverage', '__pycache__', '.venv', 'vendor',
79
+ ]);
80
+
81
+ function shouldSkipFile(filePath) {
82
+ const basename = path.basename(filePath);
83
+ const ext = path.extname(filePath);
84
+
85
+ if (SKIP_FILENAMES.has(basename)) return true;
86
+ if (SKIP_EXTENSIONS.has(ext)) return true;
87
+
88
+ const parts = filePath.split(/[/\\]/);
89
+ for (const dir of SKIP_DIRS) {
90
+ if (parts.includes(dir)) return true;
91
+ }
92
+
93
+ return false;
94
+ }
95
+
96
+ /**
97
+ * 파일 내용에서 secret 패턴을 스캔합니다.
98
+ */
99
+ function scanContent(content, filePath) {
100
+ const findings = [];
101
+ const isTestFile = /(?:test|spec|__test__|__mock__|fixture)/i.test(filePath);
102
+ const lines = content.split('\n');
103
+
104
+ for (const { name, pattern, severity } of SECRET_PATTERNS) {
105
+ // 테스트 파일에서는 medium severity 스킵 (오탐 多)
106
+ if (isTestFile && severity === 'medium') continue;
107
+
108
+ pattern.lastIndex = 0;
109
+ let match;
110
+
111
+ while ((match = pattern.exec(content)) !== null) {
112
+ const upToMatch = content.slice(0, match.index);
113
+ const lineNumber = upToMatch.split('\n').length;
114
+ const lineContent = (lines[lineNumber - 1] || '').trim();
115
+
116
+ // 주석 줄 스킵
117
+ if (/^(?:\/\/|#|\*|<!--)/.test(lineContent)) continue;
118
+
119
+ const val = match[0];
120
+
121
+ // 알려진 공개 상수 스킵
122
+ if (SKIP_VALUES.has(val)) continue;
123
+ const masked =
124
+ val.length > 8
125
+ ? val.slice(0, 4) + '****' + val.slice(-4)
126
+ : '****';
127
+
128
+ findings.push({
129
+ name,
130
+ severity,
131
+ file: filePath,
132
+ line: lineNumber,
133
+ masked,
134
+ lineContent: lineContent.slice(0, 120),
135
+ });
136
+
137
+ // 같은 줄에서 중복 탐지 방지
138
+ if (findings.filter((f) => f.file === filePath && f.line === lineNumber).length > 2) break;
139
+ }
140
+ }
141
+
142
+ return findings;
143
+ }
144
+
145
+ /**
146
+ * push될 커밋 범위에서 변경된 파일 목록을 가져옵니다.
147
+ */
148
+ function getChangedFiles(localSha, remoteSha) {
149
+ try {
150
+ const isNewBranch = remoteSha === '0000000000000000000000000000000000000000';
151
+ const range = isNewBranch
152
+ ? `4b825dc642cb6eb9a060e54bf8d69288fbee4904..${localSha}` // empty tree → HEAD
153
+ : `${remoteSha}..${localSha}`;
154
+
155
+ return execSync(`git diff --name-only ${range}`, {
156
+ cwd: projectPath,
157
+ encoding: 'utf-8',
158
+ })
159
+ .trim()
160
+ .split('\n')
161
+ .filter(Boolean);
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
167
+ /**
168
+ * 특정 커밋 시점의 파일 내용을 가져옵니다.
169
+ */
170
+ function getFileAtCommit(sha, filePath) {
171
+ try {
172
+ return execSync(`git show ${sha}:${filePath}`, {
173
+ cwd: projectPath,
174
+ encoding: 'utf-8',
175
+ maxBuffer: 1024 * 1024, // 1MB 제한
176
+ });
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Gemini AI로 regex 탐지 결과를 검증합니다 (오탐 필터링).
184
+ */
185
+ async function verifyWithGemini(findings) {
186
+ const apiKey = process.env.GEMINI_API_KEY;
187
+ if (!apiKey || apiKey === 'your_gemini_api_key_here') return findings;
188
+
189
+ try {
190
+ const { GoogleGenerativeAI } = await import('@google/generative-ai');
191
+ const genAI = new GoogleGenerativeAI(apiKey);
192
+ const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-lite' });
193
+
194
+ const prompt = `다음은 코드에서 regex로 발견된 잠재적 secret/credential 목록입니다.
195
+ 각 항목이 실제 secret인지, 아니면 예시값/플레이스홀더/테스트값인지 판단하세요.
196
+
197
+ 발견된 항목:
198
+ ${findings.map((f, i) => `${i + 1}. [${f.name}] 파일: ${f.file}:${f.line}\n 코드: ${f.lineContent}`).join('\n')}
199
+
200
+ JSON 배열로만 응답하세요 (다른 텍스트 없이):
201
+ [{"index": 1, "isReal": true/false}]`;
202
+
203
+ const result = await model.generateContent(prompt);
204
+ const text = result.response.text().trim();
205
+ const jsonMatch = text.match(/\[[\s\S]*?\]/);
206
+ if (!jsonMatch) return findings;
207
+
208
+ const verdicts = JSON.parse(jsonMatch[0]);
209
+ return findings.filter((_, i) => {
210
+ const v = verdicts.find((item) => item.index === i + 1);
211
+ return !v || v.isReal !== false;
212
+ });
213
+ } catch {
214
+ return findings; // AI 실패 시 보수적으로 전체 유지
215
+ }
216
+ }
217
+
218
+ /**
219
+ * 차단 경고 메시지를 stderr에 출력합니다.
220
+ */
221
+ function printBlockWarning(findings) {
222
+ const line = '═'.repeat(54);
223
+ process.stderr.write(`\n╔${line}╗\n`);
224
+ process.stderr.write(`║ ⛔ commit-ai-agent: SECRET DETECTED ║\n`);
225
+ process.stderr.write(`╚${line}╝\n\n`);
226
+ process.stderr.write(`push가 차단됐습니다. 아래 항목을 확인하세요:\n\n`);
227
+
228
+ findings.forEach((f, i) => {
229
+ const badge = f.severity === 'critical' ? '[CRITICAL]' : `[${f.severity.toUpperCase()}]`;
230
+ process.stderr.write(` ${i + 1}. 파일: ${f.file}:${f.line} ${badge}\n`);
231
+ process.stderr.write(` 유형: ${f.name}\n`);
232
+ process.stderr.write(` 값: ${f.masked}\n\n`);
233
+ });
234
+
235
+ process.stderr.write(`해결 방법:\n`);
236
+ process.stderr.write(` 1. 해당 파일에서 secret 제거\n`);
237
+ process.stderr.write(` 2. git commit --amend (마지막 커밋 수정)\n`);
238
+ process.stderr.write(` 3. 또는 git rebase -i 로 이전 커밋 수정\n`);
239
+ process.stderr.write(` ※ API 키가 이미 공개됐다면 즉시 무효화하세요!\n\n`);
240
+ process.stderr.write(`강제로 push하려면 (오탐인 경우):\n`);
241
+ process.stderr.write(` SKIP_SECRET_SCAN=1 git push\n\n`);
242
+ }
243
+
244
+ async function main() {
245
+ // stdin에서 push 대상 ref 목록 읽기
246
+ // 형식: <local-ref> SP <local-sha1> SP <remote-ref> SP <remote-sha1> LF
247
+ const rl = readline.createInterface({ input: process.stdin });
248
+ const refs = [];
249
+
250
+ await new Promise((resolve) => {
251
+ rl.on('line', (line) => {
252
+ const parts = line.trim().split(' ');
253
+ if (parts.length >= 4) {
254
+ refs.push({
255
+ localRef: parts[0],
256
+ localSha: parts[1],
257
+ remoteRef: parts[2],
258
+ remoteSha: parts[3],
259
+ });
260
+ }
261
+ });
262
+ rl.on('close', resolve);
263
+ });
264
+
265
+ if (refs.length === 0) process.exit(0);
266
+
267
+ const allFindings = [];
268
+
269
+ for (const { localSha, remoteSha } of refs) {
270
+ // 삭제 ref (push 없음)
271
+ if (localSha === '0000000000000000000000000000000000000000') continue;
272
+
273
+ const changedFiles = getChangedFiles(localSha, remoteSha);
274
+
275
+ for (const filePath of changedFiles) {
276
+ if (shouldSkipFile(filePath)) continue;
277
+
278
+ const content = getFileAtCommit(localSha, filePath);
279
+ if (!content) continue;
280
+
281
+ const findings = scanContent(content, filePath);
282
+ allFindings.push(...findings);
283
+ }
284
+ }
285
+
286
+ if (allFindings.length === 0) process.exit(0);
287
+
288
+ // AI 검증으로 오탐 필터링
289
+ const verified = await verifyWithGemini(allFindings);
290
+
291
+ if (verified.length === 0) process.exit(0);
292
+
293
+ printBlockWarning(verified);
294
+ process.exit(1); // push 차단
295
+ }
296
+
297
+ // 어떤 오류가 발생해도 push를 허용 (보안보다 개발 흐름 우선)
298
+ main().catch(() => process.exit(0));
package/src/server.js CHANGED
@@ -6,6 +6,7 @@ import fs from "fs";
6
6
  import { listGitProjects, getLatestCommit, getWorkingStatus } from "./git.js";
7
7
  import { analyzeCommit, analyzeWorkingStatus } from "./analyzer.js";
8
8
  import { resolveDevRoot } from "./config.js";
9
+ import { installHooks, removeHooks, getHookStatus } from "./hooks/installer.js";
9
10
 
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const app = express();
@@ -79,6 +80,68 @@ function resolveProjectPath(projectName) {
79
80
  return projectPath;
80
81
  }
81
82
 
83
+ // ──────────────────────────────────────────────
84
+ // SSE: 자동 분석 이벤트 브로드캐스트
85
+ // ──────────────────────────────────────────────
86
+ const sseClients = new Set();
87
+
88
+ // 최신 자동 분석 상태 (SSE 놓쳤을 때 폴링용)
89
+ let autoAnalysisState = { status: "idle", projectName: null, filename: null, content: null };
90
+
91
+ function broadcastEvent(data) {
92
+ const payload = `data: ${JSON.stringify(data)}\n\n`;
93
+ console.log(`[SSE] broadcast "${data.type}" → ${sseClients.size} client(s)`);
94
+ for (const client of sseClients) {
95
+ client.write(payload);
96
+ }
97
+ }
98
+
99
+ // ──────────────────────────────────────────────
100
+ // 백그라운드 분석 작업 큐 (post-commit 훅용)
101
+ // ──────────────────────────────────────────────
102
+ const analysisQueue = [];
103
+ let isProcessingQueue = false;
104
+
105
+ async function processNextQueueItem() {
106
+ if (isProcessingQueue || analysisQueue.length === 0) return;
107
+ isProcessingQueue = true;
108
+
109
+ const job = analysisQueue.shift();
110
+ autoAnalysisState = { status: "analyzing", projectName: job.projectName, filename: null, content: null };
111
+ broadcastEvent({ type: "analysis-started", projectName: job.projectName });
112
+
113
+ try {
114
+ const { projectPath, projectName } = job;
115
+ const commit = await getLatestCommit(projectPath);
116
+ const apiKey = process.env.GEMINI_API_KEY;
117
+ if (apiKey && apiKey !== "your_gemini_api_key_here") {
118
+ const analysis = await analyzeCommit(commit, projectName, apiKey);
119
+ const timestamp = new Date()
120
+ .toISOString()
121
+ .replace(/[:.]/g, "-")
122
+ .slice(0, 19);
123
+ const reportFilename = `${projectName}-${timestamp}.md`;
124
+ const fullReport = `# 커밋 분석 리포트 (자동): ${projectName}\n\n> 생성 시각: ${new Date().toLocaleString("ko-KR")}\n> post-commit 훅에 의해 자동 생성됨\n\n## 커밋 정보\n| 항목 | 내용 |\n|---|---|\n| 해시 | \`${commit.shortHash}\` |\n| 메시지 | ${commit.message} |\n| 작성자 | ${commit.author} |\n| 날짜 | ${commit.date} |\n\n---\n\n${analysis}\n`;
125
+ fs.writeFileSync(path.join(REPORTS_DIR, reportFilename), fullReport, "utf-8");
126
+ autoAnalysisState = { status: "done", projectName, filename: reportFilename, content: fullReport };
127
+ broadcastEvent({ type: "analysis-done", projectName, filename: reportFilename, content: fullReport });
128
+ console.log(`[auto] 분석 완료: ${projectName} (${commit.shortHash})`);
129
+ } else {
130
+ autoAnalysisState = { status: "idle", projectName: null, filename: null, content: null };
131
+ broadcastEvent({ type: "analysis-error", message: "GEMINI_API_KEY가 설정되지 않았습니다." });
132
+ }
133
+ } catch (err) {
134
+ autoAnalysisState = { status: "idle", projectName: null, filename: null, content: null };
135
+ broadcastEvent({ type: "analysis-error", message: err.message });
136
+ console.error(`[auto] 분석 실패: ${err.message}`);
137
+ } finally {
138
+ isProcessingQueue = false;
139
+ if (analysisQueue.length > 0) {
140
+ setTimeout(processNextQueueItem, 1000);
141
+ }
142
+ }
143
+ }
144
+
82
145
  // ──────────────────────────────────────────────
83
146
  // PWA 아이콘 (SVG를 PNG MIME으로 서빙)
84
147
  // ──────────────────────────────────────────────
@@ -92,6 +155,124 @@ app.get("/favicon.ico", (req, res) => {
92
155
  res.sendFile(path.join(__dirname, "..", "public", "icon.svg"));
93
156
  });
94
157
 
158
+ // ──────────────────────────────────────────────
159
+ // API: SSE — 자동 분석 이벤트 스트림
160
+ // ──────────────────────────────────────────────
161
+ app.get("/api/events", (req, res) => {
162
+ res.setHeader("Content-Type", "text/event-stream");
163
+ res.setHeader("Cache-Control", "no-cache");
164
+ res.setHeader("Connection", "keep-alive");
165
+ res.flushHeaders();
166
+
167
+ sseClients.add(res);
168
+ console.log(`[SSE] client connected (total: ${sseClients.size})`);
169
+
170
+ // 연결 직후 현재 상태 전달 (페이지 로드 타이밍 놓침 방지)
171
+ if (autoAnalysisState.status !== "idle") {
172
+ const eventType = autoAnalysisState.status === "analyzing" ? "analysis-started" : "analysis-done";
173
+ res.write(`data: ${JSON.stringify({ type: eventType, ...autoAnalysisState })}\n\n`);
174
+ }
175
+
176
+ // 연결 유지용 하트비트 (30초마다)
177
+ const heartbeat = setInterval(() => {
178
+ res.write(": ping\n\n");
179
+ }, 30000);
180
+
181
+ req.on("close", () => {
182
+ clearInterval(heartbeat);
183
+ sseClients.delete(res);
184
+ console.log(`[SSE] client disconnected (total: ${sseClients.size})`);
185
+ });
186
+ });
187
+
188
+ // ──────────────────────────────────────────────
189
+ // API: 자동 분석 현재 상태 (SSE 폴백용)
190
+ // ──────────────────────────────────────────────
191
+ app.get("/api/auto-analysis/state", (req, res) => {
192
+ res.json(autoAnalysisState);
193
+ });
194
+
195
+ // ──────────────────────────────────────────────
196
+ // API: Health Check (훅 스크립트가 서버 실행 여부 확인용)
197
+ // ──────────────────────────────────────────────
198
+ app.get("/api/health", (req, res) => {
199
+ res.json({ status: "ok", version: "1.0.0" });
200
+ });
201
+
202
+ // ──────────────────────────────────────────────
203
+ // API: 훅 관리
204
+ // ──────────────────────────────────────────────
205
+
206
+ /** 훅 상태 조회 (현재 프로젝트만) */
207
+ app.get("/api/hooks/status", async (req, res) => {
208
+ try {
209
+ const isSelf = fs.existsSync(path.join(DEV_ROOT, ".git"));
210
+ if (!isSelf) {
211
+ return res.json({ projects: [] });
212
+ }
213
+ const status = await getHookStatus(DEV_ROOT);
214
+ res.json({ projects: [{ name: "__self__", displayName: path.basename(DEV_ROOT), ...status }] });
215
+ } catch (err) {
216
+ res.status(500).json({ error: err.message });
217
+ }
218
+ });
219
+
220
+ /** 훅 설치 */
221
+ app.post("/api/hooks/install", async (req, res) => {
222
+ const { projectName } = req.body;
223
+ try {
224
+ const projectPath = resolveProjectPath(projectName);
225
+ const installed = await installHooks(projectPath);
226
+ res.json({ success: true, installed });
227
+ } catch (err) {
228
+ if (isBadRequestError(err)) {
229
+ return res.status(400).json({ error: err.message.replace(`${BAD_REQUEST_PREFIX} `, "") });
230
+ }
231
+ res.status(500).json({ error: err.message });
232
+ }
233
+ });
234
+
235
+ /** 훅 제거 */
236
+ app.post("/api/hooks/remove", async (req, res) => {
237
+ const { projectName } = req.body;
238
+ try {
239
+ const projectPath = resolveProjectPath(projectName);
240
+ const removed = await removeHooks(projectPath);
241
+ res.json({ success: true, removed });
242
+ } catch (err) {
243
+ if (isBadRequestError(err)) {
244
+ return res.status(400).json({ error: err.message.replace(`${BAD_REQUEST_PREFIX} `, "") });
245
+ }
246
+ res.status(500).json({ error: err.message });
247
+ }
248
+ });
249
+
250
+ /** post-commit 훅에서 호출: 백그라운드 분석 큐에 추가 */
251
+ app.post("/api/hooks/post-commit-notify", (req, res) => {
252
+ const { projectPath } = req.body;
253
+ if (!projectPath || typeof projectPath !== "string") {
254
+ return res.status(400).json({ error: "projectPath가 필요합니다." });
255
+ }
256
+
257
+ // DEV_ROOT 내부인지 검증
258
+ const relative = path.relative(DEV_ROOT, path.resolve(projectPath));
259
+ const isOutside = relative.startsWith("..") || path.isAbsolute(relative);
260
+
261
+ // single-project 모드 (DEV_ROOT 자체가 git repo)
262
+ const isSelf = path.resolve(projectPath) === path.resolve(DEV_ROOT);
263
+
264
+ if (isOutside && !isSelf) {
265
+ return res.status(400).json({ error: "유효하지 않은 projectPath입니다." });
266
+ }
267
+
268
+ const projectName = isSelf ? path.basename(DEV_ROOT) : path.basename(projectPath);
269
+ analysisQueue.push({ projectPath: path.resolve(projectPath), projectName });
270
+
271
+ // 즉시 응답 후 백그라운드에서 처리
272
+ res.json({ queued: true, jobId: Date.now() });
273
+ setTimeout(processNextQueueItem, 100);
274
+ });
275
+
95
276
  // ──────────────────────────────────────────────
96
277
  // API: 설정 확인
97
278
 
@@ -382,4 +563,5 @@ app.listen(PORT, () => {
382
563
  console.log(` 브라우저: http://localhost:${PORT}`);
383
564
  console.log(` 분석 대상: ${DEV_ROOT}`);
384
565
  console.log(` DEV_ROOT source: ${DEV_ROOT_SOURCE}\n`);
566
+
385
567
  });