sanjang 0.3.0 → 0.3.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.
Files changed (66) hide show
  1. package/dist/bin/sanjang.d.ts +1 -0
  2. package/dist/bin/sanjang.js +138 -0
  3. package/dist/lib/config.d.ts +19 -0
  4. package/dist/lib/config.js +318 -0
  5. package/dist/lib/engine/cache.d.ts +7 -0
  6. package/dist/lib/engine/cache.js +183 -0
  7. package/dist/lib/engine/config-hotfix.d.ts +7 -0
  8. package/dist/lib/engine/config-hotfix.js +129 -0
  9. package/dist/lib/engine/conflict.d.ts +12 -0
  10. package/dist/lib/engine/conflict.js +32 -0
  11. package/dist/lib/engine/diagnostics.d.ts +15 -0
  12. package/dist/lib/engine/diagnostics.js +58 -0
  13. package/dist/lib/engine/naming.d.ts +10 -0
  14. package/dist/lib/engine/naming.js +83 -0
  15. package/dist/lib/engine/ports.d.ts +9 -0
  16. package/dist/lib/engine/ports.js +55 -0
  17. package/dist/lib/engine/pr.d.ts +27 -0
  18. package/dist/lib/engine/pr.js +54 -0
  19. package/dist/lib/engine/process.d.ts +15 -0
  20. package/dist/lib/engine/process.js +250 -0
  21. package/dist/lib/engine/self-heal.d.ts +12 -0
  22. package/dist/lib/engine/self-heal.js +98 -0
  23. package/dist/lib/engine/smart-init.d.ts +7 -0
  24. package/dist/lib/engine/smart-init.js +138 -0
  25. package/dist/lib/engine/smart-pr.d.ts +19 -0
  26. package/dist/lib/engine/smart-pr.js +105 -0
  27. package/dist/lib/engine/snapshot.d.ts +10 -0
  28. package/dist/lib/engine/snapshot.js +35 -0
  29. package/dist/lib/engine/state.d.ts +7 -0
  30. package/dist/lib/engine/state.js +53 -0
  31. package/dist/lib/engine/suggest.d.ts +21 -0
  32. package/dist/lib/engine/suggest.js +121 -0
  33. package/dist/lib/engine/warp.d.ts +23 -0
  34. package/dist/lib/engine/warp.js +32 -0
  35. package/dist/lib/engine/watcher.d.ts +11 -0
  36. package/dist/lib/engine/watcher.js +43 -0
  37. package/dist/lib/engine/worktree.d.ts +13 -0
  38. package/dist/lib/engine/worktree.js +91 -0
  39. package/dist/lib/server.d.ts +20 -0
  40. package/dist/lib/server.js +1399 -0
  41. package/dist/lib/types.d.ts +109 -0
  42. package/dist/lib/types.js +2 -0
  43. package/package.json +5 -5
  44. package/bin/__tests__/sanjang.test.ts +0 -42
  45. package/bin/sanjang.js +0 -17
  46. package/bin/sanjang.ts +0 -144
  47. package/lib/config.ts +0 -337
  48. package/lib/engine/cache.ts +0 -218
  49. package/lib/engine/config-hotfix.ts +0 -161
  50. package/lib/engine/conflict.ts +0 -33
  51. package/lib/engine/diagnostics.ts +0 -81
  52. package/lib/engine/naming.ts +0 -93
  53. package/lib/engine/ports.ts +0 -61
  54. package/lib/engine/pr.ts +0 -71
  55. package/lib/engine/process.ts +0 -283
  56. package/lib/engine/self-heal.ts +0 -130
  57. package/lib/engine/smart-init.ts +0 -136
  58. package/lib/engine/smart-pr.ts +0 -130
  59. package/lib/engine/snapshot.ts +0 -45
  60. package/lib/engine/state.ts +0 -60
  61. package/lib/engine/suggest.ts +0 -169
  62. package/lib/engine/warp.ts +0 -47
  63. package/lib/engine/watcher.ts +0 -40
  64. package/lib/engine/worktree.ts +0 -100
  65. package/lib/server.ts +0 -1560
  66. package/lib/types.ts +0 -130
@@ -0,0 +1,1399 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { createServer, request as httpRequest } from "node:http";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import express from "express";
7
+ import { WebSocket, WebSocketServer } from "ws";
8
+ import { loadConfig } from "./config.js";
9
+ import { applyCacheToWorktree, buildCache, isCacheValid } from "./engine/cache.js";
10
+ import { buildConflictPrompt, parseConflictFiles } from "./engine/conflict.js";
11
+ import { buildDiagnostics } from "./engine/diagnostics.js";
12
+ import { aiSlugify, slugify } from "./engine/naming.js";
13
+ import { allocate, scanPorts, setPortConfig } from "./engine/ports.js";
14
+ import { buildClaudePrPrompt, buildFallbackPrBody } from "./engine/pr.js";
15
+ import { getProcessInfo, setConfig, startCamp, stopAllCamps, stopCamp } from "./engine/process.js";
16
+ import { listSnapshots, restoreSnapshot, saveSnapshot } from "./engine/snapshot.js";
17
+ import { getAll, getOne, remove, setCampsDir, upsert } from "./engine/state.js";
18
+ import { detectWarp, openWarpTab, removeLaunchConfig } from "./engine/warp.js";
19
+ 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
+ import { addWorktree, campPath, getProjectRoot, listBranches, removeWorktree, setProjectRoot, } from "./engine/worktree.js";
25
+ const __dirname = dirname(fileURLToPath(import.meta.url));
26
+ // ---------------------------------------------------------------------------
27
+ // Error translation
28
+ // ---------------------------------------------------------------------------
29
+ function runGit(args, cwd) {
30
+ const r = spawnSync("git", args, { cwd, stdio: "pipe", encoding: "utf8" });
31
+ if (r.status !== 0)
32
+ throw new Error(r.stderr?.trim() || `git ${args[0]} failed`);
33
+ return r.stdout || "";
34
+ }
35
+ function getChangedFiles(wtPath) {
36
+ const diff = (spawnSync("git", ["-C", wtPath, "diff", "--name-status"], { encoding: "utf8", stdio: "pipe" }).stdout || "").trim();
37
+ const untracked = (spawnSync("git", ["-C", wtPath, "ls-files", "--others", "--exclude-standard"], { encoding: "utf8", stdio: "pipe" })
38
+ .stdout || "").trim();
39
+ const files = [];
40
+ if (diff) {
41
+ for (const line of diff.split("\n")) {
42
+ const [status, ...pathParts] = line.split("\t");
43
+ files.push({
44
+ path: pathParts.join("\t"),
45
+ status: status === "M" ? "수정" : status === "D" ? "삭제" : status === "A" ? "추가" : status,
46
+ });
47
+ }
48
+ }
49
+ if (untracked) {
50
+ for (const p of untracked.split("\n"))
51
+ files.push({ path: p, status: "새 파일" });
52
+ }
53
+ return files;
54
+ }
55
+ function copyCampFiles(projectRoot, wtPath, copyFiles, onLog) {
56
+ if (!copyFiles?.length)
57
+ return;
58
+ for (const file of copyFiles) {
59
+ try {
60
+ copyFileSync(join(projectRoot, file), join(wtPath, file));
61
+ onLog?.(`${file} 복사 완료 ✓`);
62
+ }
63
+ catch {
64
+ onLog?.(`⚠️ ${file} 파일을 찾을 수 없습니다.`);
65
+ }
66
+ }
67
+ }
68
+ function friendlyError(err, branch) {
69
+ const msg = err?.message || String(err);
70
+ if (/invalid reference/.test(msg))
71
+ return `"${branch}" 브랜치를 찾을 수 없습니다.`;
72
+ if (/already exists/.test(msg))
73
+ return "이미 같은 이름의 캠프가 있습니다.";
74
+ if (/already checked out/.test(msg))
75
+ return "이 브랜치가 다른 곳에서 사용 중입니다.";
76
+ if (/No available port/.test(msg))
77
+ return "캠프를 더 만들 수 없습니다. 기존 캠프를 삭제해주세요.";
78
+ if (/cache copy failed/.test(msg))
79
+ return "설치 준비에 실패했습니다. 다시 시도해주세요.";
80
+ if (/setup failed/.test(msg))
81
+ return "의존성 설치에 실패했습니다. 다시 시도해주세요.";
82
+ if (/ENOSPC/.test(msg))
83
+ return "디스크 공간이 부족합니다.";
84
+ return msg;
85
+ }
86
+ function friendlyStartError(err) {
87
+ const msg = err?.message || String(err);
88
+ if (/Timeout|시작되지 않았|열리지/.test(msg))
89
+ return "시작하는 데 시간이 오래 걸리고 있습니다. 다시 시도해주세요.";
90
+ if (/ECONNREFUSED/.test(msg))
91
+ return "서버에 연결할 수 없습니다. 다시 시도해주세요.";
92
+ return msg;
93
+ }
94
+ function updateCampStatus(name, status, extra) {
95
+ const camp = getOne(name);
96
+ if (!camp)
97
+ return; // camp may have been deleted during async setup
98
+ upsert({ ...camp, status, ...extra });
99
+ }
100
+ function setupCampDeps(name, wtPath, cfg, broadcast) {
101
+ if (!cfg.setup) {
102
+ updateCampStatus(name, "stopped");
103
+ broadcast({ type: "playground-status", name, data: { status: "stopped" } });
104
+ return;
105
+ }
106
+ const setupCwd = cfg.dev?.cwd || ".";
107
+ const isBun = cfg.setup.includes("bun install");
108
+ // bun projects: skip cache (bun uses absolute symlinks that break in worktrees)
109
+ if (!isBun) {
110
+ const cacheResult = applyCacheToWorktree(getProjectRoot(), wtPath, setupCwd);
111
+ if (cacheResult.applied) {
112
+ broadcast({
113
+ type: "log",
114
+ name,
115
+ source: "sanjang",
116
+ data: `캐시에서 node_modules 복사 완료 ✓ (${cacheResult.duration}ms)`,
117
+ });
118
+ updateCampStatus(name, "stopped");
119
+ broadcast({ type: "playground-status", name, data: { status: "stopped" } });
120
+ return;
121
+ }
122
+ broadcast({ type: "log", name, source: "sanjang", data: `캐시 없음 (${cacheResult.reason}), 설치 중...` });
123
+ }
124
+ else {
125
+ broadcast({ type: "log", name, source: "sanjang", data: "bun install 실행 중... (bun은 캐시 대신 직접 설치)" });
126
+ }
127
+ const setupProc = spawn(cfg.setup, [], {
128
+ cwd: wtPath,
129
+ shell: true,
130
+ stdio: ["ignore", "pipe", "pipe"],
131
+ });
132
+ setupProc.stdout.on("data", (d) => {
133
+ broadcast({ type: "log", name, source: "setup", data: d.toString() });
134
+ });
135
+ setupProc.stderr.on("data", (d) => {
136
+ broadcast({ type: "log", name, source: "setup", data: d.toString() });
137
+ });
138
+ setupProc.on("close", (code) => {
139
+ if (code === 0) {
140
+ broadcast({ type: "log", name, source: "sanjang", data: "설치 완료 ✓" });
141
+ updateCampStatus(name, "stopped");
142
+ broadcast({ type: "playground-status", name, data: { status: "stopped" } });
143
+ }
144
+ else {
145
+ broadcast({ type: "log", name, source: "sanjang", data: `⚠️ 설치 실패 (코드 ${code})` });
146
+ updateCampStatus(name, "error");
147
+ broadcast({ type: "playground-status", name, data: { status: "error", error: "의존성 설치에 실패했습니다." } });
148
+ }
149
+ });
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ const MAX_CAMPS = 7;
153
+ // Initialize
154
+ // ---------------------------------------------------------------------------
155
+ export async function createApp(projectRoot, options = {}) {
156
+ const port = options.port ?? 4000;
157
+ // Initialize modules
158
+ setProjectRoot(projectRoot);
159
+ const campsDir = join(projectRoot, ".sanjang", "camps");
160
+ setCampsDir(campsDir);
161
+ const config = await loadConfig(projectRoot);
162
+ setConfig(config);
163
+ if (config.ports)
164
+ setPortConfig(config.ports);
165
+ // Reset stale running/starting statuses from previous sessions
166
+ for (const camp of getAll()) {
167
+ if (camp.status === "running" || camp.status === "starting") {
168
+ upsert({ ...camp, status: "stopped" });
169
+ }
170
+ }
171
+ // Warp detection (cached for this server instance)
172
+ const warpStatus = detectWarp();
173
+ // File watchers for running camps — push changes via WebSocket
174
+ const watchers = new Map();
175
+ const autosaveTimers = new Map();
176
+ const autosaveEnabled = new Set();
177
+ const AUTOSAVE_DELAY = 5 * 60 * 1000; // 5 minutes of inactivity
178
+ function resetAutosaveTimer(name) {
179
+ const existing = autosaveTimers.get(name);
180
+ if (existing)
181
+ clearTimeout(existing);
182
+ if (!autosaveEnabled.has(name))
183
+ return;
184
+ autosaveTimers.set(name, setTimeout(async () => {
185
+ autosaveTimers.delete(name);
186
+ const pg = getOne(name);
187
+ if (!pg)
188
+ return;
189
+ const wtPath = campPath(name);
190
+ const files = getChangedFiles(wtPath);
191
+ if (files.length === 0)
192
+ return;
193
+ try {
194
+ // Reattach if detached
195
+ const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], { encoding: "utf8", stdio: "pipe" });
196
+ if (headRef.status !== 0 && pg.branch) {
197
+ const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
198
+ if (cur) {
199
+ spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, cur], { stdio: "pipe" });
200
+ spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
201
+ }
202
+ }
203
+ runGit(["-C", wtPath, "add", "-A"], wtPath);
204
+ runGit(["-C", wtPath, "commit", "-m", `[auto] ${files.length}개 파일 자동 세이브`], wtPath);
205
+ broadcast({ type: "playground-saved", name, data: { message: `[auto] ${files.length}개 파일 자동 세이브`, auto: true } });
206
+ broadcast({ type: "autosaved", name });
207
+ }
208
+ catch { /* ignore */ }
209
+ }, AUTOSAVE_DELAY));
210
+ }
211
+ function startWatcher(name) {
212
+ if (watchers.has(name))
213
+ return;
214
+ const wtPath = campPath(name);
215
+ const watcher = new CampWatcher(wtPath, () => {
216
+ try {
217
+ const files = getChangedFiles(wtPath);
218
+ broadcast({
219
+ type: "file-changes",
220
+ name,
221
+ data: { count: files.length, files, ts: Date.now() },
222
+ });
223
+ // Reset autosave timer on any file change
224
+ if (autosaveEnabled.has(name))
225
+ resetAutosaveTimer(name);
226
+ }
227
+ catch { /* ignore — worktree may be deleted */ }
228
+ }, 800);
229
+ watcher.start();
230
+ watchers.set(name, watcher);
231
+ }
232
+ function stopWatcher(name) {
233
+ const w = watchers.get(name);
234
+ if (w) {
235
+ w.stop();
236
+ watchers.delete(name);
237
+ }
238
+ const timer = autosaveTimers.get(name);
239
+ if (timer) {
240
+ clearTimeout(timer);
241
+ autosaveTimers.delete(name);
242
+ }
243
+ }
244
+ // Express app
245
+ const app = express();
246
+ app.use(express.json());
247
+ // Dashboard path: works from both source (lib/) and dist (dist/lib/)
248
+ const dashboardDir = existsSync(join(__dirname, "..", "dashboard"))
249
+ ? join(__dirname, "..", "dashboard")
250
+ : join(__dirname, "..", "..", "dashboard");
251
+ app.use(express.static(dashboardDir));
252
+ const server = createServer(app);
253
+ const wss = new WebSocketServer({ server });
254
+ function broadcast(msg) {
255
+ const text = JSON.stringify(msg);
256
+ for (const client of wss.clients) {
257
+ if (client.readyState === WebSocket.OPEN)
258
+ client.send(text);
259
+ }
260
+ }
261
+ wss.on("connection", (ws) => {
262
+ ws.on("error", (err) => console.error("[ws] client error:", err.message));
263
+ ws.on("message", (raw) => {
264
+ try {
265
+ const msg = JSON.parse(raw.toString());
266
+ if (msg.type === "browser-error" && msg.name) {
267
+ broadcast({ type: "browser-error", name: msg.name, data: msg.data });
268
+ }
269
+ }
270
+ catch { /* ignore non-JSON */ }
271
+ });
272
+ });
273
+ // -------------------------------------------------------------------------
274
+ // Preview Proxy — /preview/:port/* proxies to localhost:port
275
+ // Injects a small script into HTML responses to capture browser errors
276
+ // -------------------------------------------------------------------------
277
+ const INJECTED_SCRIPT = `<script data-sanjang-injected>
278
+ (function(){
279
+ var ws=null,q=[];
280
+ function send(d){if(ws&&ws.readyState===1)ws.send(JSON.stringify(d));else q.push(d)}
281
+ function connect(){
282
+ ws=new WebSocket('ws://'+location.hostname+':'+new URLSearchParams(location.search.slice(1)||'').get('_sp')||location.port);
283
+ ws.onopen=function(){while(q.length)ws.send(JSON.stringify(q.shift()))};
284
+ ws.onclose=function(){setTimeout(connect,3000)};
285
+ }
286
+ var name=document.currentScript.getAttribute('data-camp');
287
+ window.addEventListener('error',function(e){
288
+ send({type:'browser-error',name:name,data:{level:'error',message:e.message,source:e.filename,line:e.lineno,col:e.colno}});
289
+ });
290
+ var origError=console.error;
291
+ console.error=function(){
292
+ var args=[].slice.call(arguments).map(function(a){try{return typeof a==='object'?JSON.stringify(a):String(a)}catch(e){return String(a)}});
293
+ send({type:'browser-error',name:name,data:{level:'console.error',message:args.join(' ')}});
294
+ origError.apply(console,arguments);
295
+ };
296
+ window.addEventListener('unhandledrejection',function(e){
297
+ send({type:'browser-error',name:name,data:{level:'promise',message:String(e.reason)}});
298
+ });
299
+ connect();
300
+ })();
301
+ </script>`;
302
+ app.use("/preview/:port", (req, res) => {
303
+ const targetPort = parseInt(req.params.port, 10);
304
+ if (!Number.isFinite(targetPort) || targetPort < 1000 || targetPort > 65535) {
305
+ return res.status(400).send("Invalid port");
306
+ }
307
+ // Find camp name by port
308
+ const camps = getAll();
309
+ const camp = camps.find(c => c.fePort === targetPort);
310
+ const campName = camp?.name ?? "unknown";
311
+ const targetPath = req.url || "/";
312
+ const proxyReq = httpRequest({ hostname: "127.0.0.1", port: targetPort, path: targetPath, method: req.method, headers: { ...req.headers, host: `localhost:${targetPort}` } }, (proxyRes) => {
313
+ const contentType = proxyRes.headers["content-type"] || "";
314
+ const isHtml = contentType.includes("text/html");
315
+ if (!isHtml) {
316
+ // Pass through non-HTML responses directly
317
+ res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
318
+ proxyRes.pipe(res);
319
+ return;
320
+ }
321
+ // Buffer HTML to inject script
322
+ const chunks = [];
323
+ proxyRes.on("data", (chunk) => chunks.push(chunk));
324
+ proxyRes.on("end", () => {
325
+ let body = Buffer.concat(chunks).toString("utf8");
326
+ const script = INJECTED_SCRIPT
327
+ .replace("data-camp'", `data-camp'`) // placeholder
328
+ .replace("data-sanjang-injected>", `data-sanjang-injected data-camp="${campName}">`);
329
+ // Replace _sp port placeholder with actual sanjang server port
330
+ const finalScript = script.replace("new URLSearchParams(location.search.slice(1)||'').get('_sp')||location.port", `'${port}'`);
331
+ // Inject before </head> or </body> or at end
332
+ if (body.includes("</head>")) {
333
+ body = body.replace("</head>", `${finalScript}</head>`);
334
+ }
335
+ else if (body.includes("</body>")) {
336
+ body = body.replace("</body>", `${finalScript}</body>`);
337
+ }
338
+ else {
339
+ body += finalScript;
340
+ }
341
+ // Remove content-length (body size changed) and content-encoding (we decoded it)
342
+ const headers = { ...proxyRes.headers };
343
+ delete headers["content-length"];
344
+ delete headers["content-encoding"];
345
+ delete headers["transfer-encoding"];
346
+ // Remove X-Frame-Options / frame-ancestors to allow iframe embedding
347
+ delete headers["x-frame-options"];
348
+ if (typeof headers["content-security-policy"] === "string") {
349
+ headers["content-security-policy"] = headers["content-security-policy"]
350
+ .replace(/frame-ancestors[^;]*(;|$)/gi, "");
351
+ }
352
+ res.writeHead(proxyRes.statusCode ?? 200, headers);
353
+ res.end(body);
354
+ });
355
+ });
356
+ proxyReq.on("error", () => {
357
+ res.status(502).send("프리뷰 서버에 연결할 수 없습니다.");
358
+ });
359
+ // Forward request body for POST etc
360
+ req.pipe(proxyReq);
361
+ });
362
+ // -------------------------------------------------------------------------
363
+ // REST API
364
+ // -------------------------------------------------------------------------
365
+ // Project info — used by dashboard header
366
+ const projectName = projectRoot.split("/").pop() ?? "project";
367
+ app.get("/api/project", (_req, res) => res.json({ name: projectName }));
368
+ app.get("/api/ports", (_req, res) => res.json(scanPorts()));
369
+ app.get("/api/branches", async (_req, res) => {
370
+ try {
371
+ res.json(await listBranches());
372
+ }
373
+ catch (err) {
374
+ res.status(500).json({ error: err.message });
375
+ }
376
+ });
377
+ app.get("/api/playgrounds", (_req, res) => res.json(getAll()));
378
+ app.post("/api/playgrounds", async (req, res) => {
379
+ const { name, branch } = req.body ?? {};
380
+ if (!name)
381
+ return res.status(400).json({ error: "name is required" });
382
+ if (!branch)
383
+ return res.status(400).json({ error: "branch is required" });
384
+ if (!/^[a-z0-9-]+$/.test(name))
385
+ return res.status(400).json({ error: "name must match /^[a-z0-9-]+$/" });
386
+ if (!/^[a-zA-Z0-9/_.-]+$/.test(branch))
387
+ return res.status(400).json({ error: "branch name contains invalid characters" });
388
+ const existing = getAll();
389
+ if (getOne(name))
390
+ return res.status(409).json({ error: `'${name}' 캠프가 이미 있습니다.` });
391
+ if (existing.length >= MAX_CAMPS)
392
+ return res.status(400).json({ error: `최대 ${MAX_CAMPS}개 캠프까지 가능합니다.` });
393
+ try {
394
+ // Reload config for each camp creation (hot reload)
395
+ const freshConfig = await loadConfig(projectRoot);
396
+ setConfig(freshConfig);
397
+ if (freshConfig.ports)
398
+ setPortConfig(freshConfig.ports);
399
+ const { slot, fePort, bePort } = allocate(existing);
400
+ // When portFlag is null, dev server uses its own fixed port
401
+ const actualFePort = freshConfig.dev?.portFlag ? fePort : freshConfig.dev?.port || fePort;
402
+ await addWorktree(name, branch);
403
+ const wtPath = campPath(name);
404
+ // Copy gitignored files first (sync, fast)
405
+ copyCampFiles(projectRoot, wtPath, freshConfig.copyFiles, (msg) => {
406
+ broadcast({ type: "log", name, source: "sanjang", data: msg });
407
+ });
408
+ const baseCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || undefined;
409
+ const record = { name, branch, slot, fePort: actualFePort, bePort, status: "setting-up", baseCommit, parentBranch: branch };
410
+ upsert(record);
411
+ broadcast({ type: "playground-created", name, data: record });
412
+ res.status(201).json(record);
413
+ setupCampDeps(name, wtPath, freshConfig, broadcast);
414
+ }
415
+ catch (err) {
416
+ // Clean up orphan worktree on failure
417
+ try {
418
+ await removeWorktree(name);
419
+ }
420
+ catch {
421
+ /* may not exist */
422
+ }
423
+ res.status(500).json({ error: friendlyError(err, branch) });
424
+ }
425
+ });
426
+ // Track in-flight start operations
427
+ const startingSet = new Set();
428
+ app.post("/api/playgrounds/:name/start", async (req, res) => {
429
+ const { name } = req.params;
430
+ const pg = getOne(name);
431
+ if (!pg)
432
+ return res.status(404).json({ error: "not found" });
433
+ if (startingSet.has(name))
434
+ return res.json({ status: "already-starting" });
435
+ startingSet.add(name);
436
+ upsert({ ...pg, status: "starting" });
437
+ broadcast({ type: "playground-status", name, data: { status: "starting" } });
438
+ res.json({ status: "starting" });
439
+ (async () => {
440
+ try {
441
+ const detectedPort = await startCamp(pg, (event) => {
442
+ broadcast({ type: event.type, name, data: event.data, source: event.source });
443
+ });
444
+ const url = `http://localhost:${detectedPort}`;
445
+ const updatedCamp = { ...getOne(name), status: "running", fePort: detectedPort, url };
446
+ upsert(updatedCamp);
447
+ broadcast({ type: "playground-status", name, data: { status: "running", url } });
448
+ startWatcher(name);
449
+ }
450
+ catch (err) {
451
+ const current = getOne(name) ?? pg;
452
+ const processInfo = getProcessInfo(name) ?? { feLogs: [], feExitCode: null };
453
+ // Self-heal: try to auto-fix before giving up
454
+ const healActions = diagnoseFromLogs(processInfo.feLogs);
455
+ const autoFixable = healActions.filter(a => a.auto);
456
+ if (autoFixable.length > 0) {
457
+ broadcast({ type: "log", name, source: "sanjang", data: "문제를 발견했습니다. 자동으로 고치는 중..." });
458
+ const freshConfig = await loadConfig(projectRoot);
459
+ const wtPath = campPath(name);
460
+ let healed = false;
461
+ for (const action of autoFixable) {
462
+ broadcast({ type: "log", name, source: "sanjang", data: ` → ${action.message}` });
463
+ const result = executeHeal(action, wtPath, projectRoot, freshConfig.setup, freshConfig.copyFiles);
464
+ if (result.success)
465
+ healed = true;
466
+ }
467
+ if (healed) {
468
+ broadcast({ type: "log", name, source: "sanjang", data: "수정 완료. 다시 시작합니다..." });
469
+ upsert({ ...current, status: "starting" });
470
+ broadcast({ type: "playground-status", name, data: { status: "starting" } });
471
+ try {
472
+ const retryPort = await startCamp(current, (event) => {
473
+ broadcast({ type: event.type, name, data: event.data, source: event.source });
474
+ });
475
+ const retryUrl = `http://localhost:${retryPort}`;
476
+ upsert({ ...getOne(name), status: "running", fePort: retryPort, url: retryUrl });
477
+ broadcast({ type: "playground-status", name, data: { status: "running", url: retryUrl } });
478
+ startWatcher(name);
479
+ broadcast({ type: "log", name, source: "sanjang", data: "자동 복구 성공 ✓" });
480
+ return; // healed successfully
481
+ }
482
+ catch {
483
+ // retry failed too, fall through to error
484
+ }
485
+ }
486
+ }
487
+ upsert({ ...current, status: "error" });
488
+ broadcast({ type: "playground-status", name, data: { status: "error", error: friendlyStartError(err) } });
489
+ const diagnostics = await buildDiagnostics(current, processInfo);
490
+ broadcast({ type: "playground-diagnostics", name, data: diagnostics });
491
+ }
492
+ finally {
493
+ startingSet.delete(name);
494
+ }
495
+ })();
496
+ });
497
+ app.post("/api/playgrounds/:name/stop", (req, res) => {
498
+ const { name } = req.params;
499
+ const pg = getOne(name);
500
+ if (!pg)
501
+ return res.status(404).json({ error: "not found" });
502
+ startingSet.delete(name);
503
+ stopCamp(name);
504
+ stopWatcher(name);
505
+ upsert({ ...pg, status: "stopped" });
506
+ broadcast({ type: "playground-status", name, data: { status: "stopped" } });
507
+ res.json({ status: "stopped" });
508
+ });
509
+ app.delete("/api/playgrounds/:name", async (req, res) => {
510
+ const { name } = req.params;
511
+ const pg = getOne(name);
512
+ if (!pg)
513
+ return res.status(404).json({ error: "not found" });
514
+ try {
515
+ startingSet.delete(name);
516
+ stopCamp(name);
517
+ stopWatcher(name);
518
+ await removeWorktree(name);
519
+ remove(name);
520
+ removeLaunchConfig(name);
521
+ broadcast({ type: "playground-deleted", name, data: null });
522
+ res.json({ deleted: true });
523
+ }
524
+ catch (err) {
525
+ remove(name);
526
+ removeLaunchConfig(name);
527
+ broadcast({ type: "playground-deleted", name, data: null });
528
+ res.json({ deleted: true, warning: err.message });
529
+ }
530
+ });
531
+ app.post("/api/playgrounds/:name/snapshot", async (req, res) => {
532
+ const { name } = req.params;
533
+ const { label } = req.body ?? {};
534
+ if (!getOne(name))
535
+ return res.status(404).json({ error: "not found" });
536
+ try {
537
+ await saveSnapshot(name, label ?? new Date().toISOString());
538
+ res.json({ saved: true });
539
+ }
540
+ catch (err) {
541
+ res.status(500).json({ error: err.message });
542
+ }
543
+ });
544
+ app.post("/api/playgrounds/:name/restore", async (req, res) => {
545
+ const { name } = req.params;
546
+ const { index } = req.body ?? {};
547
+ if (!getOne(name))
548
+ return res.status(404).json({ error: "not found" });
549
+ const idx = parseInt(index, 10);
550
+ if (!Number.isFinite(idx) || idx < 0)
551
+ return res.status(400).json({ error: "index must be a non-negative integer" });
552
+ try {
553
+ await restoreSnapshot(name, idx);
554
+ res.json({ restored: true });
555
+ }
556
+ catch (err) {
557
+ res.status(500).json({ error: err.message });
558
+ }
559
+ });
560
+ app.get("/api/playgrounds/:name/snapshots", async (req, res) => {
561
+ const { name } = req.params;
562
+ if (!getOne(name))
563
+ return res.status(404).json({ error: "not found" });
564
+ try {
565
+ res.json(await listSnapshots(name));
566
+ }
567
+ catch (err) {
568
+ res.status(500).json({ error: err.message });
569
+ }
570
+ });
571
+ // POST /api/playgrounds/:name/save — 💾 세이브 (auto git add + AI commit message + commit)
572
+ app.post("/api/playgrounds/:name/save", async (req, res) => {
573
+ const { name } = req.params;
574
+ const pg = getOne(name);
575
+ if (!pg)
576
+ return res.status(404).json({ error: "not found" });
577
+ const wtPath = campPath(name);
578
+ const files = getChangedFiles(wtPath);
579
+ if (files.length === 0)
580
+ return res.json({ saved: false, reason: "변경사항이 없습니다." });
581
+ try {
582
+ // Reattach to branch if in detached HEAD state
583
+ const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], { encoding: "utf8", stdio: "pipe" });
584
+ if (headRef.status !== 0 && pg.branch) {
585
+ // Detached HEAD — move branch pointer to current commit and checkout
586
+ const currentCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
587
+ if (currentCommit) {
588
+ spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, currentCommit], { stdio: "pipe" });
589
+ spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
590
+ }
591
+ }
592
+ // Stage all changes
593
+ runGit(["-C", wtPath, "add", "-A"], wtPath);
594
+ // Generate commit message with AI
595
+ const diff = spawnSync("git", ["-C", wtPath, "diff", "--cached", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
596
+ let message = `${files.length}개 파일 변경`;
597
+ try {
598
+ const aiResult = spawnSync("claude", ["-p", "--model", "haiku", `이 git diff를 한국어 커밋 메시지로 작성해. 한 줄, 50자 이내, 설명 없이 메시지만:\n\n${diff.slice(0, 2000)}`], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
599
+ if (aiResult.status === 0 && aiResult.stdout?.trim()) {
600
+ message = aiResult.stdout.trim();
601
+ }
602
+ }
603
+ catch {
604
+ /* fallback to default message */
605
+ }
606
+ // Commit
607
+ runGit(["-C", wtPath, "commit", "-m", message], wtPath);
608
+ broadcast({ type: "playground-saved", name, data: { message } });
609
+ res.json({ saved: true, message });
610
+ }
611
+ catch (err) {
612
+ res.status(500).json({ error: err.message });
613
+ }
614
+ });
615
+ // POST /api/playgrounds/:name/autosave — 오토세이브 토글
616
+ app.post("/api/playgrounds/:name/autosave", (req, res) => {
617
+ const { name } = req.params;
618
+ const { enabled } = req.body ?? {};
619
+ if (!getOne(name))
620
+ return res.status(404).json({ error: "not found" });
621
+ if (enabled) {
622
+ autosaveEnabled.add(name);
623
+ resetAutosaveTimer(name);
624
+ }
625
+ else {
626
+ autosaveEnabled.delete(name);
627
+ const timer = autosaveTimers.get(name);
628
+ if (timer) {
629
+ clearTimeout(timer);
630
+ autosaveTimers.delete(name);
631
+ }
632
+ }
633
+ res.json({ autosave: autosaveEnabled.has(name) });
634
+ });
635
+ app.get("/api/playgrounds/:name/autosave", (req, res) => {
636
+ res.json({ autosave: autosaveEnabled.has(req.params.name) });
637
+ });
638
+ app.post("/api/playgrounds/:name/reset", async (req, res) => {
639
+ const { name } = req.params;
640
+ const pg = getOne(name);
641
+ if (!pg)
642
+ return res.status(404).json({ error: "not found" });
643
+ try {
644
+ const wtPath = campPath(name);
645
+ runGit(["-C", wtPath, "fetch", "origin"], wtPath);
646
+ runGit(["-C", wtPath, "reset", "--hard", `origin/${pg.branch}`], wtPath);
647
+ runGit(["-C", wtPath, "clean", "-fd"], wtPath);
648
+ // Reload config to avoid stale references
649
+ const freshConfig = await loadConfig(projectRoot);
650
+ // Re-copy gitignored files (git clean deletes them)
651
+ copyCampFiles(projectRoot, wtPath, freshConfig.copyFiles);
652
+ // Re-apply cached node_modules (git clean deletes them)
653
+ if (freshConfig.setup) {
654
+ const setupCwd = freshConfig.dev?.cwd || ".";
655
+ const cacheResult = applyCacheToWorktree(projectRoot, wtPath, setupCwd);
656
+ if (cacheResult.applied) {
657
+ broadcast({
658
+ type: "log",
659
+ name,
660
+ source: "sanjang",
661
+ data: `캐시에서 node_modules 복원 ✓ (${cacheResult.duration}ms)`,
662
+ });
663
+ }
664
+ }
665
+ writeActions(name, []);
666
+ broadcast({ type: "playground-reset", name, data: { branch: pg.branch } });
667
+ res.json({ reset: true });
668
+ }
669
+ catch (err) {
670
+ res.status(500).json({ error: err.message });
671
+ }
672
+ });
673
+ app.get("/api/playgrounds/:name/diagnostics", async (req, res) => {
674
+ const { name } = req.params;
675
+ const pg = getOne(name);
676
+ if (!pg)
677
+ return res.status(404).json({ error: "not found" });
678
+ const processInfo = getProcessInfo(name) ?? { feLogs: [], feExitCode: null };
679
+ try {
680
+ res.json(await buildDiagnostics(pg, processInfo));
681
+ }
682
+ catch (err) {
683
+ res.status(500).json({ error: err.message });
684
+ }
685
+ });
686
+ // -------------------------------------------------------------------------
687
+ // Cache management
688
+ // -------------------------------------------------------------------------
689
+ app.get("/api/cache/status", (_req, res) => {
690
+ const setupCwd = config.dev?.cwd || ".";
691
+ res.json(isCacheValid(projectRoot, setupCwd));
692
+ });
693
+ app.post("/api/cache/rebuild", async (_req, res) => {
694
+ try {
695
+ broadcast({ type: "cache-rebuild", data: { status: "building" } });
696
+ const result = await buildCache(projectRoot, config, (msg) => {
697
+ broadcast({ type: "log", name: "_cache", source: "sanjang", data: msg });
698
+ });
699
+ if (result.success) {
700
+ broadcast({ type: "cache-rebuild", data: { status: "done", duration: result.duration } });
701
+ res.json({ success: true, duration: result.duration });
702
+ }
703
+ else {
704
+ broadcast({ type: "cache-rebuild", data: { status: "error", error: result.error } });
705
+ res.status(500).json({ error: result.error });
706
+ }
707
+ }
708
+ catch (err) {
709
+ res.status(500).json({ error: err.message });
710
+ }
711
+ });
712
+ // -------------------------------------------------------------------------
713
+ // Action log + Ship + Revert + Sync
714
+ // -------------------------------------------------------------------------
715
+ function actionsFile(name) {
716
+ return join(campPath(name), "actions.json");
717
+ }
718
+ function readActions(name) {
719
+ try {
720
+ return JSON.parse(readFileSync(actionsFile(name), "utf8"));
721
+ }
722
+ catch {
723
+ return [];
724
+ }
725
+ }
726
+ function writeActions(name, actions) {
727
+ try {
728
+ writeFileSync(actionsFile(name), JSON.stringify(actions, null, 2));
729
+ }
730
+ catch {
731
+ /* worktree may not exist yet */
732
+ }
733
+ }
734
+ app.post("/api/playgrounds/:name/log-action", (req, res) => {
735
+ const { name } = req.params;
736
+ const { description, files } = req.body ?? {};
737
+ if (!description)
738
+ return res.status(400).json({ error: "description required" });
739
+ const actions = readActions(name);
740
+ actions.push({ description, files: files || [], timestamp: new Date().toISOString() });
741
+ writeActions(name, actions);
742
+ broadcast({ type: "playground-action", name, data: { description } });
743
+ res.json({ logged: true });
744
+ });
745
+ app.post("/api/playgrounds/:name/remove-action", (req, res) => {
746
+ const { name } = req.params;
747
+ const { index } = req.body ?? {};
748
+ const actions = readActions(name);
749
+ if (index >= 0 && index < actions.length) {
750
+ actions.splice(index, 1);
751
+ writeActions(name, actions);
752
+ }
753
+ res.json({ removed: true });
754
+ });
755
+ app.get("/api/playgrounds/:name/changes", (req, res) => {
756
+ const { name } = req.params;
757
+ if (!getOne(name))
758
+ return res.status(404).json({ error: "not found" });
759
+ try {
760
+ const wtPath = campPath(name);
761
+ const files = getChangedFiles(wtPath);
762
+ res.json({ count: files.length, files, actions: readActions(name) });
763
+ }
764
+ catch (err) {
765
+ res.status(500).json({ error: err.message });
766
+ }
767
+ });
768
+ // GET /api/playgrounds/:name/changes-summary — AI 한 줄 변경 요약
769
+ app.get("/api/playgrounds/:name/changes-summary", async (req, res) => {
770
+ const { name } = req.params;
771
+ if (!getOne(name))
772
+ return res.status(404).json({ error: "not found" });
773
+ try {
774
+ const wtPath = campPath(name);
775
+ const diff = spawnSync("git", ["-C", wtPath, "diff", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
776
+ if (!diff.trim())
777
+ return res.json({ summary: null });
778
+ const result = spawnSync("claude", ["-p", "--model", "haiku", `이 git diff를 한국어 한 줄(20자 이내)로 요약해. 설명 없이 요약만:\n\n${diff.slice(0, 2000)}`], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
779
+ const summary = result.status === 0 ? (result.stdout ?? "").trim() : null;
780
+ res.json({ summary });
781
+ }
782
+ catch {
783
+ res.json({ summary: null });
784
+ }
785
+ });
786
+ app.post("/api/playgrounds/:name/ship", async (req, res) => {
787
+ const { name } = req.params;
788
+ const { message } = req.body ?? {};
789
+ const pg = getOne(name);
790
+ if (!pg)
791
+ return res.status(404).json({ error: "not found" });
792
+ if (!message)
793
+ return res.status(400).json({ error: "변경 내용을 한 줄로 설명해주세요." });
794
+ try {
795
+ const wtPath = campPath(name);
796
+ // 1. Reattach to branch if detached
797
+ const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], { encoding: "utf8", stdio: "pipe" });
798
+ if (headRef.status !== 0 && pg.branch) {
799
+ const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
800
+ if (cur) {
801
+ spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, cur], { stdio: "pipe" });
802
+ spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
803
+ }
804
+ }
805
+ // 2. Auto-save any uncommitted changes
806
+ const unsaved = getChangedFiles(wtPath);
807
+ if (unsaved.length > 0) {
808
+ spawnSync("git", ["-C", wtPath, "add", "-A"], { stdio: "pipe" });
809
+ spawnSync("git", ["-C", wtPath, "reset", "HEAD", "actions.json"], { stdio: "pipe" });
810
+ const statusCheck = spawnSync("git", ["-C", wtPath, "diff", "--cached", "--quiet"], { stdio: "pipe" });
811
+ if (statusCheck.status !== 0) {
812
+ spawnSync("git", ["-C", wtPath, "commit", "-m", message], { stdio: "pipe" });
813
+ }
814
+ }
815
+ // 3. Check there are commits to ship (vs base)
816
+ const base = pg.baseCommit;
817
+ const logCheck = base
818
+ ? spawnSync("git", ["-C", wtPath, "log", "--oneline", `${base}..HEAD`], { encoding: "utf8", stdio: "pipe" }).stdout?.trim()
819
+ : spawnSync("git", ["-C", wtPath, "log", "--oneline", "-1", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
820
+ if (!logCheck) {
821
+ return res.status(400).json({ error: "보낼 변경사항이 없습니다." });
822
+ }
823
+ // 4. Squash commits since base into one clean commit
824
+ if (base) {
825
+ spawnSync("git", ["-C", wtPath, "reset", "--soft", base], { stdio: "pipe" });
826
+ spawnSync("git", ["-C", wtPath, "reset", "HEAD", "actions.json"], { stdio: "pipe" });
827
+ const squashResult = spawnSync("git", ["-C", wtPath, "commit", "-m", message], { stdio: "pipe" });
828
+ if (squashResult.status !== 0)
829
+ throw new Error(squashResult.stderr?.toString() || "squash commit failed");
830
+ }
831
+ // 5. Push camp branch
832
+ const branchName = pg.branch;
833
+ const pushResult = spawnSync("git", ["-C", wtPath, "push", "-u", "--force-with-lease", "origin", `HEAD:${branchName}`], { stdio: "pipe" });
834
+ if (pushResult.status !== 0)
835
+ throw new Error(pushResult.stderr?.toString() || "push failed");
836
+ // Read actions before clearing (used for fallback PR body)
837
+ const actions = readActions(name);
838
+ writeActions(name, []);
839
+ broadcast({ type: "playground-shipped", name, data: { branch: branchName } });
840
+ // Respond immediately after push
841
+ res.json({ shipped: true, branch: branchName });
842
+ // Background: create PR via gh CLI
843
+ setImmediate(async () => {
844
+ try {
845
+ const ghCheck = spawnSync("which", ["gh"], { stdio: "pipe" });
846
+ if (ghCheck.status !== 0)
847
+ return; // gh not installed, skip
848
+ const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat", "HEAD~1"], { encoding: "utf8", stdio: "pipe" }).stdout ||
849
+ "";
850
+ const diff = spawnSync("git", ["-C", wtPath, "diff", "HEAD~1"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
851
+ // Try Claude for rich PR body, fallback to simple
852
+ const claudeCheck = spawnSync("which", ["claude"], { stdio: "pipe" });
853
+ let prBody;
854
+ if (claudeCheck.status === 0) {
855
+ const prompt = buildClaudePrPrompt({ message, diffStat, diff });
856
+ const claudeResult = spawnSync("claude", ["-p", prompt, "--output-format", "text"], {
857
+ cwd: wtPath,
858
+ encoding: "utf8",
859
+ stdio: ["pipe", "pipe", "pipe"],
860
+ timeout: 30_000,
861
+ env: { ...process.env, FORCE_COLOR: "0" },
862
+ });
863
+ prBody =
864
+ claudeResult.status === 0 && claudeResult.stdout?.trim()
865
+ ? claudeResult.stdout.trim()
866
+ : buildFallbackPrBody({ message, actions, diffStat });
867
+ }
868
+ else {
869
+ prBody = buildFallbackPrBody({ message, actions, diffStat });
870
+ }
871
+ const prResult = spawnSync("gh", ["pr", "create", "--title", message, "--body", prBody, "--head", branchName], { cwd: wtPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
872
+ if (prResult.status === 0) {
873
+ const prUrl = prResult.stdout.trim();
874
+ broadcast({ type: "playground-pr-created", name, data: { prUrl, branch: branchName } });
875
+ }
876
+ }
877
+ catch {
878
+ // Background task — swallow errors silently
879
+ }
880
+ });
881
+ }
882
+ catch (err) {
883
+ res.status(500).json({ error: err.message });
884
+ }
885
+ });
886
+ // POST /api/playgrounds/:name/pre-ship — AI가 테스트 판단 + 실행
887
+ app.post("/api/playgrounds/:name/pre-ship", async (req, res) => {
888
+ const { name } = req.params;
889
+ const pg = getOne(name);
890
+ if (!pg)
891
+ return res.status(404).json({ error: "not found" });
892
+ const wtPath = campPath(name);
893
+ broadcast({ type: "log", name, source: "sanjang", data: "🧪 테스트 확인 중..." });
894
+ try {
895
+ const claudeCheck = spawnSync("which", ["claude"], { stdio: "pipe" });
896
+ if (claudeCheck.status !== 0) {
897
+ return res.json({ passed: true, skipped: true, reason: "claude CLI 없음 — 테스트 생략" });
898
+ }
899
+ const result = spawnSync("claude", ["-p", "--model", "haiku", `이 프로젝트에서 PR 전에 돌려야 할 테스트 명령어를 찾아서 실행해줘. .github/workflows/ 디렉토리, package.json scripts, Makefile 등을 확인해. typecheck과 test를 우선 실행하고 결과를 알려줘. 명령어와 결과만 간단히.`], { cwd: wtPath, encoding: "utf8", stdio: "pipe", timeout: 120_000 });
900
+ const output = result.stdout?.trim() || "";
901
+ const failed = result.status !== 0 || /fail|error|FAIL|ERROR/i.test(output);
902
+ broadcast({ type: "log", name, source: "sanjang", data: failed ? "❌ 테스트 실패" : "✅ 테스트 통과" });
903
+ res.json({
904
+ passed: !failed,
905
+ output: output.slice(0, 3000),
906
+ });
907
+ }
908
+ catch (err) {
909
+ res.json({ passed: false, output: err.message });
910
+ }
911
+ });
912
+ app.post("/api/playgrounds/:name/revert-files", (req, res) => {
913
+ const { name } = req.params;
914
+ const { files } = req.body ?? {};
915
+ if (!getOne(name))
916
+ return res.status(404).json({ error: "not found" });
917
+ if (!files?.length)
918
+ return res.status(400).json({ error: "되돌릴 파일을 선택해주세요." });
919
+ // Validate file paths against traversal and shell injection
920
+ for (const file of files) {
921
+ if (typeof file !== "string" || file.includes("..") || file.startsWith("/") || /[`$;"'\\|&]/.test(file)) {
922
+ return res.status(400).json({ error: `invalid file path: ${file}` });
923
+ }
924
+ }
925
+ try {
926
+ const wtPath = campPath(name);
927
+ for (const file of files) {
928
+ const fullPath = resolve(join(wtPath, file));
929
+ if (!fullPath.startsWith(wtPath)) {
930
+ continue; // path traversal attempt
931
+ }
932
+ const result = spawnSync("git", ["-C", wtPath, "checkout", "--", file], { stdio: "pipe" });
933
+ if (result.status !== 0) {
934
+ try {
935
+ unlinkSync(fullPath);
936
+ }
937
+ catch {
938
+ /* file may not exist */
939
+ }
940
+ }
941
+ }
942
+ res.json({ reverted: files.length });
943
+ }
944
+ catch (err) {
945
+ res.status(500).json({ error: err.message });
946
+ }
947
+ });
948
+ // POST /api/playgrounds/:name/revert-commit — 세이브(커밋) 되돌리기
949
+ app.post("/api/playgrounds/:name/revert-commit", (req, res) => {
950
+ const { name } = req.params;
951
+ const { hash } = req.body ?? {};
952
+ const pg = getOne(name);
953
+ if (!pg)
954
+ return res.status(404).json({ error: "not found" });
955
+ if (!hash || typeof hash !== "string" || !/^[a-f0-9]+$/.test(hash)) {
956
+ return res.status(400).json({ error: "유효하지 않은 커밋 해시입니다." });
957
+ }
958
+ try {
959
+ const wtPath = campPath(name);
960
+ const result = spawnSync("git", ["-C", wtPath, "revert", "--no-commit", hash], {
961
+ encoding: "utf8",
962
+ stdio: "pipe",
963
+ });
964
+ if (result.status !== 0) {
965
+ // Abort revert on failure
966
+ spawnSync("git", ["-C", wtPath, "revert", "--abort"], { stdio: "pipe" });
967
+ const stderr = result.stderr?.trim() || "";
968
+ if (stderr.includes("CONFLICT")) {
969
+ return res.status(409).json({ error: "되돌리기 중 충돌이 발생했습니다. 수동으로 해결해주세요." });
970
+ }
971
+ return res.status(500).json({ error: stderr || "되돌리기에 실패했습니다." });
972
+ }
973
+ // Auto-commit the revert
974
+ spawnSync("git", ["-C", wtPath, "commit", "-m", `되돌리기: ${hash.slice(0, 7)}`], { stdio: "pipe" });
975
+ broadcast({ type: "playground-saved", name, data: { message: `되돌리기: ${hash.slice(0, 7)}` } });
976
+ res.json({ reverted: true });
977
+ }
978
+ catch (err) {
979
+ res.status(500).json({ error: err.message });
980
+ }
981
+ });
982
+ // Shared state (used by conflict resolver)
983
+ const runningTasks = new Map();
984
+ app.post("/api/playgrounds/:name/sync", (req, res) => {
985
+ const { name } = req.params;
986
+ const pg = getOne(name);
987
+ if (!pg)
988
+ return res.status(404).json({ error: "not found" });
989
+ try {
990
+ const wtPath = campPath(name);
991
+ runGit(["-C", wtPath, "fetch", "origin"], wtPath);
992
+ const mergeResult = spawnSync("git", ["-C", wtPath, "merge", `origin/${pg.branch}`, "--no-edit"], {
993
+ encoding: "utf8",
994
+ stdio: ["pipe", "pipe", "pipe"],
995
+ });
996
+ const result = ((mergeResult.stdout || "") + (mergeResult.stderr || "")).trim();
997
+ if (result.includes("CONFLICT") || (mergeResult.status !== 0 && result.includes("CONFLICT"))) {
998
+ // Don't abort — leave merge state so user can resolve
999
+ const statusOut = spawnSync("git", ["-C", wtPath, "status", "--porcelain"], {
1000
+ encoding: "utf8",
1001
+ stdio: "pipe",
1002
+ }).stdout || "";
1003
+ const conflictFiles = parseConflictFiles(statusOut);
1004
+ res.json({ synced: false, conflict: true, conflictFiles, message: "충돌이 있습니다. 어떻게 할까요?" });
1005
+ }
1006
+ else {
1007
+ broadcast({ type: "playground-synced", name });
1008
+ res.json({ synced: true, message: "최신 버전이 반영되었습니다." });
1009
+ }
1010
+ }
1011
+ catch (err) {
1012
+ const msg = err.message || "";
1013
+ if (msg.includes("CONFLICT")) {
1014
+ const statusOut = spawnSync("git", ["-C", campPath(name), "status", "--porcelain"], {
1015
+ encoding: "utf8",
1016
+ stdio: "pipe",
1017
+ }).stdout || "";
1018
+ const conflictFiles = parseConflictFiles(statusOut);
1019
+ res.json({ synced: false, conflict: true, conflictFiles, message: "충돌이 있습니다. 어떻게 할까요?" });
1020
+ }
1021
+ else {
1022
+ res.status(500).json({ error: err.message });
1023
+ }
1024
+ }
1025
+ });
1026
+ // POST /api/playgrounds/:name/resolve-conflict
1027
+ // body: { strategy: 'claude' | 'ours' | 'theirs' }
1028
+ app.post("/api/playgrounds/:name/resolve-conflict", async (req, res) => {
1029
+ const { name } = req.params;
1030
+ const { strategy } = req.body ?? {};
1031
+ const pg = getOne(name);
1032
+ if (!pg)
1033
+ return res.status(404).json({ error: "not found" });
1034
+ const wtPath = campPath(name);
1035
+ if (strategy === "ours") {
1036
+ spawnSync("git", ["-C", wtPath, "checkout", "--ours", "."], { stdio: "pipe" });
1037
+ spawnSync("git", ["-C", wtPath, "add", "."], { stdio: "pipe" });
1038
+ spawnSync("git", ["-C", wtPath, "commit", "--no-edit"], { stdio: "pipe" });
1039
+ return res.json({ resolved: true, strategy: "ours" });
1040
+ }
1041
+ if (strategy === "theirs") {
1042
+ spawnSync("git", ["-C", wtPath, "checkout", "--theirs", "."], { stdio: "pipe" });
1043
+ spawnSync("git", ["-C", wtPath, "add", "."], { stdio: "pipe" });
1044
+ spawnSync("git", ["-C", wtPath, "commit", "--no-edit"], { stdio: "pipe" });
1045
+ return res.json({ resolved: true, strategy: "theirs" });
1046
+ }
1047
+ if (strategy === "claude") {
1048
+ if (runningTasks.has(name)) {
1049
+ return res.status(409).json({ error: "이미 작업 중입니다." });
1050
+ }
1051
+ const statusOut = spawnSync("git", ["-C", wtPath, "status", "--porcelain"], {
1052
+ encoding: "utf8",
1053
+ stdio: "pipe",
1054
+ }).stdout || "";
1055
+ const conflictFiles = parseConflictFiles(statusOut);
1056
+ const prompt = buildConflictPrompt(conflictFiles);
1057
+ const child = spawn("claude", ["-p", prompt, "--output-format", "text"], {
1058
+ cwd: wtPath,
1059
+ stdio: ["ignore", "pipe", "pipe"],
1060
+ env: { ...process.env, FORCE_COLOR: "0" },
1061
+ });
1062
+ runningTasks.set(name, child);
1063
+ broadcast({ type: "task-started", name, data: { prompt: "충돌 해결 중..." } });
1064
+ child.stdout.on("data", (chunk) => {
1065
+ broadcast({ type: "task-output", name, data: { text: chunk.toString() } });
1066
+ });
1067
+ child.stderr.on("data", (chunk) => {
1068
+ broadcast({ type: "task-output", name, data: { text: chunk.toString() } });
1069
+ });
1070
+ child.on("close", (_code) => {
1071
+ runningTasks.delete(name);
1072
+ // Commit after Claude resolves conflicts
1073
+ spawnSync("git", ["-C", wtPath, "add", "."], { stdio: "pipe" });
1074
+ const hasConflicts = spawnSync("git", ["-C", wtPath, "diff", "--check"], { stdio: "pipe" }).status !== 0;
1075
+ if (!hasConflicts) {
1076
+ spawnSync("git", ["-C", wtPath, "commit", "--no-edit"], { stdio: "pipe" });
1077
+ broadcast({ type: "conflict-resolved", name, data: { strategy: "claude" } });
1078
+ }
1079
+ else {
1080
+ broadcast({
1081
+ type: "conflict-failed",
1082
+ name,
1083
+ data: { message: "Claude가 충돌을 완전히 해결하지 못했습니다." },
1084
+ });
1085
+ }
1086
+ });
1087
+ child.on("error", (err) => {
1088
+ runningTasks.delete(name);
1089
+ const msg = err.code === "ENOENT" ? "Claude CLI가 설치되어 있지 않습니다." : err.message;
1090
+ broadcast({ type: "task-error", name, data: { error: msg } });
1091
+ });
1092
+ return res.json({ resolving: true, strategy: "claude" });
1093
+ }
1094
+ res.status(400).json({ error: "strategy must be claude, ours, or theirs" });
1095
+ });
1096
+ // POST /api/playgrounds/:name/resolve-abort — cancel conflict state
1097
+ app.post("/api/playgrounds/:name/resolve-abort", (req, res) => {
1098
+ const { name } = req.params;
1099
+ if (!getOne(name))
1100
+ return res.status(404).json({ error: "not found" });
1101
+ const wtPath = campPath(name);
1102
+ spawnSync("git", ["-C", wtPath, "merge", "--abort"], { stdio: "pipe" });
1103
+ res.json({ aborted: true });
1104
+ });
1105
+ // Task runner removed — use Claude Code directly in the terminal
1106
+ // POST /api/playgrounds/:name/enter — 캠프 진입 (정보 조회만, 터미널은 별도)
1107
+ app.post("/api/playgrounds/:name/enter", async (req, res) => {
1108
+ const { name } = req.params;
1109
+ const pg = getOne(name);
1110
+ if (!pg)
1111
+ return res.status(404).json({ error: "not found" });
1112
+ const wtPath = campPath(name);
1113
+ // 변경사항 조회
1114
+ let changes = { count: 0, files: [], actions: [] };
1115
+ try {
1116
+ const files = getChangedFiles(wtPath);
1117
+ changes = { count: files.length, files, actions: readActions(name) };
1118
+ }
1119
+ catch {
1120
+ /* ignore */
1121
+ }
1122
+ // 커밋 기록 — baseCommit(캠프 생성 시점) 이후만 표시
1123
+ let commits = [];
1124
+ try {
1125
+ const base = pg.baseCommit;
1126
+ const logArgs = base
1127
+ ? ["-C", wtPath, "log", "--oneline", "--format=%h\t%s\t%cr", "--max-count=20", `${base}..HEAD`]
1128
+ : ["-C", wtPath, "log", "--oneline", "--format=%h\t%s\t%cr", "--max-count=5", "HEAD"];
1129
+ const log = spawnSync("git", logArgs, { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || "";
1130
+ if (log) {
1131
+ commits = log.split("\n").map(line => {
1132
+ const [hash = "", message = "", date = ""] = line.split("\t");
1133
+ return { hash, message, date };
1134
+ });
1135
+ }
1136
+ }
1137
+ catch {
1138
+ /* ignore */
1139
+ }
1140
+ res.json({
1141
+ camp: pg,
1142
+ changes,
1143
+ commits,
1144
+ warpInstalled: warpStatus.installed,
1145
+ previewUrl: pg.status === "running" ? `http://localhost:${pg.fePort}` : null,
1146
+ autosave: autosaveEnabled.has(name),
1147
+ });
1148
+ });
1149
+ // POST /api/playgrounds/:name/open-terminal — 이름 있는 Warp 탭 열기
1150
+ app.post("/api/playgrounds/:name/open-terminal", (req, res) => {
1151
+ const { name } = req.params;
1152
+ const pg = getOne(name);
1153
+ if (!pg)
1154
+ return res.status(404).json({ error: "not found" });
1155
+ const wtPath = campPath(name);
1156
+ const result = openWarpTab(name, wtPath);
1157
+ res.json(result);
1158
+ });
1159
+ // GET /api/my-work — 내 진행 중인 작업 (open PRs + 로컬 캠프)
1160
+ app.get("/api/my-work", async (_req, res) => {
1161
+ const camps = getAll();
1162
+ // Open PRs by me (gh CLI) — async to avoid blocking event loop
1163
+ let prs = [];
1164
+ const ghCheck = spawnSync("which", ["gh"], { stdio: "pipe" });
1165
+ if (ghCheck.status === 0) {
1166
+ try {
1167
+ const stdout = await new Promise((resolve, reject) => {
1168
+ let out = "";
1169
+ const proc = spawn("gh", [
1170
+ "pr",
1171
+ "list",
1172
+ "--author",
1173
+ "@me",
1174
+ "--state",
1175
+ "open",
1176
+ "--limit",
1177
+ "50",
1178
+ "--json",
1179
+ "number,title,url,headRefName,updatedAt,isDraft,reviewDecision",
1180
+ ], {
1181
+ stdio: ["ignore", "pipe", "pipe"],
1182
+ });
1183
+ proc.stdout.on("data", (d) => {
1184
+ out += d;
1185
+ });
1186
+ proc.on("close", (code) => (code === 0 ? resolve(out) : reject(new Error(`gh exit ${code}`))));
1187
+ proc.on("error", reject);
1188
+ setTimeout(() => {
1189
+ proc.kill();
1190
+ reject(new Error("timeout"));
1191
+ }, 10_000);
1192
+ });
1193
+ try {
1194
+ prs = JSON.parse(stdout);
1195
+ }
1196
+ catch {
1197
+ /* ignore */
1198
+ }
1199
+ }
1200
+ catch {
1201
+ /* gh not available or timed out */
1202
+ }
1203
+ }
1204
+ // Match camps to PRs
1205
+ const work = [];
1206
+ const campsByBranch = new Map(camps.map((c) => [c.branch, c]));
1207
+ // 1. PRs (리뷰중)
1208
+ for (const pr of prs) {
1209
+ const camp = campsByBranch.get(pr.headRefName);
1210
+ work.push({
1211
+ type: "pr",
1212
+ title: pr.title,
1213
+ prNumber: pr.number,
1214
+ prUrl: pr.url,
1215
+ branch: pr.headRefName,
1216
+ updatedAt: pr.updatedAt,
1217
+ isDraft: pr.isDraft,
1218
+ reviewStatus: pr.reviewDecision || "PENDING",
1219
+ camp: camp?.name || null,
1220
+ });
1221
+ }
1222
+ // 2. Local camps without PR (작업중)
1223
+ const prBranches = new Set(prs.map((p) => p.headRefName));
1224
+ for (const camp of camps) {
1225
+ if (!prBranches.has(camp.branch)) {
1226
+ work.push({
1227
+ type: "camp",
1228
+ title: camp.name,
1229
+ branch: camp.branch,
1230
+ status: camp.status,
1231
+ camp: camp.name,
1232
+ });
1233
+ }
1234
+ }
1235
+ res.json(work);
1236
+ });
1237
+ // POST /api/quick-start — 자연어 → 브랜치 생성 → 캠프 생성
1238
+ app.post("/api/quick-start", async (req, res) => {
1239
+ const { description } = req.body ?? {};
1240
+ if (!description?.trim())
1241
+ return res.status(400).json({ error: "뭘 하고 싶은지 입력해주세요." });
1242
+ const slug = aiSlugify(description.trim()) ?? slugify(description.trim());
1243
+ const name = slug.slice(0, 30);
1244
+ const branch = `camp/${slug}`;
1245
+ // Check if camp already exists
1246
+ if (getOne(name))
1247
+ return res.status(409).json({ error: `'${name}' 캠프가 이미 있습니다.` });
1248
+ const existing = getAll();
1249
+ if (existing.length >= MAX_CAMPS)
1250
+ return res.status(400).json({ error: `최대 ${MAX_CAMPS}개 캠프까지 가능합니다.` });
1251
+ try {
1252
+ // Create branch from default branch (dev or main)
1253
+ const defaultBranch = spawnSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
1254
+ encoding: "utf8",
1255
+ stdio: "pipe",
1256
+ })
1257
+ .stdout?.trim()
1258
+ ?.replace("refs/remotes/origin/", "") || "main";
1259
+ const branchResult = spawnSync("git", ["branch", branch, `origin/${defaultBranch}`], {
1260
+ encoding: "utf8",
1261
+ stdio: "pipe",
1262
+ });
1263
+ if (branchResult.status !== 0) {
1264
+ return res.status(500).json({ error: `브랜치 생성 실패: ${branchResult.stderr?.trim() || "unknown error"}` });
1265
+ }
1266
+ const freshConfig2 = await loadConfig(projectRoot);
1267
+ setConfig(freshConfig2);
1268
+ if (freshConfig2.ports)
1269
+ setPortConfig(freshConfig2.ports);
1270
+ const { slot, fePort, bePort } = allocate(existing);
1271
+ const actualFePort2 = freshConfig2.dev?.portFlag ? fePort : freshConfig2.dev?.port || fePort;
1272
+ await addWorktree(name, branch);
1273
+ const wtPath = campPath(name);
1274
+ copyCampFiles(projectRoot, wtPath, freshConfig2.copyFiles, (msg) => {
1275
+ broadcast({ type: "log", name, source: "sanjang", data: msg });
1276
+ });
1277
+ const baseCommit2 = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || undefined;
1278
+ const record = {
1279
+ name,
1280
+ branch,
1281
+ slot,
1282
+ fePort: actualFePort2,
1283
+ bePort,
1284
+ status: "setting-up",
1285
+ description: description.trim(),
1286
+ baseCommit: baseCommit2,
1287
+ parentBranch: defaultBranch,
1288
+ };
1289
+ upsert(record);
1290
+ broadcast({ type: "playground-created", name, data: record });
1291
+ res.status(201).json(record);
1292
+ setupCampDeps(name, wtPath, freshConfig2, broadcast);
1293
+ }
1294
+ catch (err) {
1295
+ try {
1296
+ await removeWorktree(name);
1297
+ }
1298
+ catch {
1299
+ /* cleanup */
1300
+ }
1301
+ spawnSync("git", ["branch", "-D", branch], { stdio: "pipe" });
1302
+ res.status(500).json({ error: friendlyError(err, branch) });
1303
+ }
1304
+ });
1305
+ // -------------------------------------------------------------------------
1306
+ // Agent features (suggestions, smart-pr, auto-fix)
1307
+ // -------------------------------------------------------------------------
1308
+ app.get("/api/suggestions", async (_req, res) => {
1309
+ try {
1310
+ const suggestions = await suggestTasks(projectRoot);
1311
+ res.json(suggestions);
1312
+ }
1313
+ catch {
1314
+ res.json([]); // graceful degradation
1315
+ }
1316
+ });
1317
+ app.post("/api/playgrounds/:name/smart-pr", async (req, res) => {
1318
+ const { name } = req.params;
1319
+ const pg = getOne(name);
1320
+ if (!pg)
1321
+ return res.status(404).json({ error: "not found" });
1322
+ const wtPath = campPath(name);
1323
+ const description = await generatePrDescription(wtPath);
1324
+ res.json({ description });
1325
+ });
1326
+ app.post("/api/playgrounds/:name/auto-fix", async (req, res) => {
1327
+ const { name } = req.params;
1328
+ const pg = getOne(name);
1329
+ if (!pg)
1330
+ return res.status(404).json({ error: "not found" });
1331
+ const processInfo = getProcessInfo(name);
1332
+ const logs = processInfo?.feLogs ?? [];
1333
+ // Try config hotfix
1334
+ const fix = suggestConfigFix(projectRoot, logs);
1335
+ if (fix && fix.type !== "info") {
1336
+ const applied = applyConfigFix(projectRoot, fix);
1337
+ if (applied) {
1338
+ // Restart the camp after fixing config
1339
+ try {
1340
+ stopCamp(name);
1341
+ stopWatcher(name);
1342
+ updateCampStatus(name, "starting");
1343
+ broadcast({ type: "playground-status", name, data: { status: "starting" } });
1344
+ const detectedPort = await startCamp(pg, (event) => {
1345
+ broadcast({ type: event.type, name, data: event.data, source: event.source });
1346
+ });
1347
+ const url = `http://localhost:${detectedPort}`;
1348
+ upsert({ ...getOne(name), status: "running", fePort: detectedPort, url });
1349
+ broadcast({ type: "playground-status", name, data: { status: "running", url } });
1350
+ startWatcher(name);
1351
+ return res.json({ fixed: true, description: fix.description });
1352
+ }
1353
+ catch (retryErr) {
1354
+ return res.json({ fixed: false, description: "설정을 고쳤지만 시작에 실패했습니다." });
1355
+ }
1356
+ }
1357
+ }
1358
+ res.json({ fixed: false, description: fix?.description ?? "자동으로 고칠 수 있는 문제를 찾지 못했습니다." });
1359
+ });
1360
+ // SPA fallback
1361
+ app.get("*", (_req, res) => {
1362
+ res.sendFile(join(dashboardDir, "index.html"));
1363
+ });
1364
+ return { app, server, port, runningTasks, warpStatus, watchers };
1365
+ }
1366
+ export async function startServer(projectRoot, options = {}) {
1367
+ const { server, port, runningTasks, warpStatus, watchers } = await createApp(projectRoot, options);
1368
+ server.listen(port, "127.0.0.1", () => {
1369
+ console.log(`⛰ 산장 서버 실행 중 — http://localhost:${port}`);
1370
+ if (warpStatus.installed) {
1371
+ console.log(" Warp 감지됨 ✓ — 캠프 진입 시 터미널이 자동으로 열립니다");
1372
+ }
1373
+ else {
1374
+ console.log(" ℹ Warp를 설치하면 캠프↔터미널 자동 연동을 사용할 수 있습니다");
1375
+ }
1376
+ });
1377
+ // Graceful shutdown
1378
+ function shutdown() {
1379
+ console.log("\n⛰ 산장 종료 중...");
1380
+ for (const [, child] of runningTasks) {
1381
+ try {
1382
+ child.kill("SIGTERM");
1383
+ }
1384
+ catch {
1385
+ /* ignore */
1386
+ }
1387
+ }
1388
+ for (const [, w] of watchers)
1389
+ w.stop();
1390
+ watchers.clear();
1391
+ stopAllCamps();
1392
+ server.close(() => process.exit(0));
1393
+ // Force exit after 10s if cleanup hangs
1394
+ setTimeout(() => process.exit(1), 10_000);
1395
+ }
1396
+ process.on("SIGINT", shutdown);
1397
+ process.on("SIGTERM", shutdown);
1398
+ return server;
1399
+ }