sanjang 0.3.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/LICENSE +21 -0
- package/README.md +218 -0
- package/bin/__tests__/sanjang.test.ts +42 -0
- package/bin/sanjang.js +17 -0
- package/bin/sanjang.ts +144 -0
- package/dashboard/app.js +1888 -0
- package/dashboard/app.test.js +2 -0
- package/dashboard/index.html +275 -0
- package/dashboard/style.css +2112 -0
- package/lib/config.ts +337 -0
- package/lib/engine/cache.ts +218 -0
- package/lib/engine/config-hotfix.ts +161 -0
- package/lib/engine/conflict.ts +33 -0
- package/lib/engine/diagnostics.ts +81 -0
- package/lib/engine/naming.ts +93 -0
- package/lib/engine/ports.ts +61 -0
- package/lib/engine/pr.ts +71 -0
- package/lib/engine/process.ts +283 -0
- package/lib/engine/self-heal.ts +130 -0
- package/lib/engine/smart-init.ts +136 -0
- package/lib/engine/smart-pr.ts +130 -0
- package/lib/engine/snapshot.ts +45 -0
- package/lib/engine/state.ts +60 -0
- package/lib/engine/suggest.ts +169 -0
- package/lib/engine/warp.ts +47 -0
- package/lib/engine/watcher.ts +40 -0
- package/lib/engine/worktree.ts +100 -0
- package/lib/server.ts +1560 -0
- package/lib/types.ts +130 -0
- package/package.json +48 -0
- package/templates/sanjang.config.js +32 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { BroadcastMessage, EventCallback, SanjangConfig } from "../types.ts";
|
|
5
|
+
import { campPath, getProjectRoot } from "./worktree.ts";
|
|
6
|
+
|
|
7
|
+
interface CampProcessEntry {
|
|
8
|
+
feProc: ChildProcess | null;
|
|
9
|
+
feLogs: string[];
|
|
10
|
+
feExitCode: number | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ProcessInfo {
|
|
14
|
+
feLogs: string[];
|
|
15
|
+
feExitCode: number | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const procs: Map<string, CampProcessEntry> = new Map();
|
|
19
|
+
let projectConfig: SanjangConfig | null = null;
|
|
20
|
+
let sharedBeProc: ChildProcess | null = null;
|
|
21
|
+
|
|
22
|
+
export function setConfig(config: SanjangConfig): void {
|
|
23
|
+
projectConfig = config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
// Detect actual port from dev server stdout (Vite: "➜ Local: http://localhost:3004/")
|
|
31
|
+
function detectPortFromStdout(logs: string[], timeoutMs: number): Promise<number | null> {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const deadline = Date.now() + timeoutMs;
|
|
34
|
+
// Patterns: Vite "Local: http://localhost:PORT/", Next "- Local: http://localhost:PORT"
|
|
35
|
+
const portRe = /https?:\/\/localhost:(\d+)/;
|
|
36
|
+
|
|
37
|
+
function check(): void {
|
|
38
|
+
for (const line of logs) {
|
|
39
|
+
const match = portRe.exec(line);
|
|
40
|
+
if (match?.[1]) {
|
|
41
|
+
const port = parseInt(match[1], 10);
|
|
42
|
+
// Wait briefly for the port to actually be ready
|
|
43
|
+
const sock = createConnection({ port, host: "localhost" });
|
|
44
|
+
sock.once("connect", () => { sock.destroy(); resolve(port); });
|
|
45
|
+
sock.once("error", () => {
|
|
46
|
+
sock.destroy();
|
|
47
|
+
// Port printed but not ready yet, retry
|
|
48
|
+
if (Date.now() < deadline) setTimeout(check, 1000);
|
|
49
|
+
else resolve(port); // return the port anyway
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (Date.now() >= deadline) {
|
|
55
|
+
resolve(null);
|
|
56
|
+
} else {
|
|
57
|
+
setTimeout(check, 1000);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
check();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function waitForPort(port: number, timeoutMs: number): Promise<void> {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const deadline = Date.now() + timeoutMs;
|
|
67
|
+
function attempt(): void {
|
|
68
|
+
const sock = createConnection({ port, host: "localhost" });
|
|
69
|
+
sock.once("connect", () => {
|
|
70
|
+
sock.destroy();
|
|
71
|
+
resolve();
|
|
72
|
+
});
|
|
73
|
+
sock.once("error", () => {
|
|
74
|
+
sock.destroy();
|
|
75
|
+
if (Date.now() >= deadline) {
|
|
76
|
+
reject(new Error(`포트 ${port}이 ${timeoutMs / 1000}초 내에 열리지 않았습니다.`));
|
|
77
|
+
} else {
|
|
78
|
+
setTimeout(attempt, 2000);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
attempt();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function waitForHttp(url: string, timeoutMs: number): Promise<void> {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const deadline = Date.now() + timeoutMs;
|
|
89
|
+
async function attempt(): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(url);
|
|
92
|
+
if (res.ok) return resolve();
|
|
93
|
+
} catch {
|
|
94
|
+
/* not ready */
|
|
95
|
+
}
|
|
96
|
+
if (Date.now() >= deadline) {
|
|
97
|
+
reject(new Error(`HTTP 응답 없음 (${timeoutMs / 1000}초 초과)`));
|
|
98
|
+
} else {
|
|
99
|
+
setTimeout(attempt, 3000);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
attempt();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function attachLogs(child: ChildProcess, logBuf: string[], source: string, onEvent: EventCallback): void {
|
|
107
|
+
function handleData(data: Buffer): void {
|
|
108
|
+
const text = data.toString();
|
|
109
|
+
logBuf.push(text);
|
|
110
|
+
for (const line of text.split("\n")) {
|
|
111
|
+
if (line.trim()) onEvent({ type: "log", source, data: line });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
child.stdout?.on("data", handleData);
|
|
115
|
+
child.stderr?.on("data", handleData);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Backend (shared, optional)
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
async function ensureBackend(onEvent: EventCallback): Promise<void> {
|
|
123
|
+
const be = projectConfig?.backend;
|
|
124
|
+
if (!be) return; // No backend configured
|
|
125
|
+
|
|
126
|
+
// Check if already running
|
|
127
|
+
try {
|
|
128
|
+
const healthUrl = be.healthCheck?.startsWith("/")
|
|
129
|
+
? `http://localhost:${be.port}${be.healthCheck}`
|
|
130
|
+
: `http://localhost:${be.port}`;
|
|
131
|
+
const res = await fetch(healthUrl);
|
|
132
|
+
if (res.ok) {
|
|
133
|
+
onEvent({ type: "log", source: "sanjang", data: `Backend(:${be.port}) 실행 중 ✓` });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
/* not running */
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onEvent({ type: "log", source: "sanjang", data: `Backend(:${be.port}) 시작 중...` });
|
|
141
|
+
|
|
142
|
+
const [cmd = "echo", ...args] = be.command.split(" ");
|
|
143
|
+
const cwd = be.cwd ? join(getProjectRoot(), be.cwd) : getProjectRoot();
|
|
144
|
+
|
|
145
|
+
const beProc = spawn(cmd, args, {
|
|
146
|
+
cwd,
|
|
147
|
+
env: { ...process.env, PORT: String(be.port), ...be.env },
|
|
148
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
149
|
+
detached: true,
|
|
150
|
+
shell: true,
|
|
151
|
+
});
|
|
152
|
+
beProc.unref();
|
|
153
|
+
sharedBeProc = beProc;
|
|
154
|
+
beProc.stdout?.on("data", (d: Buffer) => onEvent({ type: "log", source: "backend", data: d.toString() }));
|
|
155
|
+
beProc.stderr?.on("data", (d: Buffer) => onEvent({ type: "log", source: "backend", data: d.toString() }));
|
|
156
|
+
|
|
157
|
+
if (be.healthCheck) {
|
|
158
|
+
const healthUrl = be.healthCheck.startsWith("/") ? `http://localhost:${be.port}${be.healthCheck}` : be.healthCheck;
|
|
159
|
+
await waitForHttp(healthUrl, 60_000);
|
|
160
|
+
} else {
|
|
161
|
+
await waitForPort(be.port, 60_000);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
onEvent({ type: "log", source: "sanjang", data: `Backend(:${be.port}) 시작 완료 ✓` });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Public API
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
interface StartCampParams {
|
|
172
|
+
name: string;
|
|
173
|
+
fePort: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function startCamp(pg: StartCampParams, onEvent: EventCallback): Promise<number> {
|
|
177
|
+
const { name, fePort } = pg;
|
|
178
|
+
const wtPath = campPath(name);
|
|
179
|
+
const dev = projectConfig?.dev;
|
|
180
|
+
|
|
181
|
+
if (!dev) throw new Error("dev command not configured");
|
|
182
|
+
|
|
183
|
+
const entry: CampProcessEntry = {
|
|
184
|
+
feProc: null,
|
|
185
|
+
feLogs: [],
|
|
186
|
+
feExitCode: null,
|
|
187
|
+
};
|
|
188
|
+
procs.set(name, entry);
|
|
189
|
+
|
|
190
|
+
// Step 1: Backend (optional, shared)
|
|
191
|
+
try {
|
|
192
|
+
await ensureBackend(onEvent);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
195
|
+
onEvent({ type: "log", source: "sanjang", data: `⚠️ Backend 시작 실패 — FE만 띄웁니다. (${message})` });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Step 2: Frontend
|
|
199
|
+
onEvent({ type: "log", source: "sanjang", data: "Frontend 준비 중..." });
|
|
200
|
+
onEvent({ type: "status", data: "starting-frontend" });
|
|
201
|
+
|
|
202
|
+
// Build command — try to pass port flag if available, but always verify via stdout
|
|
203
|
+
const fullCommand = dev.portFlag ? `${dev.command} ${dev.portFlag} ${fePort}` : dev.command;
|
|
204
|
+
const cwd = dev.cwd ? join(wtPath, dev.cwd) : wtPath;
|
|
205
|
+
|
|
206
|
+
const feProc = spawn(fullCommand, [], {
|
|
207
|
+
cwd,
|
|
208
|
+
env: {
|
|
209
|
+
...process.env,
|
|
210
|
+
...dev.env,
|
|
211
|
+
...(projectConfig?.backend
|
|
212
|
+
? {
|
|
213
|
+
VITE_API_PROXY_TARGET: `http://localhost:${projectConfig.backend.port}`,
|
|
214
|
+
}
|
|
215
|
+
: {}),
|
|
216
|
+
},
|
|
217
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
218
|
+
shell: true,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
entry.feProc = feProc;
|
|
222
|
+
attachLogs(feProc, entry.feLogs, "frontend", onEvent);
|
|
223
|
+
feProc.on("exit", (code: number | null) => {
|
|
224
|
+
entry.feExitCode = code;
|
|
225
|
+
onEvent({ type: "process-exit", source: "frontend", data: code });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
onEvent({ type: "status", data: "waiting-for-vite" });
|
|
229
|
+
|
|
230
|
+
// Always detect actual URL from stdout — never guess
|
|
231
|
+
const detectedPort = await detectPortFromStdout(entry.feLogs, 90_000);
|
|
232
|
+
if (!detectedPort) {
|
|
233
|
+
throw new Error("dev 서버가 시작되지 않았습니다. 로그를 확인하세요.");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const url = `http://localhost:${detectedPort}`;
|
|
237
|
+
onEvent({ type: "log", source: "sanjang", data: `Frontend 준비 완료 ✓` });
|
|
238
|
+
onEvent({ type: "url-detected", data: { url, port: detectedPort } });
|
|
239
|
+
onEvent({ type: "status", data: "running" });
|
|
240
|
+
return detectedPort;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function stopCamp(name: string): void {
|
|
244
|
+
const entry = procs.get(name);
|
|
245
|
+
if (!entry) return;
|
|
246
|
+
if (entry.feProc && !entry.feProc.killed) {
|
|
247
|
+
entry.feProc.kill("SIGTERM");
|
|
248
|
+
// SIGKILL fallback if still alive after 5s
|
|
249
|
+
const proc = entry.feProc;
|
|
250
|
+
setTimeout(() => {
|
|
251
|
+
try {
|
|
252
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
253
|
+
} catch {
|
|
254
|
+
/* already dead */
|
|
255
|
+
}
|
|
256
|
+
}, 5000);
|
|
257
|
+
}
|
|
258
|
+
procs.delete(name);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function stopAllCamps(): void {
|
|
262
|
+
for (const [name] of procs) {
|
|
263
|
+
stopCamp(name);
|
|
264
|
+
}
|
|
265
|
+
// Kill shared backend if tracked
|
|
266
|
+
if (sharedBeProc && !sharedBeProc.killed) {
|
|
267
|
+
try {
|
|
268
|
+
sharedBeProc.kill("SIGTERM");
|
|
269
|
+
} catch {
|
|
270
|
+
/* ignore */
|
|
271
|
+
}
|
|
272
|
+
sharedBeProc = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function getProcessInfo(name: string): ProcessInfo | null {
|
|
277
|
+
const entry = procs.get(name);
|
|
278
|
+
if (!entry) return null;
|
|
279
|
+
return {
|
|
280
|
+
feLogs: entry.feLogs,
|
|
281
|
+
feExitCode: entry.feExitCode,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync, copyFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Diagnosis: pattern-match logs to identify fixable issues
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export interface HealAction {
|
|
10
|
+
type: "reinstall" | "copy-env" | "restart" | "ask-ai";
|
|
11
|
+
message: string;
|
|
12
|
+
auto: boolean; // can be fixed without human input
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const PATTERNS: Array<{ test: RegExp; action: () => HealAction }> = [
|
|
16
|
+
{
|
|
17
|
+
test: /Cannot find module|MODULE_NOT_FOUND/i,
|
|
18
|
+
action: () => ({
|
|
19
|
+
type: "reinstall",
|
|
20
|
+
message: "필요한 패키지가 없습니다. 다시 설치합니다.",
|
|
21
|
+
auto: true,
|
|
22
|
+
}),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
test: /does not provide an export named|env\/static\/public/i,
|
|
26
|
+
action: () => ({
|
|
27
|
+
type: "copy-env",
|
|
28
|
+
message: "환경 설정 파일이 없습니다. 메인에서 복사합니다.",
|
|
29
|
+
auto: true,
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
test: /ENOENT.*\.env/i,
|
|
34
|
+
action: () => ({
|
|
35
|
+
type: "copy-env",
|
|
36
|
+
message: ".env 파일을 찾을 수 없습니다. 메인에서 복사합니다.",
|
|
37
|
+
auto: true,
|
|
38
|
+
}),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
test: /403.*Forbidden|server\.fs\.allow/i,
|
|
42
|
+
action: () => ({
|
|
43
|
+
type: "reinstall",
|
|
44
|
+
message: "파일 접근 문제가 있습니다. 의존성을 다시 설치합니다.",
|
|
45
|
+
auto: true,
|
|
46
|
+
}),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
test: /EADDRINUSE|address already in use/i,
|
|
50
|
+
action: () => ({
|
|
51
|
+
type: "restart",
|
|
52
|
+
message: "다른 프로그램과 충돌이 있습니다. 다시 시작합니다.",
|
|
53
|
+
auto: true,
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export function diagnoseFromLogs(logs: string[]): HealAction[] {
|
|
59
|
+
const combined = logs.join("\n");
|
|
60
|
+
const actions: HealAction[] = [];
|
|
61
|
+
const seenTypes = new Set<string>();
|
|
62
|
+
|
|
63
|
+
for (const pattern of PATTERNS) {
|
|
64
|
+
if (pattern.test.test(combined)) {
|
|
65
|
+
const action = pattern.action();
|
|
66
|
+
if (!seenTypes.has(action.type)) {
|
|
67
|
+
seenTypes.add(action.type);
|
|
68
|
+
actions.push(action);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return actions;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Heal: execute fixes
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
export interface HealResult {
|
|
81
|
+
action: HealAction;
|
|
82
|
+
success: boolean;
|
|
83
|
+
detail?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function executeHeal(
|
|
87
|
+
action: HealAction,
|
|
88
|
+
campPath: string,
|
|
89
|
+
projectRoot: string,
|
|
90
|
+
setupCommand: string | null,
|
|
91
|
+
copyFiles: string[],
|
|
92
|
+
): HealResult {
|
|
93
|
+
switch (action.type) {
|
|
94
|
+
case "reinstall": {
|
|
95
|
+
if (!setupCommand) return { action, success: false, detail: "설치 명령이 없습니다." };
|
|
96
|
+
try {
|
|
97
|
+
execSync(setupCommand, { cwd: campPath, stdio: "pipe", timeout: 120_000, shell: true } as unknown as import("node:child_process").ExecSyncOptions);
|
|
98
|
+
return { action, success: true };
|
|
99
|
+
} catch {
|
|
100
|
+
return { action, success: false, detail: "설치에 실패했습니다." };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case "copy-env": {
|
|
105
|
+
let copied = 0;
|
|
106
|
+
for (const file of copyFiles) {
|
|
107
|
+
const src = join(projectRoot, file);
|
|
108
|
+
const dst = join(campPath, file);
|
|
109
|
+
if (existsSync(src) && !existsSync(dst)) {
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
112
|
+
copyFileSync(src, dst);
|
|
113
|
+
copied++;
|
|
114
|
+
} catch { /* skip */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { action, success: copied > 0, detail: copied > 0 ? `${copied}개 파일 복사됨` : "복사할 파일이 없습니다." };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case "restart":
|
|
121
|
+
// Restart is handled by the caller (stop + start)
|
|
122
|
+
return { action, success: true, detail: "재시작을 시도합니다." };
|
|
123
|
+
|
|
124
|
+
case "ask-ai":
|
|
125
|
+
return { action, success: false, detail: "자동 수정할 수 없습니다." };
|
|
126
|
+
|
|
127
|
+
default:
|
|
128
|
+
return { action, success: false, detail: "알 수 없는 액션입니다." };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Deep env file scanner
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const ENV_PATTERNS = [".env", ".env.local", ".env.development", ".env.development.local"];
|
|
9
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".sanjang", "dist", "build", ".next", ".nuxt", ".svelte-kit"]);
|
|
10
|
+
|
|
11
|
+
export function deepFindEnvFiles(projectRoot: string, maxDepth: number = 4): string[] {
|
|
12
|
+
const results: string[] = [];
|
|
13
|
+
|
|
14
|
+
function walk(dir: string, depth: number): void {
|
|
15
|
+
if (depth > maxDepth) return;
|
|
16
|
+
let entries;
|
|
17
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
18
|
+
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (entry.isFile() && ENV_PATTERNS.includes(entry.name)) {
|
|
21
|
+
results.push(relative(projectRoot, join(dir, entry.name)));
|
|
22
|
+
}
|
|
23
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) {
|
|
24
|
+
walk(join(dir, entry.name), depth + 1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
walk(projectRoot, 0);
|
|
30
|
+
return results.sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Setup issue detector
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export interface SetupIssue {
|
|
38
|
+
type: "env-reference-no-file" | "bun-cache-skip" | "missing-lockfile" | "turbo-no-filter";
|
|
39
|
+
message: string;
|
|
40
|
+
fix?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function detectSetupIssues(projectRoot: string): SetupIssue[] {
|
|
44
|
+
const issues: SetupIssue[] = [];
|
|
45
|
+
|
|
46
|
+
// 1. Check for env variable references without .env files
|
|
47
|
+
const hasEnvFile = ENV_PATTERNS.some(f => existsSync(join(projectRoot, f)));
|
|
48
|
+
if (!hasEnvFile) {
|
|
49
|
+
const envRefFound = scanForEnvReferences(projectRoot);
|
|
50
|
+
if (envRefFound) {
|
|
51
|
+
issues.push({
|
|
52
|
+
type: "env-reference-no-file",
|
|
53
|
+
message: "코드에서 환경변수를 참조하지만 .env 파일이 없습니다.",
|
|
54
|
+
fix: "프로젝트의 .env 파일을 찾아 copyFiles에 추가",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 2. Check for bun (cache doesn't work with bun symlinks)
|
|
60
|
+
if (existsSync(join(projectRoot, "bun.lock")) || existsSync(join(projectRoot, "bun.lockb"))) {
|
|
61
|
+
issues.push({
|
|
62
|
+
type: "bun-cache-skip",
|
|
63
|
+
message: "bun 프로젝트는 캐시 대신 직접 설치합니다.",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Check for missing lockfile
|
|
68
|
+
const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock"];
|
|
69
|
+
if (!lockfiles.some(f => existsSync(join(projectRoot, f)))) {
|
|
70
|
+
issues.push({
|
|
71
|
+
type: "missing-lockfile",
|
|
72
|
+
message: "lockfile이 없습니다. 의존성 설치가 느릴 수 있습니다.",
|
|
73
|
+
fix: "npm install 또는 bun install을 한 번 실행해주세요",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 4. Check for turbo without filter (multiple apps)
|
|
78
|
+
if (existsSync(join(projectRoot, "turbo.json"))) {
|
|
79
|
+
const appCount = countTurboApps(projectRoot);
|
|
80
|
+
if (appCount > 1) {
|
|
81
|
+
issues.push({
|
|
82
|
+
type: "turbo-no-filter",
|
|
83
|
+
message: `${appCount}개 앱이 감지됨. --filter 없이 실행하면 모든 앱이 동시에 시작됩니다.`,
|
|
84
|
+
fix: "메인 앱에 --filter 적용",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return issues;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Helpers
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
function scanForEnvReferences(dir: string, depth: number = 0): boolean {
|
|
97
|
+
if (depth > 3) return false;
|
|
98
|
+
const envPatterns = /import\.meta\.env\.|process\.env\.|PUBLIC_|VITE_|NEXT_PUBLIC_/;
|
|
99
|
+
|
|
100
|
+
let entries;
|
|
101
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; }
|
|
102
|
+
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.isFile() && /\.(ts|js|tsx|jsx|svelte|vue)$/.test(entry.name)) {
|
|
105
|
+
try {
|
|
106
|
+
const content = readFileSync(join(dir, entry.name), "utf8").slice(0, 5000);
|
|
107
|
+
if (envPatterns.test(content)) return true;
|
|
108
|
+
} catch { continue; }
|
|
109
|
+
}
|
|
110
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
111
|
+
if (scanForEnvReferences(join(dir, entry.name), depth + 1)) return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function countTurboApps(root: string): number {
|
|
118
|
+
let count = 0;
|
|
119
|
+
for (const dir of ["apps", "packages"]) {
|
|
120
|
+
const base = join(root, dir);
|
|
121
|
+
if (!existsSync(base)) continue;
|
|
122
|
+
let entries;
|
|
123
|
+
try { entries = readdirSync(base, { withFileTypes: true }); } catch { continue; }
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (!entry.isDirectory()) continue;
|
|
126
|
+
const pkgPath = join(base, entry.name, "package.json");
|
|
127
|
+
if (!existsSync(pkgPath)) continue;
|
|
128
|
+
try {
|
|
129
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
|
|
130
|
+
const scripts = pkg.scripts as Record<string, string> | undefined;
|
|
131
|
+
if (scripts?.dev) count++;
|
|
132
|
+
} catch { continue; }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return count;
|
|
136
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart PR description generator.
|
|
3
|
+
*
|
|
4
|
+
* Uses `claude -p` to generate a human-readable PR description from the diff.
|
|
5
|
+
* Falls back to a simple file-count summary when the CLI is unavailable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
const TIMEOUT_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
interface SpawnResult {
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
code: number | null;
|
|
16
|
+
timedOut: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run a command asynchronously with a timeout.
|
|
21
|
+
*/
|
|
22
|
+
function run(cmd: string, args: string[], opts: { cwd: string; timeoutMs: number }): Promise<SpawnResult> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
let stdout = "";
|
|
25
|
+
let stderr = "";
|
|
26
|
+
let timedOut = false;
|
|
27
|
+
|
|
28
|
+
const child = spawn(cmd, args, {
|
|
29
|
+
cwd: opts.cwd,
|
|
30
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
31
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
timedOut = true;
|
|
36
|
+
child.kill("SIGTERM");
|
|
37
|
+
}, opts.timeoutMs);
|
|
38
|
+
|
|
39
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
40
|
+
stdout += chunk.toString();
|
|
41
|
+
});
|
|
42
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
43
|
+
stderr += chunk.toString();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
child.on("close", (code: number | null) => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
resolve({ stdout, stderr, code, timedOut });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
child.on("error", () => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
resolve({ stdout, stderr, code: null, timedOut });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse diff --stat output to extract file count.
|
|
60
|
+
* Example line: " 3 files changed, 10 insertions(+), 2 deletions(-)"
|
|
61
|
+
*/
|
|
62
|
+
export function parseDiffStatSummary(diffStat: string): string {
|
|
63
|
+
const trimmed = diffStat.trim();
|
|
64
|
+
if (!trimmed) return "변경사항이 없어요";
|
|
65
|
+
|
|
66
|
+
const lines = trimmed.split("\n");
|
|
67
|
+
const summaryLine = lines[lines.length - 1] ?? "";
|
|
68
|
+
const filesMatch = /(\d+)\s+files?\s+changed/.exec(summaryLine);
|
|
69
|
+
const insertMatch = /(\d+)\s+insertions?/.exec(summaryLine);
|
|
70
|
+
const deleteMatch = /(\d+)\s+deletions?/.exec(summaryLine);
|
|
71
|
+
|
|
72
|
+
const fileCount = filesMatch ? parseInt(filesMatch[1]!, 10) : 0;
|
|
73
|
+
const insertions = insertMatch ? parseInt(insertMatch[1]!, 10) : 0;
|
|
74
|
+
const deletions = deleteMatch ? parseInt(deleteMatch[1]!, 10) : 0;
|
|
75
|
+
|
|
76
|
+
if (fileCount === 0) return "변경사항이 없어요";
|
|
77
|
+
|
|
78
|
+
const parts: string[] = [`${fileCount}개 파일을 수정했어요`];
|
|
79
|
+
if (insertions > 0 || deletions > 0) {
|
|
80
|
+
parts.push(`(+${insertions}, -${deletions})`);
|
|
81
|
+
}
|
|
82
|
+
return parts.join(" ");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check if claude CLI is available on PATH.
|
|
87
|
+
*/
|
|
88
|
+
function isClaudeAvailable(): boolean {
|
|
89
|
+
const result = spawnSync("which", ["claude"], { stdio: "pipe" });
|
|
90
|
+
return result.status === 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generate a PR description for the given worktree path.
|
|
95
|
+
*
|
|
96
|
+
* 1. Runs `git diff --stat` and `git diff` in the worktree.
|
|
97
|
+
* 2. If claude CLI is available, asks it to summarise in Korean.
|
|
98
|
+
* 3. Falls back to a simple file-count summary otherwise.
|
|
99
|
+
*/
|
|
100
|
+
export async function generatePrDescription(wtPath: string): Promise<string> {
|
|
101
|
+
// Gather diff info
|
|
102
|
+
const [statResult, diffResult] = await Promise.all([
|
|
103
|
+
run("git", ["diff", "--stat", "HEAD"], { cwd: wtPath, timeoutMs: 10_000 }),
|
|
104
|
+
run("git", ["diff", "HEAD"], { cwd: wtPath, timeoutMs: 10_000 }),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const diffStat = statResult.stdout.trim();
|
|
108
|
+
const diff = diffResult.stdout;
|
|
109
|
+
|
|
110
|
+
if (!diffStat && !diff.trim()) {
|
|
111
|
+
return "변경사항이 없어요";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Try claude CLI
|
|
115
|
+
if (isClaudeAvailable()) {
|
|
116
|
+
const diffSnippet = diff.slice(0, 500);
|
|
117
|
+
const prompt = `이 변경사항을 비개발자도 이해할 수 있게 한국어 2-3줄로 설명해줘:\n\n${diffStat}\n\n${diffSnippet}`;
|
|
118
|
+
const claudeResult = await run("claude", ["-p", prompt, "--output-format", "text"], {
|
|
119
|
+
cwd: wtPath,
|
|
120
|
+
timeoutMs: TIMEOUT_MS,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
if (claudeResult.code === 0 && claudeResult.stdout.trim()) {
|
|
124
|
+
return claudeResult.stdout.trim();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Fallback
|
|
129
|
+
return parseDiffStatSummary(diffStat);
|
|
130
|
+
}
|