@yr-kits/dev-copilot 0.1.0 → 0.1.2
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/dist/app/index.d.mts +2 -0
- package/dist/app/index.mjs +4 -0
- package/dist/bin/bridge.mjs +4 -881
- package/dist/bin/bridge.mjs.map +1 -1
- package/dist/bin/dev-copilot.mjs +13 -3
- package/dist/bin/dev-copilot.mjs.map +1 -1
- package/dist/bridge/app/index.d.mts +24 -0
- package/dist/bridge/app/index.d.mts.map +1 -0
- package/dist/bridge/app/index.mjs +3 -0
- package/dist/{types/index.d.mts → copilot-D8--qKgC.d.mts} +3 -3
- package/dist/copilot-D8--qKgC.d.mts.map +1 -0
- package/dist/copilot-overlay-ClRoIHew.mjs +1129 -0
- package/dist/copilot-overlay-ClRoIHew.mjs.map +1 -0
- package/dist/copilot-overlay-NlUgrMjy.d.mts +7 -0
- package/dist/copilot-overlay-NlUgrMjy.d.mts.map +1 -0
- package/dist/dev-copilot-context-CA9bqV_U.mjs +35 -0
- package/dist/dev-copilot-context-CA9bqV_U.mjs.map +1 -0
- package/dist/dev-copilot-provider-CP_gJvWG.d.mts +22 -0
- package/dist/dev-copilot-provider-CP_gJvWG.d.mts.map +1 -0
- package/dist/dev-copilot-provider-D0_wEEem.mjs +14 -0
- package/dist/dev-copilot-provider-D0_wEEem.mjs.map +1 -0
- package/dist/index.d.mts +5 -33
- package/dist/index.mjs +6 -849
- package/dist/run-bridge-cli-DVLGcVgq.mjs +1510 -0
- package/dist/run-bridge-cli-DVLGcVgq.mjs.map +1 -0
- package/dist/types.d.mts +2 -0
- package/dist/widgets/copilot-overlay/index.d.mts +2 -0
- package/dist/widgets/copilot-overlay/index.mjs +4 -0
- package/package.json +29 -9
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/dist/types/index.d.mts.map +0 -1
- /package/dist/{types/index.mjs → types.mjs} +0 -0
package/dist/bin/bridge.mjs
CHANGED
|
@@ -1,889 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { execFile } from "node:child_process";
|
|
5
|
-
import { createServer } from "node:http";
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
|
-
import os from "node:os";
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { t as runDevCopilotBridgeCli } from "../run-bridge-cli-DVLGcVgq.mjs";
|
|
9
3
|
|
|
10
|
-
//#region src/bridge/lib/config.ts
|
|
11
|
-
const DEFAULT_ALLOWED_PATHS = [
|
|
12
|
-
"app",
|
|
13
|
-
"src",
|
|
14
|
-
"widgets",
|
|
15
|
-
"features",
|
|
16
|
-
"entities",
|
|
17
|
-
"shared",
|
|
18
|
-
"components"
|
|
19
|
-
];
|
|
20
|
-
const createDevCopilotBridgeConfig = (config) => {
|
|
21
|
-
return {
|
|
22
|
-
rootDir: config.rootDir,
|
|
23
|
-
host: config.host ?? "127.0.0.1",
|
|
24
|
-
port: config.port ?? 3339,
|
|
25
|
-
corsOrigin: config.corsOrigin ?? "*",
|
|
26
|
-
agent: config.agent ?? "codex",
|
|
27
|
-
allowedPaths: config.allowedPaths ?? DEFAULT_ALLOWED_PATHS
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
//#endregion
|
|
32
|
-
//#region src/bridge/lib/guards.ts
|
|
33
|
-
const resolveAndValidatePath = (filePath, allowedPaths, rootDir) => {
|
|
34
|
-
if (!filePath) throw new Error("허용되지 않은 파일 경로입니다.");
|
|
35
|
-
let normalizedInput = filePath.replaceAll("\\", "/");
|
|
36
|
-
if (rootDir) {
|
|
37
|
-
const normalizedRootDir = rootDir.replaceAll("\\", "/").replace(/\/+$/, "");
|
|
38
|
-
if (normalizedInput.startsWith(`${normalizedRootDir}/`)) normalizedInput = normalizedInput.slice(normalizedRootDir.length + 1);
|
|
39
|
-
}
|
|
40
|
-
if (path.isAbsolute(normalizedInput) || normalizedInput.includes("..")) throw new Error("허용되지 않은 파일 경로입니다.");
|
|
41
|
-
const normalized = normalizedInput;
|
|
42
|
-
const firstSegment = normalized.split("/")[0];
|
|
43
|
-
if (!(allowedPaths.includes(".") || allowedPaths.includes("*")) && !allowedPaths.includes(firstSegment)) throw new Error(`허용 경로 외 파일은 수정할 수 없습니다: ${firstSegment}`);
|
|
44
|
-
return normalized;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
//#endregion
|
|
48
|
-
//#region src/bridge/internal/prompts.ts
|
|
49
|
-
const buildAgentPrompt = (request) => {
|
|
50
|
-
if (request.mode === "answer") return [
|
|
51
|
-
"Answer the user's web overlay request.",
|
|
52
|
-
"Do not modify files.",
|
|
53
|
-
"Use the provided project context when it is relevant.",
|
|
54
|
-
"Respond in Korean.",
|
|
55
|
-
"Keep the answer concise and actionable.",
|
|
56
|
-
"",
|
|
57
|
-
`Current route: ${request.route ?? "unknown"}`,
|
|
58
|
-
`Selected text:\n${request.selectedText || "(none)"}`,
|
|
59
|
-
`User prompt:\n${request.prompt}`,
|
|
60
|
-
`Previous AI response:\n${request.previousResponse ?? "(none)"}`,
|
|
61
|
-
`Project context:\n${request.projectContext ?? "(none)"}`
|
|
62
|
-
].join("\n");
|
|
63
|
-
return [
|
|
64
|
-
"You are a local code-edit proposal generator called from a web overlay.",
|
|
65
|
-
"Do not modify files directly.",
|
|
66
|
-
"Return JSON only.",
|
|
67
|
-
"Do not generate unified diff directly.",
|
|
68
|
-
"Put repository-relative file path(path), exact old text(oldText), and replacement text(newText) into the changes array.",
|
|
69
|
-
"Never use absolute paths. The path must be relative to the repository root, for example src/features/article/model/data.ts.",
|
|
70
|
-
"oldText must exactly match the file content and must not use ellipsis.",
|
|
71
|
-
"Ground the proposal in the selected text, user prompt, and provided project context.",
|
|
72
|
-
"Write the message field in Korean.",
|
|
73
|
-
"",
|
|
74
|
-
`Current route: ${request.route ?? "unknown"}`,
|
|
75
|
-
`Allowed path hints: ${(request.fileHints ?? []).join(", ") || "(none)"}`,
|
|
76
|
-
`Selected text:\n${request.selectedText || "(none)"}`,
|
|
77
|
-
`User prompt:\n${request.prompt}`,
|
|
78
|
-
`Previous AI response:\n${request.previousResponse ?? "(none)"}`,
|
|
79
|
-
`Project context:\n${request.projectContext ?? "(none)"}`
|
|
80
|
-
].join("\n");
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
//#endregion
|
|
84
|
-
//#region src/bridge/internal/agents/claude-adapter.ts
|
|
85
|
-
const execFileAsync$3 = promisify(execFile);
|
|
86
|
-
const toClaudeErrorMessage = (error) => {
|
|
87
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
88
|
-
if (/401|authentication|invalid authentication credentials|please run \/login/i.test(message)) return "Claude Code 로그인이 필요합니다. 터미널에서 `claude /login`을 실행해 주세요.";
|
|
89
|
-
if (/ENOENT/.test(message)) return "Claude CLI를 찾을 수 없습니다. Claude Code CLI 설치 상태를 확인해 주세요.";
|
|
90
|
-
return message;
|
|
91
|
-
};
|
|
92
|
-
const findFirstString = (value) => {
|
|
93
|
-
if (typeof value === "string") {
|
|
94
|
-
const text = value.trim();
|
|
95
|
-
return text ? text : null;
|
|
96
|
-
}
|
|
97
|
-
if (Array.isArray(value)) {
|
|
98
|
-
for (const item of value) {
|
|
99
|
-
const found = findFirstString(item);
|
|
100
|
-
if (found) return found;
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
if (value && typeof value === "object") {
|
|
105
|
-
const record = value;
|
|
106
|
-
for (const key of [
|
|
107
|
-
"result",
|
|
108
|
-
"message",
|
|
109
|
-
"output",
|
|
110
|
-
"text",
|
|
111
|
-
"content"
|
|
112
|
-
]) if (key in record) {
|
|
113
|
-
const found = findFirstString(record[key]);
|
|
114
|
-
if (found) return found;
|
|
115
|
-
}
|
|
116
|
-
for (const nestedValue of Object.values(record)) {
|
|
117
|
-
const found = findFirstString(nestedValue);
|
|
118
|
-
if (found) return found;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
};
|
|
123
|
-
const parseClaudeJsonOutput = (raw) => {
|
|
124
|
-
const parsed = JSON.parse(raw);
|
|
125
|
-
return {
|
|
126
|
-
parsed,
|
|
127
|
-
text: findFirstString(parsed) ?? ""
|
|
128
|
-
};
|
|
129
|
-
};
|
|
130
|
-
const parseClaudeEditResponse = (raw) => {
|
|
131
|
-
const { text } = parseClaudeJsonOutput(raw);
|
|
132
|
-
const parsed = JSON.parse(text);
|
|
133
|
-
return {
|
|
134
|
-
message: parsed.message,
|
|
135
|
-
patchPreview: parsed.patchPreview,
|
|
136
|
-
changes: parsed.changes,
|
|
137
|
-
warnings: parsed.warnings ?? []
|
|
138
|
-
};
|
|
139
|
-
};
|
|
140
|
-
const claudeAdapter = {
|
|
141
|
-
agent: "claude",
|
|
142
|
-
async run(request) {
|
|
143
|
-
const prompt = buildAgentPrompt(request);
|
|
144
|
-
const args = [
|
|
145
|
-
"-p",
|
|
146
|
-
"--output-format",
|
|
147
|
-
"json",
|
|
148
|
-
...request.mode === "edit" ? ["--json-schema", "{\"type\":\"object\",\"properties\":{\"message\":{\"type\":\"string\"},\"patchPreview\":{\"type\":\"string\"},\"warnings\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"changes\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"oldText\":{\"type\":\"string\"},\"newText\":{\"type\":\"string\"}},\"required\":[\"path\",\"oldText\",\"newText\"],\"additionalProperties\":false}}},\"required\":[\"message\"],\"additionalProperties\":false}"] : [],
|
|
149
|
-
prompt
|
|
150
|
-
];
|
|
151
|
-
try {
|
|
152
|
-
const { stdout } = await execFileAsync$3("claude", args, {
|
|
153
|
-
cwd: request.cwd,
|
|
154
|
-
maxBuffer: 1024 * 1024 * 5,
|
|
155
|
-
timeout: Number(process.env.DEV_COPILOT_AGENT_TIMEOUT_MS ?? 12e4),
|
|
156
|
-
env: {
|
|
157
|
-
...process.env,
|
|
158
|
-
NO_COLOR: "1"
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
if (request.mode === "answer") {
|
|
162
|
-
const { text } = parseClaudeJsonOutput(stdout);
|
|
163
|
-
return {
|
|
164
|
-
message: text,
|
|
165
|
-
warnings: []
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
return parseClaudeEditResponse(stdout);
|
|
169
|
-
} catch (error) {
|
|
170
|
-
throw new Error(toClaudeErrorMessage(error));
|
|
171
|
-
}
|
|
172
|
-
},
|
|
173
|
-
async getStatus(cwd) {
|
|
174
|
-
try {
|
|
175
|
-
await execFileAsync$3("claude", [
|
|
176
|
-
"-p",
|
|
177
|
-
"--output-format",
|
|
178
|
-
"json",
|
|
179
|
-
"OK만 출력해줘."
|
|
180
|
-
], {
|
|
181
|
-
cwd,
|
|
182
|
-
timeout: 15e3,
|
|
183
|
-
maxBuffer: 1024 * 512
|
|
184
|
-
});
|
|
185
|
-
return {
|
|
186
|
-
available: true,
|
|
187
|
-
authenticated: true,
|
|
188
|
-
agent: "claude",
|
|
189
|
-
message: "Claude Code CLI에 로그인되어 있습니다."
|
|
190
|
-
};
|
|
191
|
-
} catch (error) {
|
|
192
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
193
|
-
const unavailable = /ENOENT/.test(message);
|
|
194
|
-
const authError = /401|authentication|please run \/login|invalid authentication credentials/i.test(message);
|
|
195
|
-
return {
|
|
196
|
-
available: !unavailable,
|
|
197
|
-
authenticated: false,
|
|
198
|
-
agent: "claude",
|
|
199
|
-
message: unavailable ? "Claude CLI를 찾을 수 없습니다." : authError ? "Claude Code 로그인이 필요합니다." : message,
|
|
200
|
-
loginCommand: unavailable ? void 0 : "claude /login"
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
//#endregion
|
|
207
|
-
//#region src/bridge/internal/agents/codex-adapter.ts
|
|
208
|
-
const execFileAsync$2 = promisify(execFile);
|
|
209
|
-
const codexDiffResponseSchema = {
|
|
210
|
-
type: "object",
|
|
211
|
-
properties: {
|
|
212
|
-
message: { type: "string" },
|
|
213
|
-
patchPreview: { type: "string" },
|
|
214
|
-
warnings: {
|
|
215
|
-
type: "array",
|
|
216
|
-
items: { type: "string" }
|
|
217
|
-
},
|
|
218
|
-
changes: {
|
|
219
|
-
type: "array",
|
|
220
|
-
items: {
|
|
221
|
-
type: "object",
|
|
222
|
-
properties: {
|
|
223
|
-
path: { type: "string" },
|
|
224
|
-
oldText: { type: "string" },
|
|
225
|
-
newText: { type: "string" }
|
|
226
|
-
},
|
|
227
|
-
required: [
|
|
228
|
-
"path",
|
|
229
|
-
"oldText",
|
|
230
|
-
"newText"
|
|
231
|
-
],
|
|
232
|
-
additionalProperties: false
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
required: ["message"],
|
|
237
|
-
additionalProperties: false
|
|
238
|
-
};
|
|
239
|
-
const parseAnswerResponse = (rawOutput) => {
|
|
240
|
-
return {
|
|
241
|
-
message: rawOutput.trim(),
|
|
242
|
-
warnings: []
|
|
243
|
-
};
|
|
244
|
-
};
|
|
245
|
-
const parseEditResponse = async (outputPath) => {
|
|
246
|
-
const content = await promises.readFile(outputPath, "utf-8");
|
|
247
|
-
const parsed = JSON.parse(content);
|
|
248
|
-
return {
|
|
249
|
-
message: parsed.message,
|
|
250
|
-
patchPreview: parsed.patchPreview,
|
|
251
|
-
changes: parsed.changes,
|
|
252
|
-
warnings: parsed.warnings ?? []
|
|
253
|
-
};
|
|
254
|
-
};
|
|
255
|
-
const toCodexErrorMessage = (error) => {
|
|
256
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
257
|
-
if (/not logged in|login required|authentication|unauthorized/i.test(message)) return "Codex CLI 로그인이 필요합니다. 터미널에서 `codex login`을 실행해 주세요.";
|
|
258
|
-
if (/ENOENT/.test(message)) return "Codex CLI를 찾을 수 없습니다. Codex CLI 설치 상태를 확인해 주세요.";
|
|
259
|
-
return message;
|
|
260
|
-
};
|
|
261
|
-
const getCodexModelName = async (cwd) => {
|
|
262
|
-
const outputPath = path.join(os.tmpdir(), `dev-copilot-codex-status-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
|
263
|
-
try {
|
|
264
|
-
const { stdout, stderr } = await execFileAsync$2("codex", [
|
|
265
|
-
"exec",
|
|
266
|
-
"--cd",
|
|
267
|
-
cwd,
|
|
268
|
-
"--sandbox",
|
|
269
|
-
"read-only",
|
|
270
|
-
"--skip-git-repo-check",
|
|
271
|
-
"--output-last-message",
|
|
272
|
-
outputPath,
|
|
273
|
-
"OK만 출력해줘."
|
|
274
|
-
], {
|
|
275
|
-
cwd,
|
|
276
|
-
timeout: 3e4,
|
|
277
|
-
maxBuffer: 1024 * 512,
|
|
278
|
-
env: {
|
|
279
|
-
...process.env,
|
|
280
|
-
NO_COLOR: "1"
|
|
281
|
-
}
|
|
282
|
-
});
|
|
283
|
-
return `${stdout}\n${stderr}`.match(/^model:\s*(.+)$/m)?.[1]?.trim();
|
|
284
|
-
} catch {
|
|
285
|
-
return;
|
|
286
|
-
} finally {
|
|
287
|
-
await promises.rm(outputPath, { force: true });
|
|
288
|
-
}
|
|
289
|
-
};
|
|
290
|
-
const codexAdapter = {
|
|
291
|
-
agent: "codex",
|
|
292
|
-
async run(request) {
|
|
293
|
-
const outputPath = path.join(os.tmpdir(), `dev-copilot-codex-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
|
|
294
|
-
const schemaPath = path.join(os.tmpdir(), `dev-copilot-codex-schema-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
|
|
295
|
-
await promises.writeFile(schemaPath, JSON.stringify(codexDiffResponseSchema), "utf-8");
|
|
296
|
-
const prompt = buildAgentPrompt(request);
|
|
297
|
-
const args = [
|
|
298
|
-
"exec",
|
|
299
|
-
"--cd",
|
|
300
|
-
request.cwd,
|
|
301
|
-
"--sandbox",
|
|
302
|
-
"read-only",
|
|
303
|
-
"--skip-git-repo-check",
|
|
304
|
-
"--output-last-message",
|
|
305
|
-
outputPath,
|
|
306
|
-
...request.mode === "edit" ? ["--output-schema", schemaPath] : [],
|
|
307
|
-
prompt
|
|
308
|
-
];
|
|
309
|
-
try {
|
|
310
|
-
const { stdout } = await execFileAsync$2("codex", args, {
|
|
311
|
-
cwd: request.cwd,
|
|
312
|
-
maxBuffer: 1024 * 1024 * 5,
|
|
313
|
-
timeout: Number(process.env.DEV_COPILOT_AGENT_TIMEOUT_MS ?? 12e4),
|
|
314
|
-
env: {
|
|
315
|
-
...process.env,
|
|
316
|
-
NO_COLOR: "1"
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
if (request.mode === "answer") try {
|
|
320
|
-
return parseAnswerResponse(await promises.readFile(outputPath, "utf-8"));
|
|
321
|
-
} catch {
|
|
322
|
-
return parseAnswerResponse(stdout);
|
|
323
|
-
}
|
|
324
|
-
return await parseEditResponse(outputPath);
|
|
325
|
-
} catch (error) {
|
|
326
|
-
throw new Error(toCodexErrorMessage(error));
|
|
327
|
-
} finally {
|
|
328
|
-
await promises.rm(outputPath, { force: true });
|
|
329
|
-
await promises.rm(schemaPath, { force: true });
|
|
330
|
-
}
|
|
331
|
-
},
|
|
332
|
-
async getStatus(cwd) {
|
|
333
|
-
try {
|
|
334
|
-
const { stdout, stderr } = await execFileAsync$2("codex", ["login", "status"], {
|
|
335
|
-
cwd,
|
|
336
|
-
timeout: 1e4,
|
|
337
|
-
maxBuffer: 1024 * 128
|
|
338
|
-
});
|
|
339
|
-
const output = `${stdout}\n${stderr}`.trim();
|
|
340
|
-
const authenticated = /logged in/i.test(output);
|
|
341
|
-
const model = authenticated ? await getCodexModelName(cwd) : void 0;
|
|
342
|
-
return {
|
|
343
|
-
available: true,
|
|
344
|
-
authenticated,
|
|
345
|
-
agent: "codex",
|
|
346
|
-
message: authenticated ? output || "Codex CLI에 로그인되어 있습니다." : output || "Codex CLI 로그인이 필요합니다.",
|
|
347
|
-
model,
|
|
348
|
-
loginCommand: authenticated ? void 0 : "codex login"
|
|
349
|
-
};
|
|
350
|
-
} catch (error) {
|
|
351
|
-
const message = error instanceof Error ? error.message : "Codex CLI 상태 확인에 실패했습니다.";
|
|
352
|
-
return {
|
|
353
|
-
available: !message.includes("ENOENT"),
|
|
354
|
-
authenticated: false,
|
|
355
|
-
agent: "codex",
|
|
356
|
-
message: message.includes("ENOENT") ? "Codex CLI를 찾을 수 없습니다." : message,
|
|
357
|
-
loginCommand: message.includes("ENOENT") ? void 0 : "codex login"
|
|
358
|
-
};
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
//#endregion
|
|
364
|
-
//#region src/bridge/internal/agents/index.ts
|
|
365
|
-
const adapters = {
|
|
366
|
-
codex: codexAdapter,
|
|
367
|
-
claude: claudeAdapter
|
|
368
|
-
};
|
|
369
|
-
const resolveAgentAdapter = (agent) => {
|
|
370
|
-
return adapters[agent];
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
//#endregion
|
|
374
|
-
//#region src/bridge/internal/project-context.ts
|
|
375
|
-
const execFileAsync$1 = promisify(execFile);
|
|
376
|
-
const normalizeRelativePath = (filePath) => {
|
|
377
|
-
return filePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
|
|
378
|
-
};
|
|
379
|
-
const isIgnoredPath = (relativePath, config) => {
|
|
380
|
-
return normalizeRelativePath(relativePath).split("/").some((segment) => config.ignoredDirs.includes(segment));
|
|
381
|
-
};
|
|
382
|
-
const walk = async (relativeDir, config, results, limit) => {
|
|
383
|
-
if (results.length >= limit || isIgnoredPath(relativeDir, config)) return;
|
|
384
|
-
const absoluteDir = path.join(config.rootDir, relativeDir);
|
|
385
|
-
const entries = await promises.readdir(absoluteDir, { withFileTypes: true });
|
|
386
|
-
for (const entry of entries) {
|
|
387
|
-
if (results.length >= limit) return;
|
|
388
|
-
const relativePath = normalizeRelativePath(path.join(relativeDir, entry.name));
|
|
389
|
-
if (isIgnoredPath(relativePath, config)) continue;
|
|
390
|
-
if (entry.isDirectory()) {
|
|
391
|
-
await walk(relativePath, config, results, limit);
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
if (entry.isFile()) results.push(relativePath);
|
|
395
|
-
}
|
|
396
|
-
};
|
|
397
|
-
const listProjectFiles = async (config, query, limit = 80) => {
|
|
398
|
-
const results = [];
|
|
399
|
-
for (const allowedDir of config.allowedDirs) {
|
|
400
|
-
const absoluteDir = path.join(config.rootDir, allowedDir);
|
|
401
|
-
try {
|
|
402
|
-
if ((await promises.stat(absoluteDir)).isDirectory()) await walk(allowedDir, config, results, limit);
|
|
403
|
-
} catch {
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
if (!query) return results;
|
|
408
|
-
return results.filter((filePath) => filePath.toLowerCase().includes(query.toLowerCase()));
|
|
409
|
-
};
|
|
410
|
-
const readProjectFile = async (config, filePath, maxBytes = config.maxReadBytes) => {
|
|
411
|
-
const absolutePath = path.join(config.rootDir, normalizeRelativePath(filePath));
|
|
412
|
-
return {
|
|
413
|
-
path: filePath,
|
|
414
|
-
content: (await promises.readFile(absolutePath, "utf-8")).slice(0, maxBytes)
|
|
415
|
-
};
|
|
416
|
-
};
|
|
417
|
-
const searchWithRg = async (config, query, limit) => {
|
|
418
|
-
const { stdout } = await execFileAsync$1("rg", [
|
|
419
|
-
"--line-number",
|
|
420
|
-
"--fixed-strings",
|
|
421
|
-
"--color",
|
|
422
|
-
"never",
|
|
423
|
-
"--glob",
|
|
424
|
-
"!node_modules/**",
|
|
425
|
-
"--glob",
|
|
426
|
-
"!.next/**",
|
|
427
|
-
"--glob",
|
|
428
|
-
"!.git/**",
|
|
429
|
-
query,
|
|
430
|
-
...config.allowedDirs
|
|
431
|
-
], {
|
|
432
|
-
cwd: config.rootDir,
|
|
433
|
-
maxBuffer: 1024 * 1024
|
|
434
|
-
});
|
|
435
|
-
return stdout.split("\n").filter(Boolean).slice(0, limit).map((line) => {
|
|
436
|
-
const [filePath, lineNumber, ...rest] = line.split(":");
|
|
437
|
-
return {
|
|
438
|
-
path: filePath,
|
|
439
|
-
line: Number(lineNumber),
|
|
440
|
-
text: rest.join(":").trim()
|
|
441
|
-
};
|
|
442
|
-
});
|
|
443
|
-
};
|
|
444
|
-
const searchWithNode = async (config, query, limit) => {
|
|
445
|
-
const files = await listProjectFiles(config, void 0, 500);
|
|
446
|
-
const results = [];
|
|
447
|
-
for (const filePath of files) {
|
|
448
|
-
if (results.length >= limit) break;
|
|
449
|
-
try {
|
|
450
|
-
(await readProjectFile(config, filePath, 1e5)).content.split("\n").forEach((line, index) => {
|
|
451
|
-
if (results.length < limit && line.includes(query)) results.push({
|
|
452
|
-
path: filePath,
|
|
453
|
-
line: index + 1,
|
|
454
|
-
text: line.trim()
|
|
455
|
-
});
|
|
456
|
-
});
|
|
457
|
-
} catch {
|
|
458
|
-
continue;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
return results;
|
|
462
|
-
};
|
|
463
|
-
const searchProjectText = async (config, query, limit = config.maxSearchResults) => {
|
|
464
|
-
if (!query.trim()) return [];
|
|
465
|
-
try {
|
|
466
|
-
return await searchWithRg(config, query, limit);
|
|
467
|
-
} catch {
|
|
468
|
-
return searchWithNode(config, query, limit);
|
|
469
|
-
}
|
|
470
|
-
};
|
|
471
|
-
const findComponentByText = async (config, text, limit = 8) => {
|
|
472
|
-
const normalized = text.replace(/\s+/g, " ").trim();
|
|
473
|
-
const candidates = [
|
|
474
|
-
normalized,
|
|
475
|
-
normalized.slice(0, 120),
|
|
476
|
-
...normalized.split(/[.!?]\s+/).filter((item) => item.length > 12)
|
|
477
|
-
];
|
|
478
|
-
for (const candidate of candidates) {
|
|
479
|
-
const results = await searchProjectText(config, candidate, limit);
|
|
480
|
-
if (results.length) return {
|
|
481
|
-
query: candidate,
|
|
482
|
-
results
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
return {
|
|
486
|
-
query: normalized,
|
|
487
|
-
results: []
|
|
488
|
-
};
|
|
489
|
-
};
|
|
490
|
-
const readJsonFile = async (rootDir, filePath) => {
|
|
491
|
-
try {
|
|
492
|
-
const content = await promises.readFile(path.join(rootDir, filePath), "utf-8");
|
|
493
|
-
return JSON.parse(content);
|
|
494
|
-
} catch {
|
|
495
|
-
return null;
|
|
496
|
-
}
|
|
497
|
-
};
|
|
498
|
-
const getProjectContext = async (config) => {
|
|
499
|
-
const packageJson = await readJsonFile(config.rootDir, "package.json");
|
|
500
|
-
const tsconfig = await readJsonFile(config.rootDir, "tsconfig.json");
|
|
501
|
-
return {
|
|
502
|
-
rootDir: config.rootDir,
|
|
503
|
-
allowedDirs: config.allowedDirs,
|
|
504
|
-
packageName: packageJson?.name,
|
|
505
|
-
scripts: packageJson?.scripts,
|
|
506
|
-
dependencies: packageJson?.dependencies,
|
|
507
|
-
devDependencies: packageJson?.devDependencies,
|
|
508
|
-
tsconfigPaths: (tsconfig?.compilerOptions)?.paths
|
|
509
|
-
};
|
|
510
|
-
};
|
|
511
|
-
const buildCopilotProjectContext = async (rootDir, allowedDirs, params) => {
|
|
512
|
-
const config = {
|
|
513
|
-
rootDir,
|
|
514
|
-
allowedDirs,
|
|
515
|
-
ignoredDirs: [
|
|
516
|
-
".git",
|
|
517
|
-
".next",
|
|
518
|
-
"node_modules",
|
|
519
|
-
"coverage",
|
|
520
|
-
"public",
|
|
521
|
-
"dist",
|
|
522
|
-
"build"
|
|
523
|
-
],
|
|
524
|
-
maxReadBytes: 3e4,
|
|
525
|
-
maxSearchResults: 20
|
|
526
|
-
};
|
|
527
|
-
const project = await getProjectContext(config);
|
|
528
|
-
const selectedText = params.selectedText?.trim();
|
|
529
|
-
const routeQuery = params.route?.startsWith("/") ? `app${params.route === "/" ? "" : params.route}/page.tsx` : params.route;
|
|
530
|
-
const textMatches = selectedText ? await findComponentByText(config, selectedText, 8) : {
|
|
531
|
-
query: "",
|
|
532
|
-
results: []
|
|
533
|
-
};
|
|
534
|
-
const routeMatches = routeQuery ? await searchProjectText(config, routeQuery, 5) : [];
|
|
535
|
-
return JSON.stringify({
|
|
536
|
-
project,
|
|
537
|
-
requestContext: {
|
|
538
|
-
route: params.route,
|
|
539
|
-
fileHints: params.fileHints
|
|
540
|
-
},
|
|
541
|
-
selectedTextLookup: textMatches,
|
|
542
|
-
routeLookup: routeMatches,
|
|
543
|
-
guidance: "이 컨텍스트는 로컬 프로젝트에서 수집한 실제 코드 검색 결과입니다. 수정 제안은 이 결과를 우선 근거로 삼고 path/oldText/newText 기반으로 작성하세요."
|
|
544
|
-
}, null, 2);
|
|
545
|
-
};
|
|
546
|
-
|
|
547
|
-
//#endregion
|
|
548
|
-
//#region src/bridge/lib/patch.ts
|
|
549
|
-
const execFileAsync = promisify(execFile);
|
|
550
|
-
const getChangedFiles = (patchPreview) => {
|
|
551
|
-
const regex = /^\+\+\+ b\/(.+)$/gm;
|
|
552
|
-
const changedFiles = /* @__PURE__ */ new Set();
|
|
553
|
-
let match = regex.exec(patchPreview);
|
|
554
|
-
while (match) {
|
|
555
|
-
changedFiles.add(match[1]);
|
|
556
|
-
match = regex.exec(patchPreview);
|
|
557
|
-
}
|
|
558
|
-
return [...changedFiles];
|
|
559
|
-
};
|
|
560
|
-
const normalizeUnifiedPatch = (patchPreview) => {
|
|
561
|
-
const withoutFence = patchPreview.replace(/^```(?:diff|patch)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
562
|
-
const diffStartIndex = withoutFence.indexOf("diff --git ");
|
|
563
|
-
return diffStartIndex >= 0 ? withoutFence.slice(diffStartIndex).trim() : withoutFence;
|
|
564
|
-
};
|
|
565
|
-
const validatePatchPaths = (patchPreview, validatePath) => {
|
|
566
|
-
const changedFiles = getChangedFiles(patchPreview);
|
|
567
|
-
if (!changedFiles.length) throw new Error("패치에서 변경 파일을 찾을 수 없습니다.");
|
|
568
|
-
changedFiles.forEach(validatePath);
|
|
569
|
-
return changedFiles;
|
|
570
|
-
};
|
|
571
|
-
const checkUnifiedPatch = async (patchPreview, cwd) => {
|
|
572
|
-
const tempFilePath = path.join(os.tmpdir(), `dev-copilot-check-${Date.now()}-${Math.random().toString(16).slice(2)}.patch`);
|
|
573
|
-
await promises.writeFile(tempFilePath, `${patchPreview.trimEnd()}\n`, "utf-8");
|
|
574
|
-
try {
|
|
575
|
-
await execFileAsync("git", [
|
|
576
|
-
"apply",
|
|
577
|
-
"--check",
|
|
578
|
-
tempFilePath
|
|
579
|
-
], { cwd });
|
|
580
|
-
} catch (error) {
|
|
581
|
-
const detail = error instanceof Error && "stderr" in error ? String(error.stderr) : error instanceof Error ? error.message : "알 수 없는 patch 검증 오류";
|
|
582
|
-
throw new Error(`적용 가능한 diff를 생성하지 못했습니다: ${detail.trim()}`);
|
|
583
|
-
} finally {
|
|
584
|
-
await promises.rm(tempFilePath, { force: true });
|
|
585
|
-
}
|
|
586
|
-
};
|
|
587
|
-
const createGitPatch = async (filePath, oldContent, newContent) => {
|
|
588
|
-
const tempDir = path.join(os.tmpdir(), `dev-copilot-diff-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
|
589
|
-
const oldFilePath = path.join(tempDir, "old", filePath);
|
|
590
|
-
const newFilePath = path.join(tempDir, "new", filePath);
|
|
591
|
-
await promises.mkdir(path.dirname(oldFilePath), { recursive: true });
|
|
592
|
-
await promises.mkdir(path.dirname(newFilePath), { recursive: true });
|
|
593
|
-
await promises.writeFile(oldFilePath, oldContent, "utf-8");
|
|
594
|
-
await promises.writeFile(newFilePath, newContent, "utf-8");
|
|
595
|
-
try {
|
|
596
|
-
const { stdout } = await execFileAsync("git", [
|
|
597
|
-
"diff",
|
|
598
|
-
"--no-index",
|
|
599
|
-
"--no-ext-diff",
|
|
600
|
-
"--src-prefix=a/",
|
|
601
|
-
"--dst-prefix=b/",
|
|
602
|
-
"--",
|
|
603
|
-
path.join("old", filePath),
|
|
604
|
-
path.join("new", filePath)
|
|
605
|
-
], { cwd: tempDir }).catch((error) => {
|
|
606
|
-
const typedError = error;
|
|
607
|
-
if (typedError.code === 1 && typedError.stdout) return { stdout: typedError.stdout };
|
|
608
|
-
throw error;
|
|
609
|
-
});
|
|
610
|
-
return stdout.replaceAll(`a/old/${filePath}`, `a/${filePath}`).replaceAll(`b/new/${filePath}`, `b/${filePath}`).trim();
|
|
611
|
-
} finally {
|
|
612
|
-
await promises.rm(tempDir, {
|
|
613
|
-
force: true,
|
|
614
|
-
recursive: true
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
};
|
|
618
|
-
const createPatchFromTextReplacements = async (replacements, cwd, validatePath) => {
|
|
619
|
-
const byPath = /* @__PURE__ */ new Map();
|
|
620
|
-
for (const replacement of replacements) {
|
|
621
|
-
const normalizedPath = validatePath(replacement.path);
|
|
622
|
-
const current = byPath.get(normalizedPath) ?? [];
|
|
623
|
-
current.push(replacement);
|
|
624
|
-
byPath.set(normalizedPath, current);
|
|
625
|
-
}
|
|
626
|
-
const patches = [];
|
|
627
|
-
for (const [filePath, fileReplacements] of byPath.entries()) {
|
|
628
|
-
const absolutePath = path.join(cwd, filePath);
|
|
629
|
-
const oldContent = await promises.readFile(absolutePath, "utf-8");
|
|
630
|
-
let newContent = oldContent;
|
|
631
|
-
for (const replacement of fileReplacements) {
|
|
632
|
-
if (!newContent.includes(replacement.oldText)) throw new Error(`원문을 파일에서 찾을 수 없습니다: ${filePath}`);
|
|
633
|
-
newContent = newContent.replace(replacement.oldText, replacement.newText);
|
|
634
|
-
}
|
|
635
|
-
if (newContent === oldContent) continue;
|
|
636
|
-
patches.push(await createGitPatch(filePath, oldContent, newContent));
|
|
637
|
-
}
|
|
638
|
-
const patchPreview = patches.join("\n").trim();
|
|
639
|
-
if (!patchPreview) throw new Error("변경할 내용이 없습니다.");
|
|
640
|
-
return patchPreview;
|
|
641
|
-
};
|
|
642
|
-
const applyUnifiedPatch = async (patchPreview, cwd, actor) => {
|
|
643
|
-
const tempFilePath = path.join(os.tmpdir(), `dev-copilot-${Date.now()}-${Math.random().toString(16).slice(2)}.patch`);
|
|
644
|
-
await promises.writeFile(tempFilePath, `${patchPreview.trimEnd()}\n`, "utf-8");
|
|
645
|
-
try {
|
|
646
|
-
await execFileAsync("git", [
|
|
647
|
-
"apply",
|
|
648
|
-
"--check",
|
|
649
|
-
tempFilePath
|
|
650
|
-
], { cwd });
|
|
651
|
-
await execFileAsync("git", ["apply", tempFilePath], { cwd });
|
|
652
|
-
return {
|
|
653
|
-
actor,
|
|
654
|
-
changedFiles: getChangedFiles(patchPreview)
|
|
655
|
-
};
|
|
656
|
-
} catch (error) {
|
|
657
|
-
const detail = error instanceof Error && "stderr" in error ? String(error.stderr) : error instanceof Error ? error.message : "알 수 없는 patch 적용 오류";
|
|
658
|
-
throw new Error(`patch 적용에 실패했습니다: ${detail.trim()}`);
|
|
659
|
-
} finally {
|
|
660
|
-
await promises.rm(tempFilePath, { force: true });
|
|
661
|
-
}
|
|
662
|
-
};
|
|
663
|
-
|
|
664
|
-
//#endregion
|
|
665
|
-
//#region src/bridge/lib/store.ts
|
|
666
|
-
const patchStore = /* @__PURE__ */ new Map();
|
|
667
|
-
const TTL_MS = 1e3 * 60 * 15;
|
|
668
|
-
const pruneExpired = () => {
|
|
669
|
-
const now = Date.now();
|
|
670
|
-
for (const [key, value] of patchStore.entries()) if (now - value.createdAt > TTL_MS) patchStore.delete(key);
|
|
671
|
-
};
|
|
672
|
-
const createProposedPatch = (patchPreview, allowedPaths) => {
|
|
673
|
-
pruneExpired();
|
|
674
|
-
const patchId = randomUUID();
|
|
675
|
-
const approvalToken = `approve:${patchId}`;
|
|
676
|
-
patchStore.set(patchId, {
|
|
677
|
-
patchId,
|
|
678
|
-
approvalToken,
|
|
679
|
-
patchPreview,
|
|
680
|
-
allowedPaths,
|
|
681
|
-
createdAt: Date.now()
|
|
682
|
-
});
|
|
683
|
-
return {
|
|
684
|
-
patchId,
|
|
685
|
-
approvalToken
|
|
686
|
-
};
|
|
687
|
-
};
|
|
688
|
-
const getProposedPatch = (patchId, approvalToken) => {
|
|
689
|
-
pruneExpired();
|
|
690
|
-
const item = patchStore.get(patchId);
|
|
691
|
-
if (!item || item.approvalToken !== approvalToken) return null;
|
|
692
|
-
return item;
|
|
693
|
-
};
|
|
694
|
-
const deleteProposedPatch = (patchId) => {
|
|
695
|
-
patchStore.delete(patchId);
|
|
696
|
-
};
|
|
697
|
-
|
|
698
|
-
//#endregion
|
|
699
|
-
//#region src/bridge/server/http-server.ts
|
|
700
|
-
const createCorsHeaders = (config) => ({
|
|
701
|
-
"Access-Control-Allow-Origin": config.corsOrigin,
|
|
702
|
-
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
|
|
703
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
704
|
-
"Content-Type": "application/json; charset=utf-8"
|
|
705
|
-
});
|
|
706
|
-
const sendJson = (response, config, statusCode, payload) => {
|
|
707
|
-
response.writeHead(statusCode, createCorsHeaders(config));
|
|
708
|
-
response.end(JSON.stringify(payload));
|
|
709
|
-
};
|
|
710
|
-
const readJsonBody = async (request) => {
|
|
711
|
-
const chunks = [];
|
|
712
|
-
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
713
|
-
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
714
|
-
return JSON.parse(raw);
|
|
715
|
-
};
|
|
716
|
-
const createProjectContext = async (config, payload, allowedPaths) => {
|
|
717
|
-
return buildCopilotProjectContext(config.rootDir, allowedPaths, {
|
|
718
|
-
selectedText: payload.selectedText,
|
|
719
|
-
route: payload.context?.route,
|
|
720
|
-
fileHints: payload.context?.fileHints
|
|
721
|
-
});
|
|
722
|
-
};
|
|
723
|
-
const sanitizeAllowedPaths = (fileHints) => {
|
|
724
|
-
if (!fileHints?.length) return null;
|
|
725
|
-
const sanitized = fileHints.map((value) => value.trim()).filter(Boolean).filter((value) => value === "." || value === "*" || /^[A-Za-z0-9._-]+$/.test(value));
|
|
726
|
-
return sanitized.length ? Array.from(new Set(sanitized)) : null;
|
|
727
|
-
};
|
|
728
|
-
const toPatchSummary = (changedFiles) => {
|
|
729
|
-
return changedFiles.length === 1 ? "1개 파일에 수정이 반영되었습니다." : `${changedFiles.length}개 파일에 수정이 반영되었습니다.`;
|
|
730
|
-
};
|
|
731
|
-
const toEditPayload = (payload) => {
|
|
732
|
-
return payload;
|
|
733
|
-
};
|
|
734
|
-
const isCopilotAgent = (value) => {
|
|
735
|
-
return value === "codex" || value === "claude";
|
|
736
|
-
};
|
|
737
|
-
const resolveRequestedAgent = (config, requestAgent, queryAgent) => {
|
|
738
|
-
if (requestAgent) return requestAgent;
|
|
739
|
-
if (isCopilotAgent(queryAgent)) return queryAgent;
|
|
740
|
-
return config.agent;
|
|
741
|
-
};
|
|
742
|
-
const createDevCopilotBridgeServer = (config) => {
|
|
743
|
-
return createDevCopilotBridgeServerWithDependencies(config, { resolveAdapter: resolveAgentAdapter });
|
|
744
|
-
};
|
|
745
|
-
const createDevCopilotBridgeServerWithDependencies = (config, dependencies) => {
|
|
746
|
-
return createServer(async (request, response) => {
|
|
747
|
-
const method = request.method ?? "GET";
|
|
748
|
-
const url = new URL(request.url ?? "/", `http://${config.host}:${config.port}`);
|
|
749
|
-
if (method === "OPTIONS") {
|
|
750
|
-
response.writeHead(204, createCorsHeaders(config));
|
|
751
|
-
response.end();
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
try {
|
|
755
|
-
if (method === "GET" && url.pathname === "/status") {
|
|
756
|
-
const agent = resolveRequestedAgent(config, void 0, url.searchParams.get("agent"));
|
|
757
|
-
sendJson(response, config, 200, await dependencies.resolveAdapter(agent).getStatus(config.rootDir));
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
if (method === "POST" && url.pathname === "/chat") {
|
|
761
|
-
const payload = await readJsonBody(request);
|
|
762
|
-
const effectiveAllowedPaths = sanitizeAllowedPaths(payload.context?.fileHints) ?? config.allowedPaths;
|
|
763
|
-
const agent = resolveRequestedAgent(config, payload.context?.agent);
|
|
764
|
-
const adapter = dependencies.resolveAdapter(agent);
|
|
765
|
-
if (!payload.prompt?.trim()) {
|
|
766
|
-
sendJson(response, config, 400, { error: "프롬프트를 입력해 주세요." });
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
const agentResponse = await adapter.run({
|
|
770
|
-
selectedText: payload.selectedText ?? "",
|
|
771
|
-
prompt: payload.prompt,
|
|
772
|
-
mode: payload.mode,
|
|
773
|
-
route: payload.context?.route,
|
|
774
|
-
fileHints: payload.context?.fileHints,
|
|
775
|
-
previousResponse: payload.context?.previousResponse,
|
|
776
|
-
projectContext: await createProjectContext(config, payload, effectiveAllowedPaths),
|
|
777
|
-
cwd: config.rootDir
|
|
778
|
-
});
|
|
779
|
-
if (payload.mode === "answer") {
|
|
780
|
-
sendJson(response, config, 200, {
|
|
781
|
-
message: agentResponse.message,
|
|
782
|
-
warnings: agentResponse.warnings
|
|
783
|
-
});
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
const parsed = toEditPayload(agentResponse);
|
|
787
|
-
let patchPreview = "";
|
|
788
|
-
try {
|
|
789
|
-
if (parsed.changes?.length) patchPreview = await createPatchFromTextReplacements(parsed.changes, config.rootDir, (filePath) => resolveAndValidatePath(filePath, effectiveAllowedPaths, config.rootDir));
|
|
790
|
-
else if (parsed.patchPreview) patchPreview = normalizeUnifiedPatch(parsed.patchPreview);
|
|
791
|
-
} catch (error) {
|
|
792
|
-
sendJson(response, config, 200, {
|
|
793
|
-
message: parsed.message ?? "에이전트가 수정안을 만들었지만 실제 파일 내용과 매칭하지 못했습니다.",
|
|
794
|
-
warnings: [...parsed.warnings ?? [], error instanceof Error ? error.message : "수정안 생성에 실패했습니다."]
|
|
795
|
-
});
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
if (!patchPreview) {
|
|
799
|
-
sendJson(response, config, 200, {
|
|
800
|
-
message: parsed.message ?? "패치 제안을 생성하지 못했습니다.",
|
|
801
|
-
warnings: [...parsed.warnings ?? [], "적용 가능한 변경 목록이 없어 패치 미리보기와 적용 버튼을 만들 수 없습니다."]
|
|
802
|
-
});
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
try {
|
|
806
|
-
validatePatchPaths(patchPreview, (filePath) => resolveAndValidatePath(filePath, effectiveAllowedPaths, config.rootDir));
|
|
807
|
-
await checkUnifiedPatch(patchPreview, config.rootDir);
|
|
808
|
-
} catch (error) {
|
|
809
|
-
sendJson(response, config, 200, {
|
|
810
|
-
message: parsed.message ?? "에이전트가 수정안을 만들었지만 적용 가능한 diff 형식이 아닙니다.",
|
|
811
|
-
patchPreview,
|
|
812
|
-
warnings: [...parsed.warnings ?? [], error instanceof Error ? error.message : "patch 검증에 실패했습니다."]
|
|
813
|
-
});
|
|
814
|
-
return;
|
|
815
|
-
}
|
|
816
|
-
const patch = createProposedPatch(patchPreview, effectiveAllowedPaths);
|
|
817
|
-
sendJson(response, config, 200, {
|
|
818
|
-
message: parsed.message ?? "에이전트가 패치 미리보기를 생성했습니다.",
|
|
819
|
-
patchPreview,
|
|
820
|
-
patchId: patch.patchId,
|
|
821
|
-
warnings: parsed.warnings ?? []
|
|
822
|
-
});
|
|
823
|
-
return;
|
|
824
|
-
}
|
|
825
|
-
if (method === "POST" && url.pathname === "/apply") {
|
|
826
|
-
const payload = await readJsonBody(request);
|
|
827
|
-
if (!payload.patchId || !payload.approvalToken) {
|
|
828
|
-
sendJson(response, config, 400, { error: "patchId와 approvalToken이 필요합니다." });
|
|
829
|
-
return;
|
|
830
|
-
}
|
|
831
|
-
const proposedPatch = getProposedPatch(payload.patchId, payload.approvalToken);
|
|
832
|
-
if (!proposedPatch) {
|
|
833
|
-
sendJson(response, config, 400, { error: "유효하지 않거나 만료된 patchId입니다." });
|
|
834
|
-
return;
|
|
835
|
-
}
|
|
836
|
-
validatePatchPaths(proposedPatch.patchPreview, (filePath) => resolveAndValidatePath(filePath, proposedPatch.allowedPaths?.length ? proposedPatch.allowedPaths : config.allowedPaths, config.rootDir));
|
|
837
|
-
const result = await applyUnifiedPatch(proposedPatch.patchPreview, config.rootDir, "local-codex-bridge");
|
|
838
|
-
deleteProposedPatch(payload.patchId);
|
|
839
|
-
sendJson(response, config, 200, {
|
|
840
|
-
applied: true,
|
|
841
|
-
changedFiles: result.changedFiles,
|
|
842
|
-
summary: toPatchSummary(result.changedFiles)
|
|
843
|
-
});
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
sendJson(response, config, 404, { error: "지원하지 않는 경로입니다." });
|
|
847
|
-
} catch (error) {
|
|
848
|
-
sendJson(response, config, 500, { error: error instanceof Error ? error.message : "브리지 서버 처리 중 오류가 발생했습니다." });
|
|
849
|
-
}
|
|
850
|
-
});
|
|
851
|
-
};
|
|
852
|
-
|
|
853
|
-
//#endregion
|
|
854
|
-
//#region src/bridge/cli/run-http.ts
|
|
855
|
-
const resolveAgent = (value) => {
|
|
856
|
-
if (value === "claude") return "claude";
|
|
857
|
-
return "codex";
|
|
858
|
-
};
|
|
859
|
-
const runDevCopilotBridgeCli = async (argv) => {
|
|
860
|
-
const portFlagIndex = argv.findIndex((value) => value === "-p");
|
|
861
|
-
const portFlagValue = portFlagIndex >= 0 ? argv[portFlagIndex + 1] : void 0;
|
|
862
|
-
const positionalPort = argv.find((value) => /^\d+$/.test(value));
|
|
863
|
-
const resolvedPort = portFlagValue ? Number(portFlagValue) : positionalPort ? Number(positionalPort) : Number(process.env.DEV_COPILOT_BRIDGE_PORT ?? 3339);
|
|
864
|
-
const positionalAgent = argv.find((value) => value === "codex" || value === "claude");
|
|
865
|
-
const config = createDevCopilotBridgeConfig({
|
|
866
|
-
rootDir: process.cwd(),
|
|
867
|
-
host: process.env.DEV_COPILOT_BRIDGE_HOST,
|
|
868
|
-
port: Number.isFinite(resolvedPort) ? resolvedPort : 3339,
|
|
869
|
-
corsOrigin: process.env.DEV_COPILOT_BRIDGE_CORS_ORIGIN ?? "*",
|
|
870
|
-
agent: resolveAgent(positionalAgent),
|
|
871
|
-
allowedPaths: (process.env.DEV_COPILOT_ALLOWED_PATHS ?? "app,src,widgets,features,entities,shared,components").split(",").map((value) => value.trim()).filter(Boolean)
|
|
872
|
-
});
|
|
873
|
-
const server = createDevCopilotBridgeServer(config);
|
|
874
|
-
await new Promise((resolve) => {
|
|
875
|
-
server.listen(config.port, config.host, () => {
|
|
876
|
-
process.stdout.write(`[dev-copilot-bridge] listening on http://${config.host}:${config.port}\n`);
|
|
877
|
-
resolve();
|
|
878
|
-
});
|
|
879
|
-
});
|
|
880
|
-
};
|
|
881
|
-
|
|
882
|
-
//#endregion
|
|
883
4
|
//#region src/bin/bridge.ts
|
|
884
5
|
runDevCopilotBridgeCli(process.argv.slice(2)).catch((error) => {
|
|
6
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
885
7
|
const message = error instanceof Error ? error.message : String(error);
|
|
886
|
-
|
|
8
|
+
const normalized = code === "EADDRINUSE" ? "요청한 브리지 포트가 이미 사용 중입니다. 포트를 변경해서 다시 실행해 주세요." : message;
|
|
9
|
+
process.stderr.write(`[dev-copilot-bridge] ${normalized}\n`);
|
|
887
10
|
process.exit(1);
|
|
888
11
|
});
|
|
889
12
|
|