sanjang 0.3.5 → 0.3.7

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,15 +1,16 @@
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
11
  import { buildChangeReport, generateReportSummary } from "./engine/change-report.js";
11
12
  import { applyConfigFix, suggestConfigFix } from "./engine/config-hotfix.js";
12
- import { buildConflictPrompt, parseConflictFiles } from "./engine/conflict.js";
13
+ import { buildConflictPrompt, parseConflictFiles, parseConflictSections } from "./engine/conflict.js";
13
14
  import { buildDiagnostics } from "./engine/diagnostics.js";
14
15
  import { getMainServerState, startMainServer, stopMainServer } from "./engine/main-server.js";
15
16
  import { aiSlugify, slugify } from "./engine/naming.js";
@@ -23,7 +24,7 @@ import { getAll, getOne, remove, setCampsDir, upsert } from "./engine/state.js";
23
24
  import { suggestTasks } from "./engine/suggest.js";
24
25
  import { detectWarp, openWarpTab, removeLaunchConfig } from "./engine/warp.js";
25
26
  import { CampWatcher } from "./engine/watcher.js";
26
- import { addWorktree, campPath, getProjectRoot, listBranches, removeWorktree, setProjectRoot, } from "./engine/worktree.js";
27
+ import { addWorktree, campPath, getProjectRoot, listBranches, removeWorktree, setProjectRoot, startBranchRefresh, } from "./engine/worktree.js";
27
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
28
29
  // ---------------------------------------------------------------------------
29
30
  // Error translation
@@ -99,10 +100,15 @@ function updateCampStatus(name, status, extra) {
99
100
  return; // camp may have been deleted during async setup
100
101
  upsert({ ...camp, status, ...extra });
101
102
  }
102
- function setupCampDeps(name, wtPath, cfg, broadcast) {
103
- if (!cfg.setup) {
103
+ function setupCampDeps(name, wtPath, cfg, broadcast, onReady) {
104
+ function done() {
104
105
  updateCampStatus(name, "stopped");
105
106
  broadcast({ type: "playground-status", name, data: { status: "stopped" } });
107
+ if (onReady)
108
+ onReady();
109
+ }
110
+ if (!cfg.setup) {
111
+ done();
106
112
  return;
107
113
  }
108
114
  const setupCwd = cfg.dev?.cwd || ".";
@@ -117,8 +123,7 @@ function setupCampDeps(name, wtPath, cfg, broadcast) {
117
123
  source: "sanjang",
118
124
  data: `캐시에서 node_modules 복사 완료 ✓ (${cacheResult.duration}ms)`,
119
125
  });
120
- updateCampStatus(name, "stopped");
121
- broadcast({ type: "playground-status", name, data: { status: "stopped" } });
126
+ done();
122
127
  return;
123
128
  }
124
129
  broadcast({ type: "log", name, source: "sanjang", data: `캐시 없음 (${cacheResult.reason}), 설치 중...` });
@@ -140,8 +145,7 @@ function setupCampDeps(name, wtPath, cfg, broadcast) {
140
145
  setupProc.on("close", (code) => {
141
146
  if (code === 0) {
142
147
  broadcast({ type: "log", name, source: "sanjang", data: "설치 완료 ✓" });
143
- updateCampStatus(name, "stopped");
144
- broadcast({ type: "playground-status", name, data: { status: "stopped" } });
148
+ done();
145
149
  }
146
150
  else {
147
151
  broadcast({ type: "log", name, source: "sanjang", data: `⚠️ 설치 실패 (코드 ${code})` });
@@ -257,6 +261,46 @@ export async function createApp(projectRoot, options = {}) {
257
261
  autosaveTimers.delete(name);
258
262
  }
259
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();
260
304
  // Express app
261
305
  const app = express();
262
306
  app.use(express.json());
@@ -266,7 +310,52 @@ export async function createApp(projectRoot, options = {}) {
266
310
  : join(__dirname, "..", "..", "dashboard");
267
311
  app.use(express.static(dashboardDir));
268
312
  const server = createServer(app);
269
- 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
+ });
270
359
  function broadcast(msg) {
271
360
  const text = JSON.stringify(msg);
272
361
  for (const client of wss.clients) {
@@ -279,8 +368,8 @@ export async function createApp(projectRoot, options = {}) {
279
368
  ws.on("message", (raw) => {
280
369
  try {
281
370
  const msg = JSON.parse(raw.toString());
282
- if (msg.type === "browser-error" && msg.name) {
283
- 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 });
284
373
  }
285
374
  }
286
375
  catch {
@@ -302,18 +391,64 @@ export async function createApp(projectRoot, options = {}) {
302
391
  ws.onclose=function(){setTimeout(connect,3000)};
303
392
  }
304
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 ''}
305
399
  window.addEventListener('error',function(e){
306
- 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)}});
307
401
  });
308
402
  var origError=console.error;
309
403
  console.error=function(){
310
- var args=[].slice.call(arguments).map(function(a){try{return typeof a==='object'?JSON.stringify(a):String(a)}catch(e){return String(a)}});
311
- 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}});
312
406
  origError.apply(console,arguments);
313
407
  };
314
408
  window.addEventListener('unhandledrejection',function(e){
315
- 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}});
316
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
+
317
452
  connect();
318
453
  })();
319
454
  </script>`;
@@ -333,21 +468,24 @@ export async function createApp(projectRoot, options = {}) {
333
468
  const campName = camp?.name ?? "__main__";
334
469
  const targetPath = req.url || "/";
335
470
  const proxyReq = httpRequest({
336
- hostname: "127.0.0.1",
471
+ hostname: "localhost",
337
472
  port: targetPort,
338
473
  path: targetPath,
339
474
  method: req.method,
340
- headers: { ...req.headers, host: `localhost:${targetPort}` },
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}` },
341
479
  }, (proxyRes) => {
342
480
  const contentType = proxyRes.headers["content-type"] || "";
343
481
  const isHtml = contentType.includes("text/html");
344
482
  if (!isHtml) {
345
- // Pass through non-HTML responses directly
483
+ // Pass through non-HTML responses directly (JS, CSS, images, etc.)
346
484
  res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
347
485
  proxyRes.pipe(res);
348
486
  return;
349
487
  }
350
- // Buffer HTML to inject script
488
+ // Buffer HTML to inject sanjang monitoring script (no path rewriting needed)
351
489
  const chunks = [];
352
490
  proxyRes.on("data", (chunk) => chunks.push(chunk));
353
491
  proxyRes.on("end", () => {
@@ -356,7 +494,11 @@ export async function createApp(projectRoot, options = {}) {
356
494
  .replace("data-sanjang-injected>", `data-sanjang-injected data-camp="${campName}">`);
357
495
  // Replace _sp port placeholder with actual sanjang server port
358
496
  const finalScript = script.replace("new URLSearchParams(location.search.slice(1)||'').get('_sp')||location.port", `'${port}'`);
359
- // 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.
360
502
  if (body.includes("</head>")) {
361
503
  body = body.replace("</head>", `${finalScript}</head>`);
362
504
  }
@@ -376,6 +518,15 @@ export async function createApp(projectRoot, options = {}) {
376
518
  if (typeof headers["content-security-policy"] === "string") {
377
519
  headers["content-security-policy"] = headers["content-security-policy"].replace(/frame-ancestors[^;]*(;|$)/gi, "");
378
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];
379
530
  res.writeHead(proxyRes.statusCode ?? 200, headers);
380
531
  res.end(body);
381
532
  });
@@ -389,10 +540,42 @@ export async function createApp(projectRoot, options = {}) {
389
540
  // -------------------------------------------------------------------------
390
541
  // REST API
391
542
  // -------------------------------------------------------------------------
543
+ // CORS for extension — localhost cross-port requests
544
+ app.use("/api/", (req, res, next) => {
545
+ const origin = req.headers.origin || "";
546
+ if (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:") ||
547
+ origin.startsWith("https://localhost:") || origin.startsWith("https://127.0.0.1:")) {
548
+ res.setHeader("Access-Control-Allow-Origin", origin);
549
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
550
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
551
+ }
552
+ if (req.method === "OPTIONS")
553
+ return res.sendStatus(204);
554
+ next();
555
+ });
556
+ // Extension API — 확장이 현재 포트가 산장 캠프인지 확인
557
+ app.get("/api/camps/by-port/:port", (req, res) => {
558
+ const targetPort = parseInt(req.params.port, 10);
559
+ if (!Number.isFinite(targetPort))
560
+ return res.status(400).json({ error: "invalid port" });
561
+ const camps = getAll();
562
+ const camp = camps.find((c) => c.fePort === targetPort && c.status === "running");
563
+ if (!camp)
564
+ return res.status(404).json({ error: "not found" });
565
+ res.json({
566
+ name: camp.name,
567
+ branch: camp.branch,
568
+ status: camp.status,
569
+ fePort: camp.fePort,
570
+ });
571
+ });
572
+ app.get("/api/extension/version", (_req, res) => {
573
+ res.json({ serverVersion: "0.3.7", minExtVersion: "0.1.0" });
574
+ });
392
575
  // Project info — used by dashboard header
393
576
  const projectName = projectRoot.split("/").pop() ?? "project";
394
577
  app.get("/api/project", (_req, res) => res.json({ name: projectName }));
395
- app.get("/api/ports", (_req, res) => res.json(scanPorts()));
578
+ app.get("/api/ports", async (_req, res) => res.json(await scanPorts()));
396
579
  app.get("/api/branches", async (_req, res) => {
397
580
  try {
398
581
  res.json(await listBranches());
@@ -423,7 +606,7 @@ export async function createApp(projectRoot, options = {}) {
423
606
  setConfig(freshConfig);
424
607
  if (freshConfig.ports)
425
608
  setPortConfig(freshConfig.ports);
426
- const { slot, fePort, bePort } = allocate(existing);
609
+ const { slot, fePort, bePort } = await allocate(existing);
427
610
  // When portFlag is null, dev server uses its own fixed port
428
611
  const actualFePort = freshConfig.dev?.portFlag ? fePort : freshConfig.dev?.port || fePort;
429
612
  await addWorktree(name, branch);
@@ -447,7 +630,7 @@ export async function createApp(projectRoot, options = {}) {
447
630
  upsert(record);
448
631
  broadcast({ type: "playground-created", name, data: record });
449
632
  res.status(201).json(record);
450
- setupCampDeps(name, wtPath, freshConfig, broadcast);
633
+ setupCampDeps(name, wtPath, freshConfig, broadcast, () => triggerStart(name));
451
634
  }
452
635
  catch (err) {
453
636
  // Clean up orphan worktree on failure
@@ -462,24 +645,27 @@ export async function createApp(projectRoot, options = {}) {
462
645
  });
463
646
  // Track in-flight start operations
464
647
  const startingSet = new Set();
465
- app.post("/api/playgrounds/:name/start", async (req, res) => {
466
- const { name } = req.params;
648
+ /** Internal start logic shared by API handler and auto-start after setup */
649
+ function triggerStart(name) {
467
650
  const pg = getOne(name);
468
- if (!pg)
469
- return res.status(404).json({ error: "not found" });
470
- if (startingSet.has(name))
471
- return res.json({ status: "already-starting" });
651
+ if (!pg || startingSet.has(name))
652
+ return;
472
653
  startingSet.add(name);
473
654
  upsert({ ...pg, status: "starting" });
474
655
  broadcast({ type: "playground-status", name, data: { status: "starting" } });
475
- res.json({ status: "starting" });
476
656
  (async () => {
477
657
  try {
478
- const detectedPort = await startCamp(pg, (event) => {
658
+ const reservedPorts = new Set(getAll().filter((c) => c.name !== name).map((c) => c.fePort));
659
+ const detectedPort = await startCamp({ ...pg, reservedPorts }, (event) => {
479
660
  broadcast({ type: event.type, name, data: event.data, source: event.source });
480
661
  });
662
+ if (!getOne(name)) {
663
+ stopCamp(name);
664
+ startingSet.delete(name);
665
+ return;
666
+ }
481
667
  const url = `http://localhost:${detectedPort}`;
482
- const updatedCamp = { ...getOne(name), status: "running", fePort: detectedPort, url };
668
+ const updatedCamp = { ...(getOne(name) ?? pg), status: "running", fePort: detectedPort, url };
483
669
  upsert(updatedCamp);
484
670
  broadcast({ type: "playground-status", name, data: { status: "running", url } });
485
671
  startWatcher(name);
@@ -487,7 +673,6 @@ export async function createApp(projectRoot, options = {}) {
487
673
  catch (err) {
488
674
  const current = getOne(name) ?? pg;
489
675
  const processInfo = getProcessInfo(name) ?? { feLogs: [], feExitCode: null };
490
- // Self-heal: try to auto-fix before giving up
491
676
  const healActions = diagnoseFromLogs(processInfo.feLogs);
492
677
  const autoFixable = healActions.filter((a) => a.auto);
493
678
  if (autoFixable.length > 0) {
@@ -506,15 +691,21 @@ export async function createApp(projectRoot, options = {}) {
506
691
  upsert({ ...current, status: "starting" });
507
692
  broadcast({ type: "playground-status", name, data: { status: "starting" } });
508
693
  try {
509
- const retryPort = await startCamp(current, (event) => {
694
+ const retryReserved = new Set(getAll().filter((c) => c.name !== name).map((c) => c.fePort));
695
+ const retryPort = await startCamp({ ...current, reservedPorts: retryReserved }, (event) => {
510
696
  broadcast({ type: event.type, name, data: event.data, source: event.source });
511
697
  });
512
698
  const retryUrl = `http://localhost:${retryPort}`;
513
- upsert({ ...getOne(name), status: "running", fePort: retryPort, url: retryUrl });
699
+ const retryState = getOne(name);
700
+ if (!retryState) {
701
+ stopCamp(name);
702
+ return;
703
+ }
704
+ upsert({ ...retryState, status: "running", fePort: retryPort, url: retryUrl });
514
705
  broadcast({ type: "playground-status", name, data: { status: "running", url: retryUrl } });
515
706
  startWatcher(name);
516
707
  broadcast({ type: "log", name, source: "sanjang", data: "자동 복구 성공 ✓" });
517
- return; // healed successfully
708
+ return;
518
709
  }
519
710
  catch {
520
711
  // retry failed too, fall through to error
@@ -530,6 +721,16 @@ export async function createApp(projectRoot, options = {}) {
530
721
  startingSet.delete(name);
531
722
  }
532
723
  })();
724
+ }
725
+ app.post("/api/playgrounds/:name/start", async (req, res) => {
726
+ const { name } = req.params;
727
+ const pg = getOne(name);
728
+ if (!pg)
729
+ return res.status(404).json({ error: "not found" });
730
+ if (startingSet.has(name))
731
+ return res.json({ status: "already-starting" });
732
+ triggerStart(name);
733
+ res.json({ status: "starting" });
533
734
  });
534
735
  app.post("/api/playgrounds/:name/stop", (req, res) => {
535
736
  const { name } = req.params;
@@ -735,6 +936,29 @@ export async function createApp(projectRoot, options = {}) {
735
936
  // -------------------------------------------------------------------------
736
937
  // Cache management
737
938
  // -------------------------------------------------------------------------
939
+ // GET /api/camps/stale — camps not accessed in N days (default 7)
940
+ app.get("/api/camps/stale", (_req, res) => {
941
+ const days = parseInt(String(_req.query.days || "7"), 10);
942
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
943
+ const camps = getAll();
944
+ const stale = camps.filter((c) => {
945
+ if (!c.lastAccessedAt)
946
+ return true; // never tracked
947
+ return new Date(c.lastAccessedAt).getTime() < cutoff;
948
+ });
949
+ // Get disk usage for stale camps
950
+ const result = stale.map((c) => {
951
+ const wtPath = campPath(c.name);
952
+ let size = "?";
953
+ try {
954
+ const du = spawnSync("du", ["-sh", wtPath], { encoding: "utf8", stdio: "pipe", timeout: 5000 });
955
+ size = du.stdout?.split("\t")[0]?.trim() || "?";
956
+ }
957
+ catch { /* */ }
958
+ return { name: c.name, branch: c.branch, status: c.status, lastAccessedAt: c.lastAccessedAt, size };
959
+ });
960
+ res.json(result);
961
+ });
738
962
  app.get("/api/cache/status", (_req, res) => {
739
963
  const setupCwd = config.dev?.cwd || ".";
740
964
  res.json(isCacheValid(projectRoot, setupCwd));
@@ -814,6 +1038,42 @@ export async function createApp(projectRoot, options = {}) {
814
1038
  res.status(500).json({ error: err.message });
815
1039
  }
816
1040
  });
1041
+ // GET /api/playgrounds/:name/diff — full diff or per-file diff (?file=path)
1042
+ app.get("/api/playgrounds/:name/diff", (req, res) => {
1043
+ const { name } = req.params;
1044
+ if (!getOne(name))
1045
+ return res.status(404).json({ error: "not found" });
1046
+ try {
1047
+ const wtPath = campPath(name);
1048
+ const filePath = typeof req.query.file === "string" ? req.query.file : null;
1049
+ if (filePath) {
1050
+ // Per-file diff
1051
+ const tracked = spawnSync("git", ["-C", wtPath, "diff", "--", filePath], {
1052
+ encoding: "utf8",
1053
+ stdio: "pipe",
1054
+ }).stdout || "";
1055
+ if (tracked) {
1056
+ return res.json({ type: "diff", diff: tracked, path: filePath });
1057
+ }
1058
+ // Might be untracked (new file) — read content
1059
+ const fullPath = join(wtPath, filePath);
1060
+ if (existsSync(fullPath)) {
1061
+ const content = readFileSync(fullPath, "utf8");
1062
+ return res.json({ type: "new", content, path: filePath });
1063
+ }
1064
+ return res.json({ type: "deleted", diff: "", path: filePath });
1065
+ }
1066
+ // Full diff
1067
+ const diff = spawnSync("git", ["-C", wtPath, "diff"], {
1068
+ encoding: "utf8",
1069
+ stdio: "pipe",
1070
+ }).stdout || "";
1071
+ res.json({ type: "diff", diff });
1072
+ }
1073
+ catch (err) {
1074
+ res.status(500).json({ error: err.message });
1075
+ }
1076
+ });
817
1077
  // GET /api/playgrounds/:name/change-report — 구조화된 변경 리포트 (changes-summary 대체)
818
1078
  app.get("/api/playgrounds/:name/change-report", async (req, res) => {
819
1079
  const { name } = req.params;
@@ -1171,7 +1431,16 @@ export async function createApp(projectRoot, options = {}) {
1171
1431
  stdio: "pipe",
1172
1432
  }).stdout || "";
1173
1433
  const conflictFiles = parseConflictFiles(statusOut);
1174
- res.json({ synced: false, conflict: true, conflictFiles, message: "충돌이 있습니다. 어떻게 할까요?" });
1434
+ const conflictDetails = conflictFiles.map((f) => {
1435
+ try {
1436
+ const content = readFileSync(join(campPath(name), f), "utf8");
1437
+ return { path: f, sections: parseConflictSections(content) };
1438
+ }
1439
+ catch {
1440
+ return { path: f, sections: [] };
1441
+ }
1442
+ });
1443
+ res.json({ synced: false, conflict: true, conflictFiles, conflictDetails, message: "충돌이 있습니다. 어떻게 할까요?" });
1175
1444
  }
1176
1445
  else {
1177
1446
  broadcast({ type: "playground-synced", name });
@@ -1186,7 +1455,16 @@ export async function createApp(projectRoot, options = {}) {
1186
1455
  stdio: "pipe",
1187
1456
  }).stdout || "";
1188
1457
  const conflictFiles = parseConflictFiles(statusOut);
1189
- res.json({ synced: false, conflict: true, conflictFiles, message: "충돌이 있습니다. 어떻게 할까요?" });
1458
+ const conflictDetails = conflictFiles.map((f) => {
1459
+ try {
1460
+ const content = readFileSync(join(campPath(name), f), "utf8");
1461
+ return { path: f, sections: parseConflictSections(content) };
1462
+ }
1463
+ catch {
1464
+ return { path: f, sections: [] };
1465
+ }
1466
+ });
1467
+ res.json({ synced: false, conflict: true, conflictFiles, conflictDetails, message: "충돌이 있습니다. 어떻게 할까요?" });
1190
1468
  }
1191
1469
  else {
1192
1470
  res.status(500).json({ error: err.message });
@@ -1272,13 +1550,95 @@ export async function createApp(projectRoot, options = {}) {
1272
1550
  spawnSync("git", ["-C", wtPath, "merge", "--abort"], { stdio: "pipe" });
1273
1551
  res.json({ aborted: true });
1274
1552
  });
1275
- // Task runner removed use Claude Code directly in the terminal
1553
+ // POST /api/playgrounds/:name/resolve-fileresolve a single file
1554
+ app.post("/api/playgrounds/:name/resolve-file", (req, res) => {
1555
+ const { name } = req.params;
1556
+ const { path: filePath, strategy } = req.body ?? {};
1557
+ if (!getOne(name))
1558
+ return res.status(404).json({ error: "not found" });
1559
+ if (!filePath || !["ours", "theirs"].includes(strategy)) {
1560
+ return res.status(400).json({ error: "path and strategy (ours/theirs) required" });
1561
+ }
1562
+ const wtPath = campPath(name);
1563
+ spawnSync("git", ["-C", wtPath, "checkout", `--${strategy}`, "--", filePath], { stdio: "pipe" });
1564
+ spawnSync("git", ["-C", wtPath, "add", "--", filePath], { stdio: "pipe" });
1565
+ // Check if all conflicts are resolved
1566
+ const statusOut = spawnSync("git", ["-C", wtPath, "status", "--porcelain"], {
1567
+ encoding: "utf8",
1568
+ stdio: "pipe",
1569
+ }).stdout || "";
1570
+ const remaining = parseConflictFiles(statusOut);
1571
+ res.json({ resolved: true, file: filePath, strategy, remaining: remaining.length });
1572
+ });
1573
+ // POST /api/playgrounds/:name/resolve-finalize — commit after all files resolved
1574
+ app.post("/api/playgrounds/:name/resolve-finalize", (req, res) => {
1575
+ const { name } = req.params;
1576
+ if (!getOne(name))
1577
+ return res.status(404).json({ error: "not found" });
1578
+ const wtPath = campPath(name);
1579
+ const result = spawnSync("git", ["-C", wtPath, "commit", "--no-edit"], {
1580
+ encoding: "utf8",
1581
+ stdio: "pipe",
1582
+ });
1583
+ if (result.status === 0) {
1584
+ broadcast({ type: "conflict-resolved", name });
1585
+ res.json({ finalized: true });
1586
+ }
1587
+ else {
1588
+ res.status(500).json({ error: (result.stderr || "").trim() || "커밋 실패" });
1589
+ }
1590
+ });
1591
+ // POST /api/playgrounds/:name/test — run test command and stream output
1592
+ const testProcs = new Map();
1593
+ app.post("/api/playgrounds/:name/test", (req, res) => {
1594
+ const { name } = req.params;
1595
+ const pg = getOne(name);
1596
+ if (!pg)
1597
+ return res.status(404).json({ error: "not found" });
1598
+ // Kill existing test if running
1599
+ const existing = testProcs.get(name);
1600
+ if (existing) {
1601
+ try {
1602
+ existing.kill("SIGTERM");
1603
+ }
1604
+ catch { /* */ }
1605
+ testProcs.delete(name);
1606
+ }
1607
+ const wtPath = campPath(name);
1608
+ const testCmd = config.test?.command || detectTestCommand(projectRoot, config.dev.cwd) || "npm test";
1609
+ const testCwd = config.test?.cwd ? join(wtPath, config.test.cwd) : join(wtPath, config.dev.cwd || ".");
1610
+ const child = spawn(testCmd, [], {
1611
+ cwd: testCwd,
1612
+ shell: true,
1613
+ stdio: ["ignore", "pipe", "pipe"],
1614
+ env: { ...process.env, FORCE_COLOR: "1", CI: "true" },
1615
+ });
1616
+ testProcs.set(name, child);
1617
+ broadcast({ type: "test-started", name });
1618
+ child.stdout.on("data", (d) => {
1619
+ broadcast({ type: "test-output", name, data: { text: d.toString() } });
1620
+ });
1621
+ child.stderr.on("data", (d) => {
1622
+ broadcast({ type: "test-output", name, data: { text: d.toString() } });
1623
+ });
1624
+ child.on("close", (code) => {
1625
+ testProcs.delete(name);
1626
+ broadcast({ type: "test-done", name, data: { exitCode: code ?? 1 } });
1627
+ });
1628
+ child.on("error", (err) => {
1629
+ testProcs.delete(name);
1630
+ broadcast({ type: "test-done", name, data: { exitCode: 1, error: err.message } });
1631
+ });
1632
+ res.json({ started: true, command: testCmd });
1633
+ });
1276
1634
  // POST /api/playgrounds/:name/enter — 캠프 진입 (정보 조회만, 터미널은 별도)
1277
1635
  app.post("/api/playgrounds/:name/enter", async (req, res) => {
1278
1636
  const { name } = req.params;
1279
1637
  const pg = getOne(name);
1280
1638
  if (!pg)
1281
1639
  return res.status(404).json({ error: "not found" });
1640
+ // Track last access
1641
+ upsert({ ...pg, lastAccessedAt: new Date().toISOString() });
1282
1642
  const wtPath = campPath(name);
1283
1643
  // 변경사항 조회
1284
1644
  let changes = { count: 0, files: [], actions: [] };
@@ -1437,7 +1797,7 @@ export async function createApp(projectRoot, options = {}) {
1437
1797
  setConfig(freshConfig2);
1438
1798
  if (freshConfig2.ports)
1439
1799
  setPortConfig(freshConfig2.ports);
1440
- const { slot, fePort, bePort } = allocate(existing);
1800
+ const { slot, fePort, bePort } = await allocate(existing);
1441
1801
  const actualFePort2 = freshConfig2.dev?.portFlag ? fePort : freshConfig2.dev?.port || fePort;
1442
1802
  await addWorktree(name, branch);
1443
1803
  const wtPath = campPath(name);
@@ -1460,7 +1820,7 @@ export async function createApp(projectRoot, options = {}) {
1460
1820
  upsert(record);
1461
1821
  broadcast({ type: "playground-created", name, data: record });
1462
1822
  res.status(201).json(record);
1463
- setupCampDeps(name, wtPath, freshConfig2, broadcast);
1823
+ setupCampDeps(name, wtPath, freshConfig2, broadcast, () => triggerStart(name));
1464
1824
  }
1465
1825
  catch (err) {
1466
1826
  try {
@@ -1512,7 +1872,8 @@ export async function createApp(projectRoot, options = {}) {
1512
1872
  stopWatcher(name);
1513
1873
  updateCampStatus(name, "starting");
1514
1874
  broadcast({ type: "playground-status", name, data: { status: "starting" } });
1515
- const detectedPort = await startCamp(pg, (event) => {
1875
+ const fixReserved = new Set(getAll().filter((c) => c.name !== name).map((c) => c.fePort));
1876
+ const detectedPort = await startCamp({ ...pg, reservedPorts: fixReserved }, (event) => {
1516
1877
  broadcast({ type: event.type, name, data: event.data, source: event.source });
1517
1878
  });
1518
1879
  const url = `http://localhost:${detectedPort}`;
@@ -1541,8 +1902,9 @@ export async function createApp(projectRoot, options = {}) {
1541
1902
  return res.json(mainState);
1542
1903
  }
1543
1904
  try {
1544
- await startMainServer(projectRoot, config, (port) => {
1545
- broadcast({ type: "compare-ready", data: { port } });
1905
+ await startMainServer(projectRoot, config, {
1906
+ onReady: (port) => { broadcast({ type: "compare-ready", data: { port } }); },
1907
+ onLog: (msg) => { broadcast({ type: "log", name: "__main__", source: "sanjang", data: msg }); },
1546
1908
  });
1547
1909
  res.json(getMainServerState());
1548
1910
  }
@@ -1615,26 +1977,111 @@ export async function createApp(projectRoot, options = {}) {
1615
1977
  activityCache = { data, ts: Date.now() };
1616
1978
  res.json(data);
1617
1979
  });
1618
- // SPA fallback
1980
+ // Vite resource proxy — catch-all for non-dashboard paths.
1981
+ // Instead of whitelisting Vite prefixes (/@, /src/, /node_modules/, /@fs/, /.vite/, ...),
1982
+ // we blacklist known dashboard routes and proxy everything else to a running camp.
1983
+ app.use((req, res, next) => {
1984
+ const url = req.url;
1985
+ // Dashboard routes — let them through to SPA fallback
1986
+ if (url === "/" || url.startsWith("/api/") || url.startsWith("/preview/")) {
1987
+ return next();
1988
+ }
1989
+ // Dashboard static files are already handled by express.static above
1990
+ // Determine target dev server port (camp or main comparison server)
1991
+ const referer = req.headers.referer || "";
1992
+ const refMatch = /\/preview\/(\d+)/.exec(referer);
1993
+ const camps = getAll();
1994
+ const runningCamps = camps.filter((c) => c.status === "running");
1995
+ const mainState = getMainServerState();
1996
+ // Build set of all known dev server ports
1997
+ const knownPorts = new Set(camps.map((c) => c.fePort));
1998
+ if (mainState.status === "running" && mainState.port)
1999
+ knownPorts.add(mainState.port);
2000
+ let targetPort = null;
2001
+ if (refMatch) {
2002
+ const port = parseInt(refMatch[1], 10);
2003
+ if (knownPorts.has(port))
2004
+ targetPort = port;
2005
+ }
2006
+ // Fallback: extract camp name from /@fs/ path containing .sanjang/camps/<name>/
2007
+ // in either the request URL or the Referer. This handles nested module imports
2008
+ // whose Referer is another /@fs/ module URL (not the preview page URL).
2009
+ if (!targetPort) {
2010
+ const fsPattern = /\/@fs\/.*?\/\.sanjang\/camps\/([^/]+)\//;
2011
+ const fsMatch = fsPattern.exec(url) || fsPattern.exec(referer);
2012
+ if (fsMatch) {
2013
+ const camp = camps.find((c) => c.name === fsMatch[1]);
2014
+ if (camp && camp.status === "running")
2015
+ targetPort = camp.fePort;
2016
+ }
2017
+ }
2018
+ // Fallback: read port from cookie (set by preview proxy on HTML responses).
2019
+ // This handles client-side navigation where neither URL nor Referer
2020
+ // contains camp info (e.g. goto('/login') from a SvelteKit app).
2021
+ if (!targetPort) {
2022
+ const cookieHeader = req.headers.cookie || "";
2023
+ const portCookie = /(?:^|;\s*)_sanjang_port=(\d+)/.exec(cookieHeader);
2024
+ if (portCookie) {
2025
+ const cookiePort = parseInt(portCookie[1], 10);
2026
+ if (knownPorts.has(cookiePort))
2027
+ targetPort = cookiePort;
2028
+ }
2029
+ }
2030
+ // Fall back: if only one running dev server total, use it
2031
+ const allRunningPorts = [
2032
+ ...runningCamps.map((c) => c.fePort),
2033
+ ...(mainState.status === "running" && mainState.port ? [mainState.port] : []),
2034
+ ];
2035
+ if (!targetPort && allRunningPorts.length === 1) {
2036
+ targetPort = allRunningPorts[0];
2037
+ }
2038
+ if (!targetPort)
2039
+ return next();
2040
+ // Proxy to the target Vite dev server
2041
+ const proxyReq = httpRequest({
2042
+ hostname: "localhost",
2043
+ port: targetPort,
2044
+ path: url,
2045
+ method: req.method,
2046
+ headers: { ...req.headers, host: `localhost:${targetPort}` },
2047
+ }, (proxyRes) => {
2048
+ res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
2049
+ proxyRes.pipe(res);
2050
+ });
2051
+ proxyReq.on("error", () => next());
2052
+ req.pipe(proxyReq);
2053
+ });
2054
+ // SPA fallback — only for dashboard routes that passed through above
1619
2055
  app.get("*", (_req, res) => {
1620
2056
  res.sendFile(join(dashboardDir, "index.html"));
1621
2057
  });
1622
- return { app, server, port, runningTasks, warpStatus, watchers };
2058
+ return { app, server, port, runningTasks, warpStatus, watchers, healthCheckTimer };
1623
2059
  }
1624
2060
  export async function startServer(projectRoot, options = {}) {
1625
- const { server, port, runningTasks, warpStatus, watchers } = await createApp(projectRoot, options);
2061
+ const { server, port, runningTasks, warpStatus, watchers, healthCheckTimer } = await createApp(projectRoot, options);
1626
2062
  server.listen(port, "127.0.0.1", () => {
1627
- console.log(`⛰ 산장 서버 실행 중 — http://localhost:${port}`);
2063
+ const url = `http://localhost:${port}`;
2064
+ console.log(`⛰ 산장 서버 실행 중 — ${url}`);
2065
+ startBranchRefresh();
1628
2066
  if (warpStatus.installed) {
1629
2067
  console.log(" Warp 감지됨 ✓ — 캠프 진입 시 터미널이 자동으로 열립니다");
1630
2068
  }
1631
2069
  else {
1632
2070
  console.log(" ℹ Warp를 설치하면 캠프↔터미널 자동 연동을 사용할 수 있습니다");
1633
2071
  }
2072
+ // Auto-open browser
2073
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2074
+ try {
2075
+ spawn(openCmd, [url], { stdio: "ignore", detached: true }).unref();
2076
+ }
2077
+ catch {
2078
+ // Silently ignore — CLI-only environments
2079
+ }
1634
2080
  });
1635
2081
  // Graceful shutdown
1636
2082
  function shutdown() {
1637
2083
  console.log("\n⛰ 산장 종료 중...");
2084
+ clearInterval(healthCheckTimer);
1638
2085
  for (const [, child] of runningTasks) {
1639
2086
  try {
1640
2087
  child.kill("SIGTERM");