sanjang 0.3.4 → 0.3.5
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 +25 -13
- package/dashboard/app.js +401 -27
- package/dashboard/index.html +16 -2
- package/dashboard/style.css +83 -5
- package/dist/bin/sanjang.js +8 -6
- package/dist/lib/config.js +3 -5
- package/dist/lib/engine/change-report.d.ts +27 -0
- package/dist/lib/engine/change-report.js +233 -0
- package/dist/lib/engine/diagnostics.js +2 -6
- package/dist/lib/engine/main-server.d.ts +15 -0
- package/dist/lib/engine/main-server.js +111 -0
- package/dist/lib/engine/naming.js +11 -2
- package/dist/lib/engine/pr.js +1 -1
- package/dist/lib/engine/process.js +4 -1
- package/dist/lib/engine/self-heal.js +16 -5
- package/dist/lib/engine/smart-init.js +7 -6
- package/dist/lib/engine/state.js +1 -1
- package/dist/lib/engine/suggest.js +1 -4
- package/dist/lib/engine/warp.d.ts +1 -1
- package/dist/lib/engine/warp.js +1 -1
- package/dist/lib/server.js +241 -49
- package/dist/lib/types.d.ts +19 -0
- package/package.json +2 -2
|
@@ -24,7 +24,10 @@ function detectPortFromStdout(logs, timeoutMs) {
|
|
|
24
24
|
const port = parseInt(match[1], 10);
|
|
25
25
|
// Wait briefly for the port to actually be ready
|
|
26
26
|
const sock = createConnection({ port, host: "localhost" });
|
|
27
|
-
sock.once("connect", () => {
|
|
27
|
+
sock.once("connect", () => {
|
|
28
|
+
sock.destroy();
|
|
29
|
+
resolve(port);
|
|
30
|
+
});
|
|
28
31
|
sock.once("error", () => {
|
|
29
32
|
sock.destroy();
|
|
30
33
|
// Port printed but not ready yet, retry
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, copyFileSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { join, dirname } from "node:path";
|
|
3
1
|
import { execSync } from "node:child_process";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
const PATTERNS = [
|
|
5
5
|
{
|
|
6
6
|
test: /Cannot find module|MODULE_NOT_FOUND/i,
|
|
@@ -64,7 +64,12 @@ export function executeHeal(action, campPath, projectRoot, setupCommand, copyFil
|
|
|
64
64
|
if (!setupCommand)
|
|
65
65
|
return { action, success: false, detail: "설치 명령이 없습니다." };
|
|
66
66
|
try {
|
|
67
|
-
execSync(setupCommand, {
|
|
67
|
+
execSync(setupCommand, {
|
|
68
|
+
cwd: campPath,
|
|
69
|
+
stdio: "pipe",
|
|
70
|
+
timeout: 120_000,
|
|
71
|
+
shell: true,
|
|
72
|
+
});
|
|
68
73
|
return { action, success: true };
|
|
69
74
|
}
|
|
70
75
|
catch {
|
|
@@ -82,10 +87,16 @@ export function executeHeal(action, campPath, projectRoot, setupCommand, copyFil
|
|
|
82
87
|
copyFileSync(src, dst);
|
|
83
88
|
copied++;
|
|
84
89
|
}
|
|
85
|
-
catch {
|
|
90
|
+
catch {
|
|
91
|
+
/* skip */
|
|
92
|
+
}
|
|
86
93
|
}
|
|
87
94
|
}
|
|
88
|
-
return {
|
|
95
|
+
return {
|
|
96
|
+
action,
|
|
97
|
+
success: copied > 0,
|
|
98
|
+
detail: copied > 0 ? `${copied}개 파일 복사됨` : "복사할 파일이 없습니다.",
|
|
99
|
+
};
|
|
89
100
|
}
|
|
90
101
|
case "restart":
|
|
91
102
|
// Restart is handled by the caller (stop + start)
|
|
@@ -32,7 +32,7 @@ export function deepFindEnvFiles(projectRoot, maxDepth = 4) {
|
|
|
32
32
|
export function detectSetupIssues(projectRoot) {
|
|
33
33
|
const issues = [];
|
|
34
34
|
// 1. Check for env variable references without .env files
|
|
35
|
-
const hasEnvFile = ENV_PATTERNS.some(f => existsSync(join(projectRoot, f)));
|
|
35
|
+
const hasEnvFile = ENV_PATTERNS.some((f) => existsSync(join(projectRoot, f)));
|
|
36
36
|
if (!hasEnvFile) {
|
|
37
37
|
const envRefFound = scanForEnvReferences(projectRoot);
|
|
38
38
|
if (envRefFound) {
|
|
@@ -52,7 +52,7 @@ export function detectSetupIssues(projectRoot) {
|
|
|
52
52
|
}
|
|
53
53
|
// 3. Check for missing lockfile
|
|
54
54
|
const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock"];
|
|
55
|
-
if (!lockfiles.some(f => existsSync(join(projectRoot, f)))) {
|
|
55
|
+
if (!lockfiles.some((f) => existsSync(join(projectRoot, f)))) {
|
|
56
56
|
issues.push({
|
|
57
57
|
type: "missing-lockfile",
|
|
58
58
|
message: "lockfile이 없습니다. 의존성 설치가 느릴 수 있습니다.",
|
|
@@ -97,7 +97,10 @@ function scanForEnvReferences(dir, depth = 0) {
|
|
|
97
97
|
continue;
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
if (entry.isDirectory() &&
|
|
100
|
+
if (entry.isDirectory() &&
|
|
101
|
+
!SKIP_DIRS.has(entry.name) &&
|
|
102
|
+
!entry.name.startsWith(".") &&
|
|
103
|
+
entry.name !== "node_modules") {
|
|
101
104
|
if (scanForEnvReferences(join(dir, entry.name), depth + 1))
|
|
102
105
|
return true;
|
|
103
106
|
}
|
|
@@ -129,9 +132,7 @@ function countTurboApps(root) {
|
|
|
129
132
|
if (scripts?.dev)
|
|
130
133
|
count++;
|
|
131
134
|
}
|
|
132
|
-
catch {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
+
catch { }
|
|
135
136
|
}
|
|
136
137
|
}
|
|
137
138
|
return count;
|
package/dist/lib/engine/state.js
CHANGED
|
@@ -29,7 +29,7 @@ function read() {
|
|
|
29
29
|
function write(records) {
|
|
30
30
|
ensureDir();
|
|
31
31
|
// Atomic write: write to temp file then rename to prevent corruption
|
|
32
|
-
const tmp = stateFile()
|
|
32
|
+
const tmp = `${stateFile()}.tmp`;
|
|
33
33
|
writeFileSync(tmp, JSON.stringify(records, null, 2), "utf8");
|
|
34
34
|
renameSync(tmp, stateFile());
|
|
35
35
|
}
|
|
@@ -97,10 +97,7 @@ async function fetchRecentCommits(cwd) {
|
|
|
97
97
|
export async function suggestTasks(projectRoot) {
|
|
98
98
|
const results = [];
|
|
99
99
|
// gh-dependent fetches — tolerate failure (gh not installed / no repo)
|
|
100
|
-
const [issues, prs] = await Promise.allSettled([
|
|
101
|
-
fetchIssues(projectRoot),
|
|
102
|
-
fetchMyPrs(projectRoot),
|
|
103
|
-
]);
|
|
100
|
+
const [issues, prs] = await Promise.allSettled([fetchIssues(projectRoot), fetchMyPrs(projectRoot)]);
|
|
104
101
|
// PRs first — most actionable ("이어하기")
|
|
105
102
|
if (prs.status === "fulfilled") {
|
|
106
103
|
results.push(...prs.value);
|
|
@@ -15,7 +15,7 @@ export declare function detectWarp(): WarpDetectResult;
|
|
|
15
15
|
* Opens as a new tab in the existing Warp window (not a new window).
|
|
16
16
|
* The tab title naturally shows the directory name (= camp name).
|
|
17
17
|
*/
|
|
18
|
-
export declare function openWarpTab(
|
|
18
|
+
export declare function openWarpTab(_campName: string, worktreePath: string): WarpOpenResult;
|
|
19
19
|
/**
|
|
20
20
|
* No-op cleanup (launch config removed — using open -a instead).
|
|
21
21
|
*/
|
package/dist/lib/engine/warp.js
CHANGED
|
@@ -12,7 +12,7 @@ export function detectWarp() {
|
|
|
12
12
|
* Opens as a new tab in the existing Warp window (not a new window).
|
|
13
13
|
* The tab title naturally shows the directory name (= camp name).
|
|
14
14
|
*/
|
|
15
|
-
export function openWarpTab(
|
|
15
|
+
export function openWarpTab(_campName, worktreePath) {
|
|
16
16
|
const { installed } = detectWarp();
|
|
17
17
|
if (!installed) {
|
|
18
18
|
return { opened: false, terminal: null, path: worktreePath };
|
package/dist/lib/server.js
CHANGED
|
@@ -7,20 +7,22 @@ import express from "express";
|
|
|
7
7
|
import { WebSocket, WebSocketServer } from "ws";
|
|
8
8
|
import { loadConfig } from "./config.js";
|
|
9
9
|
import { applyCacheToWorktree, buildCache, isCacheValid } from "./engine/cache.js";
|
|
10
|
+
import { buildChangeReport, generateReportSummary } from "./engine/change-report.js";
|
|
11
|
+
import { applyConfigFix, suggestConfigFix } from "./engine/config-hotfix.js";
|
|
10
12
|
import { buildConflictPrompt, parseConflictFiles } from "./engine/conflict.js";
|
|
11
13
|
import { buildDiagnostics } from "./engine/diagnostics.js";
|
|
14
|
+
import { getMainServerState, startMainServer, stopMainServer } from "./engine/main-server.js";
|
|
12
15
|
import { aiSlugify, slugify } from "./engine/naming.js";
|
|
13
16
|
import { allocate, scanPorts, setPortConfig } from "./engine/ports.js";
|
|
14
17
|
import { buildClaudePrPrompt, buildFallbackPrBody } from "./engine/pr.js";
|
|
15
18
|
import { getProcessInfo, setConfig, startCamp, stopAllCamps, stopCamp } from "./engine/process.js";
|
|
19
|
+
import { diagnoseFromLogs, executeHeal } from "./engine/self-heal.js";
|
|
20
|
+
import { generatePrDescription } from "./engine/smart-pr.js";
|
|
16
21
|
import { listSnapshots, restoreSnapshot, saveSnapshot } from "./engine/snapshot.js";
|
|
17
22
|
import { getAll, getOne, remove, setCampsDir, upsert } from "./engine/state.js";
|
|
23
|
+
import { suggestTasks } from "./engine/suggest.js";
|
|
18
24
|
import { detectWarp, openWarpTab, removeLaunchConfig } from "./engine/warp.js";
|
|
19
25
|
import { CampWatcher } from "./engine/watcher.js";
|
|
20
|
-
import { diagnoseFromLogs, executeHeal } from "./engine/self-heal.js";
|
|
21
|
-
import { suggestTasks } from "./engine/suggest.js";
|
|
22
|
-
import { generatePrDescription } from "./engine/smart-pr.js";
|
|
23
|
-
import { suggestConfigFix, applyConfigFix } from "./engine/config-hotfix.js";
|
|
24
26
|
import { addWorktree, campPath, getProjectRoot, listBranches, removeWorktree, setProjectRoot, } from "./engine/worktree.js";
|
|
25
27
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
28
|
// ---------------------------------------------------------------------------
|
|
@@ -192,9 +194,15 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
192
194
|
return;
|
|
193
195
|
try {
|
|
194
196
|
// Reattach if detached
|
|
195
|
-
const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
|
|
197
|
+
const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
|
|
198
|
+
encoding: "utf8",
|
|
199
|
+
stdio: "pipe",
|
|
200
|
+
});
|
|
196
201
|
if (headRef.status !== 0 && pg.branch) {
|
|
197
|
-
const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
|
|
202
|
+
const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
|
|
203
|
+
encoding: "utf8",
|
|
204
|
+
stdio: "pipe",
|
|
205
|
+
}).stdout?.trim();
|
|
198
206
|
if (cur) {
|
|
199
207
|
spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, cur], { stdio: "pipe" });
|
|
200
208
|
spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
|
|
@@ -202,10 +210,16 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
202
210
|
}
|
|
203
211
|
runGit(["-C", wtPath, "add", "-A"], wtPath);
|
|
204
212
|
runGit(["-C", wtPath, "commit", "-m", `[auto] ${files.length}개 파일 자동 세이브`], wtPath);
|
|
205
|
-
broadcast({
|
|
213
|
+
broadcast({
|
|
214
|
+
type: "playground-saved",
|
|
215
|
+
name,
|
|
216
|
+
data: { message: `[auto] ${files.length}개 파일 자동 세이브`, auto: true },
|
|
217
|
+
});
|
|
206
218
|
broadcast({ type: "autosaved", name });
|
|
207
219
|
}
|
|
208
|
-
catch {
|
|
220
|
+
catch {
|
|
221
|
+
/* ignore */
|
|
222
|
+
}
|
|
209
223
|
}, AUTOSAVE_DELAY));
|
|
210
224
|
}
|
|
211
225
|
function startWatcher(name) {
|
|
@@ -224,7 +238,9 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
224
238
|
if (autosaveEnabled.has(name))
|
|
225
239
|
resetAutosaveTimer(name);
|
|
226
240
|
}
|
|
227
|
-
catch {
|
|
241
|
+
catch {
|
|
242
|
+
/* ignore — worktree may be deleted */
|
|
243
|
+
}
|
|
228
244
|
}, 800);
|
|
229
245
|
watcher.start();
|
|
230
246
|
watchers.set(name, watcher);
|
|
@@ -267,7 +283,9 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
267
283
|
broadcast({ type: "browser-error", name: msg.name, data: msg.data });
|
|
268
284
|
}
|
|
269
285
|
}
|
|
270
|
-
catch {
|
|
286
|
+
catch {
|
|
287
|
+
/* ignore non-JSON */
|
|
288
|
+
}
|
|
271
289
|
});
|
|
272
290
|
});
|
|
273
291
|
// -------------------------------------------------------------------------
|
|
@@ -306,13 +324,21 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
306
324
|
}
|
|
307
325
|
// Only allow proxying to known camp ports (prevent SSRF to arbitrary local services)
|
|
308
326
|
const camps = getAll();
|
|
309
|
-
const camp = camps.find(c => c.fePort === targetPort);
|
|
310
|
-
|
|
327
|
+
const camp = camps.find((c) => c.fePort === targetPort);
|
|
328
|
+
const mainState = getMainServerState();
|
|
329
|
+
const isMainPort = mainState.status === "running" && mainState.port === targetPort;
|
|
330
|
+
if (!camp && !isMainPort) {
|
|
311
331
|
return res.status(403).send("이 포트는 활성 캠프가 아닙니다.");
|
|
312
332
|
}
|
|
313
|
-
const campName = camp
|
|
333
|
+
const campName = camp?.name ?? "__main__";
|
|
314
334
|
const targetPath = req.url || "/";
|
|
315
|
-
const proxyReq = httpRequest({
|
|
335
|
+
const proxyReq = httpRequest({
|
|
336
|
+
hostname: "127.0.0.1",
|
|
337
|
+
port: targetPort,
|
|
338
|
+
path: targetPath,
|
|
339
|
+
method: req.method,
|
|
340
|
+
headers: { ...req.headers, host: `localhost:${targetPort}` },
|
|
341
|
+
}, (proxyRes) => {
|
|
316
342
|
const contentType = proxyRes.headers["content-type"] || "";
|
|
317
343
|
const isHtml = contentType.includes("text/html");
|
|
318
344
|
if (!isHtml) {
|
|
@@ -326,8 +352,7 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
326
352
|
proxyRes.on("data", (chunk) => chunks.push(chunk));
|
|
327
353
|
proxyRes.on("end", () => {
|
|
328
354
|
let body = Buffer.concat(chunks).toString("utf8");
|
|
329
|
-
const script = INJECTED_SCRIPT
|
|
330
|
-
.replace("data-camp'", `data-camp'`) // placeholder
|
|
355
|
+
const script = INJECTED_SCRIPT.replace("data-camp'", `data-camp'`) // placeholder
|
|
331
356
|
.replace("data-sanjang-injected>", `data-sanjang-injected data-camp="${campName}">`);
|
|
332
357
|
// Replace _sp port placeholder with actual sanjang server port
|
|
333
358
|
const finalScript = script.replace("new URLSearchParams(location.search.slice(1)||'').get('_sp')||location.port", `'${port}'`);
|
|
@@ -349,8 +374,7 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
349
374
|
// Remove X-Frame-Options / frame-ancestors to allow iframe embedding
|
|
350
375
|
delete headers["x-frame-options"];
|
|
351
376
|
if (typeof headers["content-security-policy"] === "string") {
|
|
352
|
-
headers["content-security-policy"] = headers["content-security-policy"]
|
|
353
|
-
.replace(/frame-ancestors[^;]*(;|$)/gi, "");
|
|
377
|
+
headers["content-security-policy"] = headers["content-security-policy"].replace(/frame-ancestors[^;]*(;|$)/gi, "");
|
|
354
378
|
}
|
|
355
379
|
res.writeHead(proxyRes.statusCode ?? 200, headers);
|
|
356
380
|
res.end(body);
|
|
@@ -408,8 +432,18 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
408
432
|
copyCampFiles(projectRoot, wtPath, freshConfig.copyFiles, (msg) => {
|
|
409
433
|
broadcast({ type: "log", name, source: "sanjang", data: msg });
|
|
410
434
|
});
|
|
411
|
-
const baseCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() ||
|
|
412
|
-
|
|
435
|
+
const baseCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() ||
|
|
436
|
+
undefined;
|
|
437
|
+
const record = {
|
|
438
|
+
name,
|
|
439
|
+
branch,
|
|
440
|
+
slot,
|
|
441
|
+
fePort: actualFePort,
|
|
442
|
+
bePort,
|
|
443
|
+
status: "setting-up",
|
|
444
|
+
baseCommit,
|
|
445
|
+
parentBranch: branch,
|
|
446
|
+
};
|
|
413
447
|
upsert(record);
|
|
414
448
|
broadcast({ type: "playground-created", name, data: record });
|
|
415
449
|
res.status(201).json(record);
|
|
@@ -455,7 +489,7 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
455
489
|
const processInfo = getProcessInfo(name) ?? { feLogs: [], feExitCode: null };
|
|
456
490
|
// Self-heal: try to auto-fix before giving up
|
|
457
491
|
const healActions = diagnoseFromLogs(processInfo.feLogs);
|
|
458
|
-
const autoFixable = healActions.filter(a => a.auto);
|
|
492
|
+
const autoFixable = healActions.filter((a) => a.auto);
|
|
459
493
|
if (autoFixable.length > 0) {
|
|
460
494
|
broadcast({ type: "log", name, source: "sanjang", data: "문제를 발견했습니다. 자동으로 고치는 중..." });
|
|
461
495
|
const freshConfig = await loadConfig(projectRoot);
|
|
@@ -583,10 +617,16 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
583
617
|
return res.json({ saved: false, reason: "변경사항이 없습니다." });
|
|
584
618
|
try {
|
|
585
619
|
// Reattach to branch if in detached HEAD state
|
|
586
|
-
const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
|
|
620
|
+
const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
|
|
621
|
+
encoding: "utf8",
|
|
622
|
+
stdio: "pipe",
|
|
623
|
+
});
|
|
587
624
|
if (headRef.status !== 0 && pg.branch) {
|
|
588
625
|
// Detached HEAD — move branch pointer to current commit and checkout
|
|
589
|
-
const currentCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
|
|
626
|
+
const currentCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
|
|
627
|
+
encoding: "utf8",
|
|
628
|
+
stdio: "pipe",
|
|
629
|
+
}).stdout?.trim();
|
|
590
630
|
if (currentCommit) {
|
|
591
631
|
spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, currentCommit], { stdio: "pipe" });
|
|
592
632
|
spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
|
|
@@ -595,10 +635,16 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
595
635
|
// Stage all changes
|
|
596
636
|
runGit(["-C", wtPath, "add", "-A"], wtPath);
|
|
597
637
|
// Generate commit message with AI
|
|
598
|
-
const diff = spawnSync("git", ["-C", wtPath, "diff", "--cached", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout ||
|
|
638
|
+
const diff = spawnSync("git", ["-C", wtPath, "diff", "--cached", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout ||
|
|
639
|
+
"";
|
|
599
640
|
let message = `${files.length}개 파일 변경`;
|
|
600
641
|
try {
|
|
601
|
-
const aiResult = spawnSync("claude", [
|
|
642
|
+
const aiResult = spawnSync("claude", [
|
|
643
|
+
"-p",
|
|
644
|
+
"--model",
|
|
645
|
+
"haiku",
|
|
646
|
+
`이 git diff를 한국어 커밋 메시지로 작성해. 한 줄, 50자 이내, 설명 없이 메시지만:\n\n${diff.slice(0, 2000)}`,
|
|
647
|
+
], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
|
|
602
648
|
if (aiResult.status === 0 && aiResult.stdout?.trim()) {
|
|
603
649
|
message = aiResult.stdout.trim();
|
|
604
650
|
}
|
|
@@ -768,22 +814,95 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
768
814
|
res.status(500).json({ error: err.message });
|
|
769
815
|
}
|
|
770
816
|
});
|
|
771
|
-
// GET /api/playgrounds/:name/
|
|
772
|
-
app.get("/api/playgrounds/:name/
|
|
817
|
+
// GET /api/playgrounds/:name/change-report — 구조화된 변경 리포트 (changes-summary 대체)
|
|
818
|
+
app.get("/api/playgrounds/:name/change-report", async (req, res) => {
|
|
773
819
|
const { name } = req.params;
|
|
774
820
|
if (!getOne(name))
|
|
775
821
|
return res.status(404).json({ error: "not found" });
|
|
776
822
|
try {
|
|
777
823
|
const wtPath = campPath(name);
|
|
778
|
-
const
|
|
779
|
-
if (
|
|
780
|
-
return res.json({
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
824
|
+
const files = getChangedFiles(wtPath);
|
|
825
|
+
if (files.length === 0) {
|
|
826
|
+
return res.json({
|
|
827
|
+
files: [],
|
|
828
|
+
totalCount: 0,
|
|
829
|
+
byCategory: {},
|
|
830
|
+
warnings: [],
|
|
831
|
+
summary: null,
|
|
832
|
+
humanDescription: null,
|
|
833
|
+
categoryDetails: null,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
const validStatuses = new Set(["수정", "추가", "삭제", "새 파일"]);
|
|
837
|
+
const reportFiles = files.map((f) => ({
|
|
838
|
+
path: f.path,
|
|
839
|
+
status: (validStatuses.has(f.status) ? f.status : "수정"),
|
|
840
|
+
}));
|
|
841
|
+
let report = buildChangeReport(reportFiles);
|
|
842
|
+
// AI 요약은 ?ai=true 일 때만 (비용 절약)
|
|
843
|
+
if (req.query.ai === "true") {
|
|
844
|
+
const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat"], {
|
|
845
|
+
encoding: "utf8",
|
|
846
|
+
stdio: "pipe",
|
|
847
|
+
}).stdout || "";
|
|
848
|
+
const diff = spawnSync("git", ["-C", wtPath, "diff"], {
|
|
849
|
+
encoding: "utf8",
|
|
850
|
+
stdio: "pipe",
|
|
851
|
+
}).stdout || "";
|
|
852
|
+
report = generateReportSummary(diffStat, diff, report);
|
|
853
|
+
}
|
|
854
|
+
res.json(report);
|
|
784
855
|
}
|
|
785
|
-
catch {
|
|
786
|
-
res.json({
|
|
856
|
+
catch (err) {
|
|
857
|
+
res.status(500).json({ error: err.message });
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
// GET /api/playgrounds/:name/commit-report/:hash — 특정 커밋의 변경 리포트
|
|
861
|
+
app.get("/api/playgrounds/:name/commit-report/:hash", async (req, res) => {
|
|
862
|
+
const { name, hash } = req.params;
|
|
863
|
+
if (!getOne(name))
|
|
864
|
+
return res.status(404).json({ error: "not found" });
|
|
865
|
+
try {
|
|
866
|
+
const wtPath = campPath(name);
|
|
867
|
+
// 해당 커밋의 diff (부모 대비)
|
|
868
|
+
const diffNames = spawnSync("git", ["-C", wtPath, "diff", "--name-status", `${hash}~1..${hash}`], {
|
|
869
|
+
encoding: "utf8",
|
|
870
|
+
stdio: "pipe",
|
|
871
|
+
}).stdout?.trim() || "";
|
|
872
|
+
if (!diffNames)
|
|
873
|
+
return res.json({
|
|
874
|
+
files: [],
|
|
875
|
+
totalCount: 0,
|
|
876
|
+
byCategory: {},
|
|
877
|
+
warnings: [],
|
|
878
|
+
summary: null,
|
|
879
|
+
humanDescription: null,
|
|
880
|
+
categoryDetails: null,
|
|
881
|
+
});
|
|
882
|
+
const validStatuses = new Set(["수정", "추가", "삭제", "새 파일"]);
|
|
883
|
+
const statusMap = { M: "수정", A: "새 파일", D: "삭제" };
|
|
884
|
+
const parsedFiles = diffNames.split("\n").map((line) => {
|
|
885
|
+
const [st, ...pathParts] = line.split("\t");
|
|
886
|
+
const mapped = statusMap[st || ""] || "수정";
|
|
887
|
+
return {
|
|
888
|
+
path: pathParts.join("\t"),
|
|
889
|
+
status: (validStatuses.has(mapped) ? mapped : "수정"),
|
|
890
|
+
};
|
|
891
|
+
});
|
|
892
|
+
let report = buildChangeReport(parsedFiles);
|
|
893
|
+
if (req.query.ai === "true") {
|
|
894
|
+
const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat", `${hash}~1..${hash}`], {
|
|
895
|
+
encoding: "utf8",
|
|
896
|
+
stdio: "pipe",
|
|
897
|
+
}).stdout || "";
|
|
898
|
+
const diff = spawnSync("git", ["-C", wtPath, "diff", `${hash}~1..${hash}`], { encoding: "utf8", stdio: "pipe" })
|
|
899
|
+
.stdout || "";
|
|
900
|
+
report = generateReportSummary(diffStat, diff, report);
|
|
901
|
+
}
|
|
902
|
+
res.json(report);
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
res.status(500).json({ error: err.message });
|
|
787
906
|
}
|
|
788
907
|
});
|
|
789
908
|
app.post("/api/playgrounds/:name/ship", async (req, res) => {
|
|
@@ -797,9 +916,15 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
797
916
|
try {
|
|
798
917
|
const wtPath = campPath(name);
|
|
799
918
|
// 1. Reattach to branch if detached
|
|
800
|
-
const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
|
|
919
|
+
const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
|
|
920
|
+
encoding: "utf8",
|
|
921
|
+
stdio: "pipe",
|
|
922
|
+
});
|
|
801
923
|
if (headRef.status !== 0 && pg.branch) {
|
|
802
|
-
const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
|
|
924
|
+
const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
|
|
925
|
+
encoding: "utf8",
|
|
926
|
+
stdio: "pipe",
|
|
927
|
+
}).stdout?.trim();
|
|
803
928
|
if (cur) {
|
|
804
929
|
spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, cur], { stdio: "pipe" });
|
|
805
930
|
spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
|
|
@@ -818,8 +943,14 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
818
943
|
// 3. Check there are commits to ship (vs base)
|
|
819
944
|
const base = pg.baseCommit;
|
|
820
945
|
const logCheck = base
|
|
821
|
-
? spawnSync("git", ["-C", wtPath, "log", "--oneline", `${base}..HEAD`], {
|
|
822
|
-
|
|
946
|
+
? spawnSync("git", ["-C", wtPath, "log", "--oneline", `${base}..HEAD`], {
|
|
947
|
+
encoding: "utf8",
|
|
948
|
+
stdio: "pipe",
|
|
949
|
+
}).stdout?.trim()
|
|
950
|
+
: spawnSync("git", ["-C", wtPath, "log", "--oneline", "-1", "HEAD"], {
|
|
951
|
+
encoding: "utf8",
|
|
952
|
+
stdio: "pipe",
|
|
953
|
+
}).stdout?.trim();
|
|
823
954
|
if (!logCheck) {
|
|
824
955
|
return res.status(400).json({ error: "보낼 변경사항이 없습니다." });
|
|
825
956
|
}
|
|
@@ -851,6 +982,33 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
851
982
|
const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat", "HEAD~1"], { encoding: "utf8", stdio: "pipe" }).stdout ||
|
|
852
983
|
"";
|
|
853
984
|
const diff = spawnSync("git", ["-C", wtPath, "diff", "HEAD~1"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
|
|
985
|
+
// Change report warnings for PR body — based on full diff since camp creation
|
|
986
|
+
let reportWarnings = "";
|
|
987
|
+
try {
|
|
988
|
+
const base = pg.baseCommit;
|
|
989
|
+
if (base) {
|
|
990
|
+
const diffFiles = spawnSync("git", ["-C", wtPath, "diff", "--name-status", `${base}..HEAD`], {
|
|
991
|
+
encoding: "utf8",
|
|
992
|
+
stdio: "pipe",
|
|
993
|
+
}).stdout?.trim() || "";
|
|
994
|
+
if (diffFiles) {
|
|
995
|
+
const parsedFiles = diffFiles.split("\n").map((line) => {
|
|
996
|
+
const [st, ...pathParts] = line.split("\t");
|
|
997
|
+
return {
|
|
998
|
+
path: pathParts.join("\t"),
|
|
999
|
+
status: (st === "M" ? "수정" : st === "D" ? "삭제" : st === "A" ? "추가" : "수정"),
|
|
1000
|
+
};
|
|
1001
|
+
});
|
|
1002
|
+
const shipReport = buildChangeReport(parsedFiles);
|
|
1003
|
+
if (shipReport.warnings.length > 0) {
|
|
1004
|
+
reportWarnings = "\n\n### ⚠️ 주의사항\n" + shipReport.warnings.map((w) => `- ${w.message}`).join("\n");
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
catch {
|
|
1010
|
+
/* non-critical */
|
|
1011
|
+
}
|
|
854
1012
|
// Try Claude for rich PR body, fallback to simple
|
|
855
1013
|
const claudeCheck = spawnSync("which", ["claude"], { stdio: "pipe" });
|
|
856
1014
|
let prBody;
|
|
@@ -865,11 +1023,11 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
865
1023
|
});
|
|
866
1024
|
prBody =
|
|
867
1025
|
claudeResult.status === 0 && claudeResult.stdout?.trim()
|
|
868
|
-
? claudeResult.stdout.trim()
|
|
869
|
-
: buildFallbackPrBody({ message, actions, diffStat });
|
|
1026
|
+
? claudeResult.stdout.trim() + reportWarnings
|
|
1027
|
+
: buildFallbackPrBody({ message, actions, diffStat }) + reportWarnings;
|
|
870
1028
|
}
|
|
871
1029
|
else {
|
|
872
|
-
prBody = buildFallbackPrBody({ message, actions, diffStat });
|
|
1030
|
+
prBody = buildFallbackPrBody({ message, actions, diffStat }) + reportWarnings;
|
|
873
1031
|
}
|
|
874
1032
|
const prResult = spawnSync("gh", ["pr", "create", "--title", message, "--body", prBody, "--head", branchName], { cwd: wtPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
|
|
875
1033
|
if (prResult.status === 0) {
|
|
@@ -899,7 +1057,12 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
899
1057
|
if (claudeCheck.status !== 0) {
|
|
900
1058
|
return res.json({ passed: true, skipped: true, reason: "claude CLI 없음 — 테스트 생략" });
|
|
901
1059
|
}
|
|
902
|
-
const result = spawnSync("claude", [
|
|
1060
|
+
const result = spawnSync("claude", [
|
|
1061
|
+
"-p",
|
|
1062
|
+
"--model",
|
|
1063
|
+
"haiku",
|
|
1064
|
+
`이 프로젝트에서 PR 전에 돌려야 할 테스트 명령어를 찾아서 실행해줘. .github/workflows/ 디렉토리, package.json scripts, Makefile 등을 확인해. typecheck과 test를 우선 실행하고 결과를 알려줘. 명령어와 결과만 간단히.`,
|
|
1065
|
+
], { cwd: wtPath, encoding: "utf8", stdio: "pipe", timeout: 120_000 });
|
|
903
1066
|
const output = result.stdout?.trim() || "";
|
|
904
1067
|
const failed = result.status !== 0 || /fail|error|FAIL|ERROR/i.test(output);
|
|
905
1068
|
broadcast({ type: "log", name, source: "sanjang", data: failed ? "❌ 테스트 실패" : "✅ 테스트 통과" });
|
|
@@ -921,7 +1084,11 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
921
1084
|
return res.status(400).json({ error: "되돌릴 파일을 선택해주세요." });
|
|
922
1085
|
// Validate file paths against traversal and shell injection
|
|
923
1086
|
for (const file of files) {
|
|
924
|
-
if (typeof file !== "string" ||
|
|
1087
|
+
if (typeof file !== "string" ||
|
|
1088
|
+
file.includes("..") ||
|
|
1089
|
+
file.startsWith("/") ||
|
|
1090
|
+
file.startsWith("-") ||
|
|
1091
|
+
/[`$;"'\\|&]/.test(file)) {
|
|
925
1092
|
return res.status(400).json({ error: `invalid file path: ${file}` });
|
|
926
1093
|
}
|
|
927
1094
|
}
|
|
@@ -1131,7 +1298,7 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1131
1298
|
: ["-C", wtPath, "log", "--oneline", "--format=%h\t%s\t%cr", "--max-count=5", "HEAD"];
|
|
1132
1299
|
const log = spawnSync("git", logArgs, { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || "";
|
|
1133
1300
|
if (log) {
|
|
1134
|
-
commits = log.split("\n").map(line => {
|
|
1301
|
+
commits = log.split("\n").map((line) => {
|
|
1135
1302
|
const [hash = "", message = "", date = ""] = line.split("\t");
|
|
1136
1303
|
return { hash, message, date };
|
|
1137
1304
|
});
|
|
@@ -1277,7 +1444,8 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1277
1444
|
copyCampFiles(projectRoot, wtPath, freshConfig2.copyFiles, (msg) => {
|
|
1278
1445
|
broadcast({ type: "log", name, source: "sanjang", data: msg });
|
|
1279
1446
|
});
|
|
1280
|
-
const baseCommit2 = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() ||
|
|
1447
|
+
const baseCommit2 = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() ||
|
|
1448
|
+
undefined;
|
|
1281
1449
|
const record = {
|
|
1282
1450
|
name,
|
|
1283
1451
|
branch,
|
|
@@ -1353,7 +1521,7 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1353
1521
|
startWatcher(name);
|
|
1354
1522
|
return res.json({ fixed: true, description: fix.description });
|
|
1355
1523
|
}
|
|
1356
|
-
catch (
|
|
1524
|
+
catch (_retryErr) {
|
|
1357
1525
|
return res.json({ fixed: false, description: "설정을 고쳤지만 시작에 실패했습니다." });
|
|
1358
1526
|
}
|
|
1359
1527
|
}
|
|
@@ -1362,6 +1530,31 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1362
1530
|
});
|
|
1363
1531
|
let activityCache = null;
|
|
1364
1532
|
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
1533
|
+
// GET /api/compare/status — main 서버 상태
|
|
1534
|
+
app.get("/api/compare/status", (_req, res) => {
|
|
1535
|
+
res.json(getMainServerState());
|
|
1536
|
+
});
|
|
1537
|
+
// POST /api/compare/start — main 서버 시작
|
|
1538
|
+
app.post("/api/compare/start", async (_req, res) => {
|
|
1539
|
+
const mainState = getMainServerState();
|
|
1540
|
+
if (mainState.status === "running") {
|
|
1541
|
+
return res.json(mainState);
|
|
1542
|
+
}
|
|
1543
|
+
try {
|
|
1544
|
+
await startMainServer(projectRoot, config, (port) => {
|
|
1545
|
+
broadcast({ type: "compare-ready", data: { port } });
|
|
1546
|
+
});
|
|
1547
|
+
res.json(getMainServerState());
|
|
1548
|
+
}
|
|
1549
|
+
catch (err) {
|
|
1550
|
+
res.status(500).json({ error: err.message });
|
|
1551
|
+
}
|
|
1552
|
+
});
|
|
1553
|
+
// POST /api/compare/stop — main 서버 중지
|
|
1554
|
+
app.post("/api/compare/stop", (_req, res) => {
|
|
1555
|
+
stopMainServer();
|
|
1556
|
+
res.json({ ok: true });
|
|
1557
|
+
});
|
|
1365
1558
|
app.get("/api/activity", (_req, res) => {
|
|
1366
1559
|
if (activityCache && Date.now() - activityCache.ts < ACTIVITY_CACHE_TTL) {
|
|
1367
1560
|
return res.json(activityCache.data);
|
|
@@ -1413,8 +1606,6 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1413
1606
|
streak++;
|
|
1414
1607
|
}
|
|
1415
1608
|
else if (key === todayStr) {
|
|
1416
|
-
// Today having 0 commits is ok — streak can start from yesterday
|
|
1417
|
-
continue;
|
|
1418
1609
|
}
|
|
1419
1610
|
else {
|
|
1420
1611
|
break;
|
|
@@ -1456,6 +1647,7 @@ export async function startServer(projectRoot, options = {}) {
|
|
|
1456
1647
|
w.stop();
|
|
1457
1648
|
watchers.clear();
|
|
1458
1649
|
stopAllCamps();
|
|
1650
|
+
stopMainServer();
|
|
1459
1651
|
server.close(() => process.exit(0));
|
|
1460
1652
|
// Force exit after 10s if cleanup hangs
|
|
1461
1653
|
setTimeout(() => process.exit(1), 10_000);
|