sanjang 0.3.4 → 0.3.6

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.
@@ -1,27 +1,30 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
  import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { createServer, request as httpRequest } from "node:http";
4
+ import { connect as netConnect } from "node:net";
4
5
  import { dirname, join, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import express from "express";
7
8
  import { WebSocket, WebSocketServer } from "ws";
8
- import { loadConfig } from "./config.js";
9
+ import { detectTestCommand, loadConfig } from "./config.js";
9
10
  import { applyCacheToWorktree, buildCache, isCacheValid } from "./engine/cache.js";
10
- import { buildConflictPrompt, parseConflictFiles } from "./engine/conflict.js";
11
+ import { buildChangeReport, generateReportSummary } from "./engine/change-report.js";
12
+ import { applyConfigFix, suggestConfigFix } from "./engine/config-hotfix.js";
13
+ import { buildConflictPrompt, parseConflictFiles, parseConflictSections } from "./engine/conflict.js";
11
14
  import { buildDiagnostics } from "./engine/diagnostics.js";
15
+ import { getMainServerState, startMainServer, stopMainServer } from "./engine/main-server.js";
12
16
  import { aiSlugify, slugify } from "./engine/naming.js";
13
17
  import { allocate, scanPorts, setPortConfig } from "./engine/ports.js";
14
18
  import { buildClaudePrPrompt, buildFallbackPrBody } from "./engine/pr.js";
15
19
  import { getProcessInfo, setConfig, startCamp, stopAllCamps, stopCamp } from "./engine/process.js";
20
+ import { diagnoseFromLogs, executeHeal } from "./engine/self-heal.js";
21
+ import { generatePrDescription } from "./engine/smart-pr.js";
16
22
  import { listSnapshots, restoreSnapshot, saveSnapshot } from "./engine/snapshot.js";
17
23
  import { getAll, getOne, remove, setCampsDir, upsert } from "./engine/state.js";
24
+ import { suggestTasks } from "./engine/suggest.js";
18
25
  import { detectWarp, openWarpTab, removeLaunchConfig } from "./engine/warp.js";
19
26
  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";
27
+ import { addWorktree, campPath, getProjectRoot, listBranches, removeWorktree, setProjectRoot, startBranchRefresh, } from "./engine/worktree.js";
25
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
29
  // ---------------------------------------------------------------------------
27
30
  // Error translation
@@ -97,10 +100,15 @@ function updateCampStatus(name, status, extra) {
97
100
  return; // camp may have been deleted during async setup
98
101
  upsert({ ...camp, status, ...extra });
99
102
  }
100
- function setupCampDeps(name, wtPath, cfg, broadcast) {
101
- if (!cfg.setup) {
103
+ function setupCampDeps(name, wtPath, cfg, broadcast, onReady) {
104
+ function done() {
102
105
  updateCampStatus(name, "stopped");
103
106
  broadcast({ type: "playground-status", name, data: { status: "stopped" } });
107
+ if (onReady)
108
+ onReady();
109
+ }
110
+ if (!cfg.setup) {
111
+ done();
104
112
  return;
105
113
  }
106
114
  const setupCwd = cfg.dev?.cwd || ".";
@@ -115,8 +123,7 @@ function setupCampDeps(name, wtPath, cfg, broadcast) {
115
123
  source: "sanjang",
116
124
  data: `캐시에서 node_modules 복사 완료 ✓ (${cacheResult.duration}ms)`,
117
125
  });
118
- updateCampStatus(name, "stopped");
119
- broadcast({ type: "playground-status", name, data: { status: "stopped" } });
126
+ done();
120
127
  return;
121
128
  }
122
129
  broadcast({ type: "log", name, source: "sanjang", data: `캐시 없음 (${cacheResult.reason}), 설치 중...` });
@@ -138,8 +145,7 @@ function setupCampDeps(name, wtPath, cfg, broadcast) {
138
145
  setupProc.on("close", (code) => {
139
146
  if (code === 0) {
140
147
  broadcast({ type: "log", name, source: "sanjang", data: "설치 완료 ✓" });
141
- updateCampStatus(name, "stopped");
142
- broadcast({ type: "playground-status", name, data: { status: "stopped" } });
148
+ done();
143
149
  }
144
150
  else {
145
151
  broadcast({ type: "log", name, source: "sanjang", data: `⚠️ 설치 실패 (코드 ${code})` });
@@ -192,9 +198,15 @@ export async function createApp(projectRoot, options = {}) {
192
198
  return;
193
199
  try {
194
200
  // Reattach if detached
195
- const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], { encoding: "utf8", stdio: "pipe" });
201
+ const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
202
+ encoding: "utf8",
203
+ stdio: "pipe",
204
+ });
196
205
  if (headRef.status !== 0 && pg.branch) {
197
- const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
206
+ const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
207
+ encoding: "utf8",
208
+ stdio: "pipe",
209
+ }).stdout?.trim();
198
210
  if (cur) {
199
211
  spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, cur], { stdio: "pipe" });
200
212
  spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
@@ -202,10 +214,16 @@ export async function createApp(projectRoot, options = {}) {
202
214
  }
203
215
  runGit(["-C", wtPath, "add", "-A"], wtPath);
204
216
  runGit(["-C", wtPath, "commit", "-m", `[auto] ${files.length}개 파일 자동 세이브`], wtPath);
205
- broadcast({ type: "playground-saved", name, data: { message: `[auto] ${files.length}개 파일 자동 세이브`, auto: true } });
217
+ broadcast({
218
+ type: "playground-saved",
219
+ name,
220
+ data: { message: `[auto] ${files.length}개 파일 자동 세이브`, auto: true },
221
+ });
206
222
  broadcast({ type: "autosaved", name });
207
223
  }
208
- catch { /* ignore */ }
224
+ catch {
225
+ /* ignore */
226
+ }
209
227
  }, AUTOSAVE_DELAY));
210
228
  }
211
229
  function startWatcher(name) {
@@ -224,7 +242,9 @@ export async function createApp(projectRoot, options = {}) {
224
242
  if (autosaveEnabled.has(name))
225
243
  resetAutosaveTimer(name);
226
244
  }
227
- catch { /* ignore — worktree may be deleted */ }
245
+ catch {
246
+ /* ignore — worktree may be deleted */
247
+ }
228
248
  }, 800);
229
249
  watcher.start();
230
250
  watchers.set(name, watcher);
@@ -241,6 +261,46 @@ export async function createApp(projectRoot, options = {}) {
241
261
  autosaveTimers.delete(name);
242
262
  }
243
263
  }
264
+ // ---------------------------------------------------------------------------
265
+ // Periodic health check — detect dead "running" camps and auto-update state
266
+ // ---------------------------------------------------------------------------
267
+ const HEALTH_CHECK_INTERVAL = 15_000; // 15s
268
+ function startHealthCheck() {
269
+ return setInterval(async () => {
270
+ const camps = getAll();
271
+ for (const camp of camps) {
272
+ if (camp.status !== "running" || !camp.fePort)
273
+ continue;
274
+ // Check 1: Is the Vite dev server alive? (direct)
275
+ let directOk = false;
276
+ try {
277
+ const res = await fetch(`http://localhost:${camp.fePort}/`, {
278
+ signal: AbortSignal.timeout(3000),
279
+ });
280
+ directOk = res.status < 500;
281
+ }
282
+ catch {
283
+ directOk = false;
284
+ }
285
+ if (!directOk) {
286
+ upsert({ ...camp, status: "stopped" });
287
+ broadcast({
288
+ type: "playground-status",
289
+ name: camp.name,
290
+ data: { status: "stopped", reason: "health-check" },
291
+ });
292
+ broadcast({
293
+ type: "log",
294
+ name: camp.name,
295
+ source: "sanjang",
296
+ data: `⚠️ 헬스체크 실패: :${camp.fePort} 응답 없음 → stopped 처리`,
297
+ });
298
+ stopWatcher(camp.name);
299
+ }
300
+ }
301
+ }, HEALTH_CHECK_INTERVAL);
302
+ }
303
+ const healthCheckTimer = startHealthCheck();
244
304
  // Express app
245
305
  const app = express();
246
306
  app.use(express.json());
@@ -250,7 +310,52 @@ export async function createApp(projectRoot, options = {}) {
250
310
  : join(__dirname, "..", "..", "dashboard");
251
311
  app.use(express.static(dashboardDir));
252
312
  const server = createServer(app);
253
- const wss = new WebSocketServer({ server });
313
+ const wss = new WebSocketServer({ noServer: true });
314
+ // -----------------------------------------------------------------------
315
+ // WebSocket upgrade — route HMR WS to camp dev servers, others to sanjang WSS
316
+ // -----------------------------------------------------------------------
317
+ server.on("upgrade", (req, socket, head) => {
318
+ const url = req.url || "/";
319
+ const hmrMatch = /^\/preview\/(\d+)(\/.*)?$/.exec(url);
320
+ if (hmrMatch) {
321
+ const targetPort = parseInt(hmrMatch[1], 10);
322
+ if (!Number.isFinite(targetPort) || targetPort < 1000 || targetPort > 65535) {
323
+ socket.destroy();
324
+ return;
325
+ }
326
+ // Verify it's a known camp port
327
+ const camps = getAll();
328
+ const mainState = getMainServerState();
329
+ const isKnown = camps.some((c) => c.fePort === targetPort) ||
330
+ (mainState.status === "running" && mainState.port === targetPort);
331
+ if (!isKnown) {
332
+ socket.destroy();
333
+ return;
334
+ }
335
+ // Proxy WS to the target dev server
336
+ const targetPath = hmrMatch[2] || "/";
337
+ const proxy = netConnect({ host: "localhost", port: targetPort }, () => {
338
+ // Rewrite the request line to target path and forward the upgrade
339
+ const reqLine = `${req.method} ${targetPath} HTTP/${req.httpVersion}\r\n`;
340
+ const headers = Object.entries(req.headers)
341
+ .filter(([k]) => k.toLowerCase() !== "host")
342
+ .map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(", ") : v}`)
343
+ .join("\r\n");
344
+ proxy.write(`${reqLine}host: localhost:${targetPort}\r\n${headers}\r\n\r\n`);
345
+ if (head.length > 0)
346
+ proxy.write(head);
347
+ socket.pipe(proxy).pipe(socket);
348
+ });
349
+ proxy.on("error", () => socket.destroy());
350
+ socket.on("error", () => proxy.destroy());
351
+ }
352
+ else {
353
+ // Sanjang dashboard WS
354
+ wss.handleUpgrade(req, socket, head, (ws) => {
355
+ wss.emit("connection", ws, req);
356
+ });
357
+ }
358
+ });
254
359
  function broadcast(msg) {
255
360
  const text = JSON.stringify(msg);
256
361
  for (const client of wss.clients) {
@@ -263,11 +368,13 @@ export async function createApp(projectRoot, options = {}) {
263
368
  ws.on("message", (raw) => {
264
369
  try {
265
370
  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 });
371
+ if ((msg.type === "browser-error" || msg.type === "browser-console" || msg.type === "browser-network") && msg.name) {
372
+ broadcast({ type: msg.type, name: msg.name, data: msg.data });
268
373
  }
269
374
  }
270
- catch { /* ignore non-JSON */ }
375
+ catch {
376
+ /* ignore non-JSON */
377
+ }
271
378
  });
272
379
  });
273
380
  // -------------------------------------------------------------------------
@@ -284,18 +391,64 @@ export async function createApp(projectRoot, options = {}) {
284
391
  ws.onclose=function(){setTimeout(connect,3000)};
285
392
  }
286
393
  var name=document.currentScript.getAttribute('data-camp');
394
+ function fmt(a){try{return typeof a==='object'?JSON.stringify(a):String(a)}catch(e){return String(a)}}
395
+ function fmtArgs(a){return [].slice.call(a).map(fmt).join(' ')}
396
+
397
+ // --- Error capture ---
398
+ function getStack(err){if(err&&err.stack){var s=err.stack;var idx=s.indexOf('\\n');return idx>-1?s.slice(idx+1).trim():s}return ''}
287
399
  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}});
400
+ send({type:'browser-error',name:name,data:{level:'error',message:e.message,source:e.filename,line:e.lineno,col:e.colno,stack:getStack(e.error)}});
289
401
  });
290
402
  var origError=console.error;
291
403
  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(' ')}});
404
+ var stack=getStack(new Error()).split('\\n').slice(1).join('\\n')
405
+ send({type:'browser-error',name:name,data:{level:'console.error',message:fmtArgs(arguments),stack:stack}});
294
406
  origError.apply(console,arguments);
295
407
  };
296
408
  window.addEventListener('unhandledrejection',function(e){
297
- send({type:'browser-error',name:name,data:{level:'promise',message:String(e.reason)}});
409
+ var r=e.reason;var msg=r&&r.message?r.message:String(r);var stack=getStack(r);
410
+ send({type:'browser-error',name:name,data:{level:'promise',message:msg,stack:stack}});
298
411
  });
412
+
413
+ // --- Console capture (log/warn/info) ---
414
+ var origLog=console.log,origWarn=console.warn,origInfo=console.info;
415
+ console.log=function(){
416
+ send({type:'browser-console',name:name,data:{level:'log',message:fmtArgs(arguments)}});
417
+ origLog.apply(console,arguments);
418
+ };
419
+ console.warn=function(){
420
+ send({type:'browser-console',name:name,data:{level:'warn',message:fmtArgs(arguments)}});
421
+ origWarn.apply(console,arguments);
422
+ };
423
+ console.info=function(){
424
+ send({type:'browser-console',name:name,data:{level:'info',message:fmtArgs(arguments)}});
425
+ origInfo.apply(console,arguments);
426
+ };
427
+
428
+ // --- Network capture (fetch + XHR) ---
429
+ var origFetch=window.fetch;
430
+ if(origFetch)window.fetch=function(url,opts){
431
+ var method=(opts&&opts.method||'GET').toUpperCase();
432
+ var start=Date.now();
433
+ var urlStr=typeof url==='string'?url:(url&&url.url?url.url:String(url));
434
+ return origFetch.apply(this,arguments).then(function(resp){
435
+ send({type:'browser-network',name:name,data:{url:urlStr,method:method,status:resp.status,duration:Date.now()-start}});
436
+ return resp;
437
+ }).catch(function(err){
438
+ send({type:'browser-network',name:name,data:{url:urlStr,method:method,status:0,duration:Date.now()-start,error:String(err)}});
439
+ throw err;
440
+ });
441
+ };
442
+ var origXHROpen=XMLHttpRequest.prototype.open,origXHRSend=XMLHttpRequest.prototype.send;
443
+ XMLHttpRequest.prototype.open=function(m,u){this._sj={method:m,url:u};origXHROpen.apply(this,arguments)};
444
+ XMLHttpRequest.prototype.send=function(){
445
+ var self=this,info=self._sj||{};info.start=Date.now();
446
+ self.addEventListener('loadend',function(){
447
+ send({type:'browser-network',name:name,data:{url:info.url||'',method:(info.method||'GET').toUpperCase(),status:self.status,duration:Date.now()-info.start}});
448
+ });
449
+ origXHRSend.apply(this,arguments);
450
+ };
451
+
299
452
  connect();
300
453
  })();
301
454
  </script>`;
@@ -306,32 +459,46 @@ export async function createApp(projectRoot, options = {}) {
306
459
  }
307
460
  // Only allow proxying to known camp ports (prevent SSRF to arbitrary local services)
308
461
  const camps = getAll();
309
- const camp = camps.find(c => c.fePort === targetPort);
310
- if (!camp) {
462
+ const camp = camps.find((c) => c.fePort === targetPort);
463
+ const mainState = getMainServerState();
464
+ const isMainPort = mainState.status === "running" && mainState.port === targetPort;
465
+ if (!camp && !isMainPort) {
311
466
  return res.status(403).send("이 포트는 활성 캠프가 아닙니다.");
312
467
  }
313
- const campName = camp.name;
468
+ const campName = camp?.name ?? "__main__";
314
469
  const targetPath = req.url || "/";
315
- const proxyReq = httpRequest({ hostname: "127.0.0.1", port: targetPort, path: targetPath, method: req.method, headers: { ...req.headers, host: `localhost:${targetPort}` } }, (proxyRes) => {
470
+ const proxyReq = httpRequest({
471
+ hostname: "localhost",
472
+ port: targetPort,
473
+ path: targetPath,
474
+ method: req.method,
475
+ // Preserve the browser's origin (sanjang port) so SvelteKit SSR
476
+ // generates URLs matching the actual browser origin. Otherwise goto()
477
+ // rejects navigation as "external URL" due to origin mismatch.
478
+ headers: { ...req.headers, host: `localhost:${port}` },
479
+ }, (proxyRes) => {
316
480
  const contentType = proxyRes.headers["content-type"] || "";
317
481
  const isHtml = contentType.includes("text/html");
318
482
  if (!isHtml) {
319
- // Pass through non-HTML responses directly
483
+ // Pass through non-HTML responses directly (JS, CSS, images, etc.)
320
484
  res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
321
485
  proxyRes.pipe(res);
322
486
  return;
323
487
  }
324
- // Buffer HTML to inject script
488
+ // Buffer HTML to inject sanjang monitoring script (no path rewriting needed)
325
489
  const chunks = [];
326
490
  proxyRes.on("data", (chunk) => chunks.push(chunk));
327
491
  proxyRes.on("end", () => {
328
492
  let body = Buffer.concat(chunks).toString("utf8");
329
- const script = INJECTED_SCRIPT
330
- .replace("data-camp'", `data-camp'`) // placeholder
493
+ const script = INJECTED_SCRIPT.replace("data-camp'", `data-camp'`) // placeholder
331
494
  .replace("data-sanjang-injected>", `data-sanjang-injected data-camp="${campName}">`);
332
495
  // Replace _sp port placeholder with actual sanjang server port
333
496
  const finalScript = script.replace("new URLSearchParams(location.search.slice(1)||'').get('_sp')||location.port", `'${port}'`);
334
- // Inject before </head> or </body> or at end
497
+ // Inject monitoring script before </head> or </body> or at end.
498
+ // NOTE: Do NOT inject <base> tag — it breaks SvelteKit client-side
499
+ // routing (goto() rejects URLs as "external" due to origin mismatch).
500
+ // Instead, the Vite resource catch-all proxy handles routing via
501
+ // Referer and /@fs/ path-based camp resolution.
335
502
  if (body.includes("</head>")) {
336
503
  body = body.replace("</head>", `${finalScript}</head>`);
337
504
  }
@@ -349,9 +516,17 @@ export async function createApp(projectRoot, options = {}) {
349
516
  // Remove X-Frame-Options / frame-ancestors to allow iframe embedding
350
517
  delete headers["x-frame-options"];
351
518
  if (typeof headers["content-security-policy"] === "string") {
352
- headers["content-security-policy"] = headers["content-security-policy"]
353
- .replace(/frame-ancestors[^;]*(;|$)/gi, "");
519
+ headers["content-security-policy"] = headers["content-security-policy"].replace(/frame-ancestors[^;]*(;|$)/gi, "");
354
520
  }
521
+ // Prevent caching of proxied HTML (injected scripts change between restarts)
522
+ headers["cache-control"] = "no-store, no-cache, must-revalidate";
523
+ // Set cookie so catch-all proxy can route after client-side navigation
524
+ // (when Referer no longer contains /preview/PORT)
525
+ const existing = headers["set-cookie"];
526
+ const portCookie = `_sanjang_port=${targetPort}; Path=/; SameSite=Lax`;
527
+ headers["set-cookie"] = existing
528
+ ? (Array.isArray(existing) ? [...existing, portCookie] : [existing, portCookie])
529
+ : [portCookie];
355
530
  res.writeHead(proxyRes.statusCode ?? 200, headers);
356
531
  res.end(body);
357
532
  });
@@ -368,7 +543,7 @@ export async function createApp(projectRoot, options = {}) {
368
543
  // Project info — used by dashboard header
369
544
  const projectName = projectRoot.split("/").pop() ?? "project";
370
545
  app.get("/api/project", (_req, res) => res.json({ name: projectName }));
371
- app.get("/api/ports", (_req, res) => res.json(scanPorts()));
546
+ app.get("/api/ports", async (_req, res) => res.json(await scanPorts()));
372
547
  app.get("/api/branches", async (_req, res) => {
373
548
  try {
374
549
  res.json(await listBranches());
@@ -399,7 +574,7 @@ export async function createApp(projectRoot, options = {}) {
399
574
  setConfig(freshConfig);
400
575
  if (freshConfig.ports)
401
576
  setPortConfig(freshConfig.ports);
402
- const { slot, fePort, bePort } = allocate(existing);
577
+ const { slot, fePort, bePort } = await allocate(existing);
403
578
  // When portFlag is null, dev server uses its own fixed port
404
579
  const actualFePort = freshConfig.dev?.portFlag ? fePort : freshConfig.dev?.port || fePort;
405
580
  await addWorktree(name, branch);
@@ -408,12 +583,22 @@ export async function createApp(projectRoot, options = {}) {
408
583
  copyCampFiles(projectRoot, wtPath, freshConfig.copyFiles, (msg) => {
409
584
  broadcast({ type: "log", name, source: "sanjang", data: msg });
410
585
  });
411
- const baseCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || undefined;
412
- const record = { name, branch, slot, fePort: actualFePort, bePort, status: "setting-up", baseCommit, parentBranch: branch };
586
+ const baseCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() ||
587
+ undefined;
588
+ const record = {
589
+ name,
590
+ branch,
591
+ slot,
592
+ fePort: actualFePort,
593
+ bePort,
594
+ status: "setting-up",
595
+ baseCommit,
596
+ parentBranch: branch,
597
+ };
413
598
  upsert(record);
414
599
  broadcast({ type: "playground-created", name, data: record });
415
600
  res.status(201).json(record);
416
- setupCampDeps(name, wtPath, freshConfig, broadcast);
601
+ setupCampDeps(name, wtPath, freshConfig, broadcast, () => triggerStart(name));
417
602
  }
418
603
  catch (err) {
419
604
  // Clean up orphan worktree on failure
@@ -428,24 +613,27 @@ export async function createApp(projectRoot, options = {}) {
428
613
  });
429
614
  // Track in-flight start operations
430
615
  const startingSet = new Set();
431
- app.post("/api/playgrounds/:name/start", async (req, res) => {
432
- const { name } = req.params;
616
+ /** Internal start logic shared by API handler and auto-start after setup */
617
+ function triggerStart(name) {
433
618
  const pg = getOne(name);
434
- if (!pg)
435
- return res.status(404).json({ error: "not found" });
436
- if (startingSet.has(name))
437
- return res.json({ status: "already-starting" });
619
+ if (!pg || startingSet.has(name))
620
+ return;
438
621
  startingSet.add(name);
439
622
  upsert({ ...pg, status: "starting" });
440
623
  broadcast({ type: "playground-status", name, data: { status: "starting" } });
441
- res.json({ status: "starting" });
442
624
  (async () => {
443
625
  try {
444
- const detectedPort = await startCamp(pg, (event) => {
626
+ const reservedPorts = new Set(getAll().filter((c) => c.name !== name).map((c) => c.fePort));
627
+ const detectedPort = await startCamp({ ...pg, reservedPorts }, (event) => {
445
628
  broadcast({ type: event.type, name, data: event.data, source: event.source });
446
629
  });
630
+ if (!getOne(name)) {
631
+ stopCamp(name);
632
+ startingSet.delete(name);
633
+ return;
634
+ }
447
635
  const url = `http://localhost:${detectedPort}`;
448
- const updatedCamp = { ...getOne(name), status: "running", fePort: detectedPort, url };
636
+ const updatedCamp = { ...(getOne(name) ?? pg), status: "running", fePort: detectedPort, url };
449
637
  upsert(updatedCamp);
450
638
  broadcast({ type: "playground-status", name, data: { status: "running", url } });
451
639
  startWatcher(name);
@@ -453,9 +641,8 @@ export async function createApp(projectRoot, options = {}) {
453
641
  catch (err) {
454
642
  const current = getOne(name) ?? pg;
455
643
  const processInfo = getProcessInfo(name) ?? { feLogs: [], feExitCode: null };
456
- // Self-heal: try to auto-fix before giving up
457
644
  const healActions = diagnoseFromLogs(processInfo.feLogs);
458
- const autoFixable = healActions.filter(a => a.auto);
645
+ const autoFixable = healActions.filter((a) => a.auto);
459
646
  if (autoFixable.length > 0) {
460
647
  broadcast({ type: "log", name, source: "sanjang", data: "문제를 발견했습니다. 자동으로 고치는 중..." });
461
648
  const freshConfig = await loadConfig(projectRoot);
@@ -472,15 +659,21 @@ export async function createApp(projectRoot, options = {}) {
472
659
  upsert({ ...current, status: "starting" });
473
660
  broadcast({ type: "playground-status", name, data: { status: "starting" } });
474
661
  try {
475
- const retryPort = await startCamp(current, (event) => {
662
+ const retryReserved = new Set(getAll().filter((c) => c.name !== name).map((c) => c.fePort));
663
+ const retryPort = await startCamp({ ...current, reservedPorts: retryReserved }, (event) => {
476
664
  broadcast({ type: event.type, name, data: event.data, source: event.source });
477
665
  });
478
666
  const retryUrl = `http://localhost:${retryPort}`;
479
- upsert({ ...getOne(name), status: "running", fePort: retryPort, url: retryUrl });
667
+ const retryState = getOne(name);
668
+ if (!retryState) {
669
+ stopCamp(name);
670
+ return;
671
+ }
672
+ upsert({ ...retryState, status: "running", fePort: retryPort, url: retryUrl });
480
673
  broadcast({ type: "playground-status", name, data: { status: "running", url: retryUrl } });
481
674
  startWatcher(name);
482
675
  broadcast({ type: "log", name, source: "sanjang", data: "자동 복구 성공 ✓" });
483
- return; // healed successfully
676
+ return;
484
677
  }
485
678
  catch {
486
679
  // retry failed too, fall through to error
@@ -496,6 +689,16 @@ export async function createApp(projectRoot, options = {}) {
496
689
  startingSet.delete(name);
497
690
  }
498
691
  })();
692
+ }
693
+ app.post("/api/playgrounds/:name/start", async (req, res) => {
694
+ const { name } = req.params;
695
+ const pg = getOne(name);
696
+ if (!pg)
697
+ return res.status(404).json({ error: "not found" });
698
+ if (startingSet.has(name))
699
+ return res.json({ status: "already-starting" });
700
+ triggerStart(name);
701
+ res.json({ status: "starting" });
499
702
  });
500
703
  app.post("/api/playgrounds/:name/stop", (req, res) => {
501
704
  const { name } = req.params;
@@ -583,10 +786,16 @@ export async function createApp(projectRoot, options = {}) {
583
786
  return res.json({ saved: false, reason: "변경사항이 없습니다." });
584
787
  try {
585
788
  // Reattach to branch if in detached HEAD state
586
- const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], { encoding: "utf8", stdio: "pipe" });
789
+ const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
790
+ encoding: "utf8",
791
+ stdio: "pipe",
792
+ });
587
793
  if (headRef.status !== 0 && pg.branch) {
588
794
  // Detached HEAD — move branch pointer to current commit and checkout
589
- const currentCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
795
+ const currentCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
796
+ encoding: "utf8",
797
+ stdio: "pipe",
798
+ }).stdout?.trim();
590
799
  if (currentCommit) {
591
800
  spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, currentCommit], { stdio: "pipe" });
592
801
  spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
@@ -595,10 +804,16 @@ export async function createApp(projectRoot, options = {}) {
595
804
  // Stage all changes
596
805
  runGit(["-C", wtPath, "add", "-A"], wtPath);
597
806
  // Generate commit message with AI
598
- const diff = spawnSync("git", ["-C", wtPath, "diff", "--cached", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
807
+ const diff = spawnSync("git", ["-C", wtPath, "diff", "--cached", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout ||
808
+ "";
599
809
  let message = `${files.length}개 파일 변경`;
600
810
  try {
601
- const aiResult = spawnSync("claude", ["-p", "--model", "haiku", `이 git diff를 한국어 커밋 메시지로 작성해. 한 줄, 50자 이내, 설명 없이 메시지만:\n\n${diff.slice(0, 2000)}`], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
811
+ const aiResult = spawnSync("claude", [
812
+ "-p",
813
+ "--model",
814
+ "haiku",
815
+ `이 git diff를 한국어 커밋 메시지로 작성해. 한 줄, 50자 이내, 설명 없이 메시지만:\n\n${diff.slice(0, 2000)}`,
816
+ ], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
602
817
  if (aiResult.status === 0 && aiResult.stdout?.trim()) {
603
818
  message = aiResult.stdout.trim();
604
819
  }
@@ -689,6 +904,29 @@ export async function createApp(projectRoot, options = {}) {
689
904
  // -------------------------------------------------------------------------
690
905
  // Cache management
691
906
  // -------------------------------------------------------------------------
907
+ // GET /api/camps/stale — camps not accessed in N days (default 7)
908
+ app.get("/api/camps/stale", (_req, res) => {
909
+ const days = parseInt(String(_req.query.days || "7"), 10);
910
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
911
+ const camps = getAll();
912
+ const stale = camps.filter((c) => {
913
+ if (!c.lastAccessedAt)
914
+ return true; // never tracked
915
+ return new Date(c.lastAccessedAt).getTime() < cutoff;
916
+ });
917
+ // Get disk usage for stale camps
918
+ const result = stale.map((c) => {
919
+ const wtPath = campPath(c.name);
920
+ let size = "?";
921
+ try {
922
+ const du = spawnSync("du", ["-sh", wtPath], { encoding: "utf8", stdio: "pipe", timeout: 5000 });
923
+ size = du.stdout?.split("\t")[0]?.trim() || "?";
924
+ }
925
+ catch { /* */ }
926
+ return { name: c.name, branch: c.branch, status: c.status, lastAccessedAt: c.lastAccessedAt, size };
927
+ });
928
+ res.json(result);
929
+ });
692
930
  app.get("/api/cache/status", (_req, res) => {
693
931
  const setupCwd = config.dev?.cwd || ".";
694
932
  res.json(isCacheValid(projectRoot, setupCwd));
@@ -768,22 +1006,131 @@ export async function createApp(projectRoot, options = {}) {
768
1006
  res.status(500).json({ error: err.message });
769
1007
  }
770
1008
  });
771
- // GET /api/playgrounds/:name/changes-summaryAI 변경 요약
772
- app.get("/api/playgrounds/:name/changes-summary", async (req, res) => {
1009
+ // GET /api/playgrounds/:name/difffull diff or per-file diff (?file=path)
1010
+ app.get("/api/playgrounds/:name/diff", (req, res) => {
773
1011
  const { name } = req.params;
774
1012
  if (!getOne(name))
775
1013
  return res.status(404).json({ error: "not found" });
776
1014
  try {
777
1015
  const wtPath = campPath(name);
778
- const diff = spawnSync("git", ["-C", wtPath, "diff", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
779
- if (!diff.trim())
780
- return res.json({ summary: null });
781
- const result = spawnSync("claude", ["-p", "--model", "haiku", `이 git diff를 한국어 한 줄(20자 이내)로 요약해. 설명 없이 요약만:\n\n${diff.slice(0, 2000)}`], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
782
- const summary = result.status === 0 ? (result.stdout ?? "").trim() : null;
783
- res.json({ summary });
1016
+ const filePath = typeof req.query.file === "string" ? req.query.file : null;
1017
+ if (filePath) {
1018
+ // Per-file diff
1019
+ const tracked = spawnSync("git", ["-C", wtPath, "diff", "--", filePath], {
1020
+ encoding: "utf8",
1021
+ stdio: "pipe",
1022
+ }).stdout || "";
1023
+ if (tracked) {
1024
+ return res.json({ type: "diff", diff: tracked, path: filePath });
1025
+ }
1026
+ // Might be untracked (new file) — read content
1027
+ const fullPath = join(wtPath, filePath);
1028
+ if (existsSync(fullPath)) {
1029
+ const content = readFileSync(fullPath, "utf8");
1030
+ return res.json({ type: "new", content, path: filePath });
1031
+ }
1032
+ return res.json({ type: "deleted", diff: "", path: filePath });
1033
+ }
1034
+ // Full diff
1035
+ const diff = spawnSync("git", ["-C", wtPath, "diff"], {
1036
+ encoding: "utf8",
1037
+ stdio: "pipe",
1038
+ }).stdout || "";
1039
+ res.json({ type: "diff", diff });
784
1040
  }
785
- catch {
786
- res.json({ summary: null });
1041
+ catch (err) {
1042
+ res.status(500).json({ error: err.message });
1043
+ }
1044
+ });
1045
+ // GET /api/playgrounds/:name/change-report — 구조화된 변경 리포트 (changes-summary 대체)
1046
+ app.get("/api/playgrounds/:name/change-report", async (req, res) => {
1047
+ const { name } = req.params;
1048
+ if (!getOne(name))
1049
+ return res.status(404).json({ error: "not found" });
1050
+ try {
1051
+ const wtPath = campPath(name);
1052
+ const files = getChangedFiles(wtPath);
1053
+ if (files.length === 0) {
1054
+ return res.json({
1055
+ files: [],
1056
+ totalCount: 0,
1057
+ byCategory: {},
1058
+ warnings: [],
1059
+ summary: null,
1060
+ humanDescription: null,
1061
+ categoryDetails: null,
1062
+ });
1063
+ }
1064
+ const validStatuses = new Set(["수정", "추가", "삭제", "새 파일"]);
1065
+ const reportFiles = files.map((f) => ({
1066
+ path: f.path,
1067
+ status: (validStatuses.has(f.status) ? f.status : "수정"),
1068
+ }));
1069
+ let report = buildChangeReport(reportFiles);
1070
+ // AI 요약은 ?ai=true 일 때만 (비용 절약)
1071
+ if (req.query.ai === "true") {
1072
+ const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat"], {
1073
+ encoding: "utf8",
1074
+ stdio: "pipe",
1075
+ }).stdout || "";
1076
+ const diff = spawnSync("git", ["-C", wtPath, "diff"], {
1077
+ encoding: "utf8",
1078
+ stdio: "pipe",
1079
+ }).stdout || "";
1080
+ report = generateReportSummary(diffStat, diff, report);
1081
+ }
1082
+ res.json(report);
1083
+ }
1084
+ catch (err) {
1085
+ res.status(500).json({ error: err.message });
1086
+ }
1087
+ });
1088
+ // GET /api/playgrounds/:name/commit-report/:hash — 특정 커밋의 변경 리포트
1089
+ app.get("/api/playgrounds/:name/commit-report/:hash", async (req, res) => {
1090
+ const { name, hash } = req.params;
1091
+ if (!getOne(name))
1092
+ return res.status(404).json({ error: "not found" });
1093
+ try {
1094
+ const wtPath = campPath(name);
1095
+ // 해당 커밋의 diff (부모 대비)
1096
+ const diffNames = spawnSync("git", ["-C", wtPath, "diff", "--name-status", `${hash}~1..${hash}`], {
1097
+ encoding: "utf8",
1098
+ stdio: "pipe",
1099
+ }).stdout?.trim() || "";
1100
+ if (!diffNames)
1101
+ return res.json({
1102
+ files: [],
1103
+ totalCount: 0,
1104
+ byCategory: {},
1105
+ warnings: [],
1106
+ summary: null,
1107
+ humanDescription: null,
1108
+ categoryDetails: null,
1109
+ });
1110
+ const validStatuses = new Set(["수정", "추가", "삭제", "새 파일"]);
1111
+ const statusMap = { M: "수정", A: "새 파일", D: "삭제" };
1112
+ const parsedFiles = diffNames.split("\n").map((line) => {
1113
+ const [st, ...pathParts] = line.split("\t");
1114
+ const mapped = statusMap[st || ""] || "수정";
1115
+ return {
1116
+ path: pathParts.join("\t"),
1117
+ status: (validStatuses.has(mapped) ? mapped : "수정"),
1118
+ };
1119
+ });
1120
+ let report = buildChangeReport(parsedFiles);
1121
+ if (req.query.ai === "true") {
1122
+ const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat", `${hash}~1..${hash}`], {
1123
+ encoding: "utf8",
1124
+ stdio: "pipe",
1125
+ }).stdout || "";
1126
+ const diff = spawnSync("git", ["-C", wtPath, "diff", `${hash}~1..${hash}`], { encoding: "utf8", stdio: "pipe" })
1127
+ .stdout || "";
1128
+ report = generateReportSummary(diffStat, diff, report);
1129
+ }
1130
+ res.json(report);
1131
+ }
1132
+ catch (err) {
1133
+ res.status(500).json({ error: err.message });
787
1134
  }
788
1135
  });
789
1136
  app.post("/api/playgrounds/:name/ship", async (req, res) => {
@@ -797,9 +1144,15 @@ export async function createApp(projectRoot, options = {}) {
797
1144
  try {
798
1145
  const wtPath = campPath(name);
799
1146
  // 1. Reattach to branch if detached
800
- const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], { encoding: "utf8", stdio: "pipe" });
1147
+ const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
1148
+ encoding: "utf8",
1149
+ stdio: "pipe",
1150
+ });
801
1151
  if (headRef.status !== 0 && pg.branch) {
802
- const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
1152
+ const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
1153
+ encoding: "utf8",
1154
+ stdio: "pipe",
1155
+ }).stdout?.trim();
803
1156
  if (cur) {
804
1157
  spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, cur], { stdio: "pipe" });
805
1158
  spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
@@ -818,8 +1171,14 @@ export async function createApp(projectRoot, options = {}) {
818
1171
  // 3. Check there are commits to ship (vs base)
819
1172
  const base = pg.baseCommit;
820
1173
  const logCheck = base
821
- ? spawnSync("git", ["-C", wtPath, "log", "--oneline", `${base}..HEAD`], { encoding: "utf8", stdio: "pipe" }).stdout?.trim()
822
- : spawnSync("git", ["-C", wtPath, "log", "--oneline", "-1", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
1174
+ ? spawnSync("git", ["-C", wtPath, "log", "--oneline", `${base}..HEAD`], {
1175
+ encoding: "utf8",
1176
+ stdio: "pipe",
1177
+ }).stdout?.trim()
1178
+ : spawnSync("git", ["-C", wtPath, "log", "--oneline", "-1", "HEAD"], {
1179
+ encoding: "utf8",
1180
+ stdio: "pipe",
1181
+ }).stdout?.trim();
823
1182
  if (!logCheck) {
824
1183
  return res.status(400).json({ error: "보낼 변경사항이 없습니다." });
825
1184
  }
@@ -851,6 +1210,33 @@ export async function createApp(projectRoot, options = {}) {
851
1210
  const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat", "HEAD~1"], { encoding: "utf8", stdio: "pipe" }).stdout ||
852
1211
  "";
853
1212
  const diff = spawnSync("git", ["-C", wtPath, "diff", "HEAD~1"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
1213
+ // Change report warnings for PR body — based on full diff since camp creation
1214
+ let reportWarnings = "";
1215
+ try {
1216
+ const base = pg.baseCommit;
1217
+ if (base) {
1218
+ const diffFiles = spawnSync("git", ["-C", wtPath, "diff", "--name-status", `${base}..HEAD`], {
1219
+ encoding: "utf8",
1220
+ stdio: "pipe",
1221
+ }).stdout?.trim() || "";
1222
+ if (diffFiles) {
1223
+ const parsedFiles = diffFiles.split("\n").map((line) => {
1224
+ const [st, ...pathParts] = line.split("\t");
1225
+ return {
1226
+ path: pathParts.join("\t"),
1227
+ status: (st === "M" ? "수정" : st === "D" ? "삭제" : st === "A" ? "추가" : "수정"),
1228
+ };
1229
+ });
1230
+ const shipReport = buildChangeReport(parsedFiles);
1231
+ if (shipReport.warnings.length > 0) {
1232
+ reportWarnings = "\n\n### ⚠️ 주의사항\n" + shipReport.warnings.map((w) => `- ${w.message}`).join("\n");
1233
+ }
1234
+ }
1235
+ }
1236
+ }
1237
+ catch {
1238
+ /* non-critical */
1239
+ }
854
1240
  // Try Claude for rich PR body, fallback to simple
855
1241
  const claudeCheck = spawnSync("which", ["claude"], { stdio: "pipe" });
856
1242
  let prBody;
@@ -865,11 +1251,11 @@ export async function createApp(projectRoot, options = {}) {
865
1251
  });
866
1252
  prBody =
867
1253
  claudeResult.status === 0 && claudeResult.stdout?.trim()
868
- ? claudeResult.stdout.trim()
869
- : buildFallbackPrBody({ message, actions, diffStat });
1254
+ ? claudeResult.stdout.trim() + reportWarnings
1255
+ : buildFallbackPrBody({ message, actions, diffStat }) + reportWarnings;
870
1256
  }
871
1257
  else {
872
- prBody = buildFallbackPrBody({ message, actions, diffStat });
1258
+ prBody = buildFallbackPrBody({ message, actions, diffStat }) + reportWarnings;
873
1259
  }
874
1260
  const prResult = spawnSync("gh", ["pr", "create", "--title", message, "--body", prBody, "--head", branchName], { cwd: wtPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
875
1261
  if (prResult.status === 0) {
@@ -899,7 +1285,12 @@ export async function createApp(projectRoot, options = {}) {
899
1285
  if (claudeCheck.status !== 0) {
900
1286
  return res.json({ passed: true, skipped: true, reason: "claude CLI 없음 — 테스트 생략" });
901
1287
  }
902
- 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 });
1288
+ const result = spawnSync("claude", [
1289
+ "-p",
1290
+ "--model",
1291
+ "haiku",
1292
+ `이 프로젝트에서 PR 전에 돌려야 할 테스트 명령어를 찾아서 실행해줘. .github/workflows/ 디렉토리, package.json scripts, Makefile 등을 확인해. typecheck과 test를 우선 실행하고 결과를 알려줘. 명령어와 결과만 간단히.`,
1293
+ ], { cwd: wtPath, encoding: "utf8", stdio: "pipe", timeout: 120_000 });
903
1294
  const output = result.stdout?.trim() || "";
904
1295
  const failed = result.status !== 0 || /fail|error|FAIL|ERROR/i.test(output);
905
1296
  broadcast({ type: "log", name, source: "sanjang", data: failed ? "❌ 테스트 실패" : "✅ 테스트 통과" });
@@ -921,7 +1312,11 @@ export async function createApp(projectRoot, options = {}) {
921
1312
  return res.status(400).json({ error: "되돌릴 파일을 선택해주세요." });
922
1313
  // Validate file paths against traversal and shell injection
923
1314
  for (const file of files) {
924
- if (typeof file !== "string" || file.includes("..") || file.startsWith("/") || /[`$;"'\\|&]/.test(file)) {
1315
+ if (typeof file !== "string" ||
1316
+ file.includes("..") ||
1317
+ file.startsWith("/") ||
1318
+ file.startsWith("-") ||
1319
+ /[`$;"'\\|&]/.test(file)) {
925
1320
  return res.status(400).json({ error: `invalid file path: ${file}` });
926
1321
  }
927
1322
  }
@@ -1004,7 +1399,16 @@ export async function createApp(projectRoot, options = {}) {
1004
1399
  stdio: "pipe",
1005
1400
  }).stdout || "";
1006
1401
  const conflictFiles = parseConflictFiles(statusOut);
1007
- res.json({ synced: false, conflict: true, conflictFiles, message: "충돌이 있습니다. 어떻게 할까요?" });
1402
+ const conflictDetails = conflictFiles.map((f) => {
1403
+ try {
1404
+ const content = readFileSync(join(campPath(name), f), "utf8");
1405
+ return { path: f, sections: parseConflictSections(content) };
1406
+ }
1407
+ catch {
1408
+ return { path: f, sections: [] };
1409
+ }
1410
+ });
1411
+ res.json({ synced: false, conflict: true, conflictFiles, conflictDetails, message: "충돌이 있습니다. 어떻게 할까요?" });
1008
1412
  }
1009
1413
  else {
1010
1414
  broadcast({ type: "playground-synced", name });
@@ -1019,7 +1423,16 @@ export async function createApp(projectRoot, options = {}) {
1019
1423
  stdio: "pipe",
1020
1424
  }).stdout || "";
1021
1425
  const conflictFiles = parseConflictFiles(statusOut);
1022
- res.json({ synced: false, conflict: true, conflictFiles, message: "충돌이 있습니다. 어떻게 할까요?" });
1426
+ const conflictDetails = conflictFiles.map((f) => {
1427
+ try {
1428
+ const content = readFileSync(join(campPath(name), f), "utf8");
1429
+ return { path: f, sections: parseConflictSections(content) };
1430
+ }
1431
+ catch {
1432
+ return { path: f, sections: [] };
1433
+ }
1434
+ });
1435
+ res.json({ synced: false, conflict: true, conflictFiles, conflictDetails, message: "충돌이 있습니다. 어떻게 할까요?" });
1023
1436
  }
1024
1437
  else {
1025
1438
  res.status(500).json({ error: err.message });
@@ -1105,13 +1518,95 @@ export async function createApp(projectRoot, options = {}) {
1105
1518
  spawnSync("git", ["-C", wtPath, "merge", "--abort"], { stdio: "pipe" });
1106
1519
  res.json({ aborted: true });
1107
1520
  });
1108
- // Task runner removed use Claude Code directly in the terminal
1521
+ // POST /api/playgrounds/:name/resolve-fileresolve a single file
1522
+ app.post("/api/playgrounds/:name/resolve-file", (req, res) => {
1523
+ const { name } = req.params;
1524
+ const { path: filePath, strategy } = req.body ?? {};
1525
+ if (!getOne(name))
1526
+ return res.status(404).json({ error: "not found" });
1527
+ if (!filePath || !["ours", "theirs"].includes(strategy)) {
1528
+ return res.status(400).json({ error: "path and strategy (ours/theirs) required" });
1529
+ }
1530
+ const wtPath = campPath(name);
1531
+ spawnSync("git", ["-C", wtPath, "checkout", `--${strategy}`, "--", filePath], { stdio: "pipe" });
1532
+ spawnSync("git", ["-C", wtPath, "add", "--", filePath], { stdio: "pipe" });
1533
+ // Check if all conflicts are resolved
1534
+ const statusOut = spawnSync("git", ["-C", wtPath, "status", "--porcelain"], {
1535
+ encoding: "utf8",
1536
+ stdio: "pipe",
1537
+ }).stdout || "";
1538
+ const remaining = parseConflictFiles(statusOut);
1539
+ res.json({ resolved: true, file: filePath, strategy, remaining: remaining.length });
1540
+ });
1541
+ // POST /api/playgrounds/:name/resolve-finalize — commit after all files resolved
1542
+ app.post("/api/playgrounds/:name/resolve-finalize", (req, res) => {
1543
+ const { name } = req.params;
1544
+ if (!getOne(name))
1545
+ return res.status(404).json({ error: "not found" });
1546
+ const wtPath = campPath(name);
1547
+ const result = spawnSync("git", ["-C", wtPath, "commit", "--no-edit"], {
1548
+ encoding: "utf8",
1549
+ stdio: "pipe",
1550
+ });
1551
+ if (result.status === 0) {
1552
+ broadcast({ type: "conflict-resolved", name });
1553
+ res.json({ finalized: true });
1554
+ }
1555
+ else {
1556
+ res.status(500).json({ error: (result.stderr || "").trim() || "커밋 실패" });
1557
+ }
1558
+ });
1559
+ // POST /api/playgrounds/:name/test — run test command and stream output
1560
+ const testProcs = new Map();
1561
+ app.post("/api/playgrounds/:name/test", (req, res) => {
1562
+ const { name } = req.params;
1563
+ const pg = getOne(name);
1564
+ if (!pg)
1565
+ return res.status(404).json({ error: "not found" });
1566
+ // Kill existing test if running
1567
+ const existing = testProcs.get(name);
1568
+ if (existing) {
1569
+ try {
1570
+ existing.kill("SIGTERM");
1571
+ }
1572
+ catch { /* */ }
1573
+ testProcs.delete(name);
1574
+ }
1575
+ const wtPath = campPath(name);
1576
+ const testCmd = config.test?.command || detectTestCommand(projectRoot, config.dev.cwd) || "npm test";
1577
+ const testCwd = config.test?.cwd ? join(wtPath, config.test.cwd) : join(wtPath, config.dev.cwd || ".");
1578
+ const child = spawn(testCmd, [], {
1579
+ cwd: testCwd,
1580
+ shell: true,
1581
+ stdio: ["ignore", "pipe", "pipe"],
1582
+ env: { ...process.env, FORCE_COLOR: "1", CI: "true" },
1583
+ });
1584
+ testProcs.set(name, child);
1585
+ broadcast({ type: "test-started", name });
1586
+ child.stdout.on("data", (d) => {
1587
+ broadcast({ type: "test-output", name, data: { text: d.toString() } });
1588
+ });
1589
+ child.stderr.on("data", (d) => {
1590
+ broadcast({ type: "test-output", name, data: { text: d.toString() } });
1591
+ });
1592
+ child.on("close", (code) => {
1593
+ testProcs.delete(name);
1594
+ broadcast({ type: "test-done", name, data: { exitCode: code ?? 1 } });
1595
+ });
1596
+ child.on("error", (err) => {
1597
+ testProcs.delete(name);
1598
+ broadcast({ type: "test-done", name, data: { exitCode: 1, error: err.message } });
1599
+ });
1600
+ res.json({ started: true, command: testCmd });
1601
+ });
1109
1602
  // POST /api/playgrounds/:name/enter — 캠프 진입 (정보 조회만, 터미널은 별도)
1110
1603
  app.post("/api/playgrounds/:name/enter", async (req, res) => {
1111
1604
  const { name } = req.params;
1112
1605
  const pg = getOne(name);
1113
1606
  if (!pg)
1114
1607
  return res.status(404).json({ error: "not found" });
1608
+ // Track last access
1609
+ upsert({ ...pg, lastAccessedAt: new Date().toISOString() });
1115
1610
  const wtPath = campPath(name);
1116
1611
  // 변경사항 조회
1117
1612
  let changes = { count: 0, files: [], actions: [] };
@@ -1131,7 +1626,7 @@ export async function createApp(projectRoot, options = {}) {
1131
1626
  : ["-C", wtPath, "log", "--oneline", "--format=%h\t%s\t%cr", "--max-count=5", "HEAD"];
1132
1627
  const log = spawnSync("git", logArgs, { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || "";
1133
1628
  if (log) {
1134
- commits = log.split("\n").map(line => {
1629
+ commits = log.split("\n").map((line) => {
1135
1630
  const [hash = "", message = "", date = ""] = line.split("\t");
1136
1631
  return { hash, message, date };
1137
1632
  });
@@ -1270,14 +1765,15 @@ export async function createApp(projectRoot, options = {}) {
1270
1765
  setConfig(freshConfig2);
1271
1766
  if (freshConfig2.ports)
1272
1767
  setPortConfig(freshConfig2.ports);
1273
- const { slot, fePort, bePort } = allocate(existing);
1768
+ const { slot, fePort, bePort } = await allocate(existing);
1274
1769
  const actualFePort2 = freshConfig2.dev?.portFlag ? fePort : freshConfig2.dev?.port || fePort;
1275
1770
  await addWorktree(name, branch);
1276
1771
  const wtPath = campPath(name);
1277
1772
  copyCampFiles(projectRoot, wtPath, freshConfig2.copyFiles, (msg) => {
1278
1773
  broadcast({ type: "log", name, source: "sanjang", data: msg });
1279
1774
  });
1280
- const baseCommit2 = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || undefined;
1775
+ const baseCommit2 = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() ||
1776
+ undefined;
1281
1777
  const record = {
1282
1778
  name,
1283
1779
  branch,
@@ -1292,7 +1788,7 @@ export async function createApp(projectRoot, options = {}) {
1292
1788
  upsert(record);
1293
1789
  broadcast({ type: "playground-created", name, data: record });
1294
1790
  res.status(201).json(record);
1295
- setupCampDeps(name, wtPath, freshConfig2, broadcast);
1791
+ setupCampDeps(name, wtPath, freshConfig2, broadcast, () => triggerStart(name));
1296
1792
  }
1297
1793
  catch (err) {
1298
1794
  try {
@@ -1344,7 +1840,8 @@ export async function createApp(projectRoot, options = {}) {
1344
1840
  stopWatcher(name);
1345
1841
  updateCampStatus(name, "starting");
1346
1842
  broadcast({ type: "playground-status", name, data: { status: "starting" } });
1347
- const detectedPort = await startCamp(pg, (event) => {
1843
+ const fixReserved = new Set(getAll().filter((c) => c.name !== name).map((c) => c.fePort));
1844
+ const detectedPort = await startCamp({ ...pg, reservedPorts: fixReserved }, (event) => {
1348
1845
  broadcast({ type: event.type, name, data: event.data, source: event.source });
1349
1846
  });
1350
1847
  const url = `http://localhost:${detectedPort}`;
@@ -1353,7 +1850,7 @@ export async function createApp(projectRoot, options = {}) {
1353
1850
  startWatcher(name);
1354
1851
  return res.json({ fixed: true, description: fix.description });
1355
1852
  }
1356
- catch (retryErr) {
1853
+ catch (_retryErr) {
1357
1854
  return res.json({ fixed: false, description: "설정을 고쳤지만 시작에 실패했습니다." });
1358
1855
  }
1359
1856
  }
@@ -1362,6 +1859,32 @@ export async function createApp(projectRoot, options = {}) {
1362
1859
  });
1363
1860
  let activityCache = null;
1364
1861
  const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
1862
+ // GET /api/compare/status — main 서버 상태
1863
+ app.get("/api/compare/status", (_req, res) => {
1864
+ res.json(getMainServerState());
1865
+ });
1866
+ // POST /api/compare/start — main 서버 시작
1867
+ app.post("/api/compare/start", async (_req, res) => {
1868
+ const mainState = getMainServerState();
1869
+ if (mainState.status === "running") {
1870
+ return res.json(mainState);
1871
+ }
1872
+ try {
1873
+ await startMainServer(projectRoot, config, {
1874
+ onReady: (port) => { broadcast({ type: "compare-ready", data: { port } }); },
1875
+ onLog: (msg) => { broadcast({ type: "log", name: "__main__", source: "sanjang", data: msg }); },
1876
+ });
1877
+ res.json(getMainServerState());
1878
+ }
1879
+ catch (err) {
1880
+ res.status(500).json({ error: err.message });
1881
+ }
1882
+ });
1883
+ // POST /api/compare/stop — main 서버 중지
1884
+ app.post("/api/compare/stop", (_req, res) => {
1885
+ stopMainServer();
1886
+ res.json({ ok: true });
1887
+ });
1365
1888
  app.get("/api/activity", (_req, res) => {
1366
1889
  if (activityCache && Date.now() - activityCache.ts < ACTIVITY_CACHE_TTL) {
1367
1890
  return res.json(activityCache.data);
@@ -1413,8 +1936,6 @@ export async function createApp(projectRoot, options = {}) {
1413
1936
  streak++;
1414
1937
  }
1415
1938
  else if (key === todayStr) {
1416
- // Today having 0 commits is ok — streak can start from yesterday
1417
- continue;
1418
1939
  }
1419
1940
  else {
1420
1941
  break;
@@ -1424,26 +1945,111 @@ export async function createApp(projectRoot, options = {}) {
1424
1945
  activityCache = { data, ts: Date.now() };
1425
1946
  res.json(data);
1426
1947
  });
1427
- // SPA fallback
1948
+ // Vite resource proxy — catch-all for non-dashboard paths.
1949
+ // Instead of whitelisting Vite prefixes (/@, /src/, /node_modules/, /@fs/, /.vite/, ...),
1950
+ // we blacklist known dashboard routes and proxy everything else to a running camp.
1951
+ app.use((req, res, next) => {
1952
+ const url = req.url;
1953
+ // Dashboard routes — let them through to SPA fallback
1954
+ if (url === "/" || url.startsWith("/api/") || url.startsWith("/preview/")) {
1955
+ return next();
1956
+ }
1957
+ // Dashboard static files are already handled by express.static above
1958
+ // Determine target dev server port (camp or main comparison server)
1959
+ const referer = req.headers.referer || "";
1960
+ const refMatch = /\/preview\/(\d+)/.exec(referer);
1961
+ const camps = getAll();
1962
+ const runningCamps = camps.filter((c) => c.status === "running");
1963
+ const mainState = getMainServerState();
1964
+ // Build set of all known dev server ports
1965
+ const knownPorts = new Set(camps.map((c) => c.fePort));
1966
+ if (mainState.status === "running" && mainState.port)
1967
+ knownPorts.add(mainState.port);
1968
+ let targetPort = null;
1969
+ if (refMatch) {
1970
+ const port = parseInt(refMatch[1], 10);
1971
+ if (knownPorts.has(port))
1972
+ targetPort = port;
1973
+ }
1974
+ // Fallback: extract camp name from /@fs/ path containing .sanjang/camps/<name>/
1975
+ // in either the request URL or the Referer. This handles nested module imports
1976
+ // whose Referer is another /@fs/ module URL (not the preview page URL).
1977
+ if (!targetPort) {
1978
+ const fsPattern = /\/@fs\/.*?\/\.sanjang\/camps\/([^/]+)\//;
1979
+ const fsMatch = fsPattern.exec(url) || fsPattern.exec(referer);
1980
+ if (fsMatch) {
1981
+ const camp = camps.find((c) => c.name === fsMatch[1]);
1982
+ if (camp && camp.status === "running")
1983
+ targetPort = camp.fePort;
1984
+ }
1985
+ }
1986
+ // Fallback: read port from cookie (set by preview proxy on HTML responses).
1987
+ // This handles client-side navigation where neither URL nor Referer
1988
+ // contains camp info (e.g. goto('/login') from a SvelteKit app).
1989
+ if (!targetPort) {
1990
+ const cookieHeader = req.headers.cookie || "";
1991
+ const portCookie = /(?:^|;\s*)_sanjang_port=(\d+)/.exec(cookieHeader);
1992
+ if (portCookie) {
1993
+ const cookiePort = parseInt(portCookie[1], 10);
1994
+ if (knownPorts.has(cookiePort))
1995
+ targetPort = cookiePort;
1996
+ }
1997
+ }
1998
+ // Fall back: if only one running dev server total, use it
1999
+ const allRunningPorts = [
2000
+ ...runningCamps.map((c) => c.fePort),
2001
+ ...(mainState.status === "running" && mainState.port ? [mainState.port] : []),
2002
+ ];
2003
+ if (!targetPort && allRunningPorts.length === 1) {
2004
+ targetPort = allRunningPorts[0];
2005
+ }
2006
+ if (!targetPort)
2007
+ return next();
2008
+ // Proxy to the target Vite dev server
2009
+ const proxyReq = httpRequest({
2010
+ hostname: "localhost",
2011
+ port: targetPort,
2012
+ path: url,
2013
+ method: req.method,
2014
+ headers: { ...req.headers, host: `localhost:${targetPort}` },
2015
+ }, (proxyRes) => {
2016
+ res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
2017
+ proxyRes.pipe(res);
2018
+ });
2019
+ proxyReq.on("error", () => next());
2020
+ req.pipe(proxyReq);
2021
+ });
2022
+ // SPA fallback — only for dashboard routes that passed through above
1428
2023
  app.get("*", (_req, res) => {
1429
2024
  res.sendFile(join(dashboardDir, "index.html"));
1430
2025
  });
1431
- return { app, server, port, runningTasks, warpStatus, watchers };
2026
+ return { app, server, port, runningTasks, warpStatus, watchers, healthCheckTimer };
1432
2027
  }
1433
2028
  export async function startServer(projectRoot, options = {}) {
1434
- const { server, port, runningTasks, warpStatus, watchers } = await createApp(projectRoot, options);
2029
+ const { server, port, runningTasks, warpStatus, watchers, healthCheckTimer } = await createApp(projectRoot, options);
1435
2030
  server.listen(port, "127.0.0.1", () => {
1436
- console.log(`⛰ 산장 서버 실행 중 — http://localhost:${port}`);
2031
+ const url = `http://localhost:${port}`;
2032
+ console.log(`⛰ 산장 서버 실행 중 — ${url}`);
2033
+ startBranchRefresh();
1437
2034
  if (warpStatus.installed) {
1438
2035
  console.log(" Warp 감지됨 ✓ — 캠프 진입 시 터미널이 자동으로 열립니다");
1439
2036
  }
1440
2037
  else {
1441
2038
  console.log(" ℹ Warp를 설치하면 캠프↔터미널 자동 연동을 사용할 수 있습니다");
1442
2039
  }
2040
+ // Auto-open browser
2041
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2042
+ try {
2043
+ spawn(openCmd, [url], { stdio: "ignore", detached: true }).unref();
2044
+ }
2045
+ catch {
2046
+ // Silently ignore — CLI-only environments
2047
+ }
1443
2048
  });
1444
2049
  // Graceful shutdown
1445
2050
  function shutdown() {
1446
2051
  console.log("\n⛰ 산장 종료 중...");
2052
+ clearInterval(healthCheckTimer);
1447
2053
  for (const [, child] of runningTasks) {
1448
2054
  try {
1449
2055
  child.kill("SIGTERM");
@@ -1456,6 +2062,7 @@ export async function startServer(projectRoot, options = {}) {
1456
2062
  w.stop();
1457
2063
  watchers.clear();
1458
2064
  stopAllCamps();
2065
+ stopMainServer();
1459
2066
  server.close(() => process.exit(0));
1460
2067
  // Force exit after 10s if cleanup hangs
1461
2068
  setTimeout(() => process.exit(1), 10_000);