sanjang 0.3.0

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