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.
- package/dashboard/app.js +572 -26
- package/dashboard/index.html +136 -37
- package/dashboard/style.css +293 -4
- package/dist/bin/sanjang.js +144 -1
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.js +26 -0
- package/dist/lib/engine/conflict.d.ts +13 -0
- package/dist/lib/engine/conflict.js +41 -0
- package/dist/lib/engine/main-server.d.ts +6 -2
- package/dist/lib/engine/main-server.js +152 -82
- package/dist/lib/engine/ports.d.ts +2 -2
- package/dist/lib/engine/ports.js +33 -23
- package/dist/lib/engine/process-utils.d.ts +11 -0
- package/dist/lib/engine/process-utils.js +65 -0
- package/dist/lib/engine/process.d.ts +2 -0
- package/dist/lib/engine/process.js +27 -42
- package/dist/lib/engine/state.js +13 -4
- package/dist/lib/engine/worktree.d.ts +2 -0
- package/dist/lib/engine/worktree.js +30 -8
- package/dist/lib/server.d.ts +1 -0
- package/dist/lib/server.js +496 -49
- package/dist/lib/types.d.ts +6 -0
- package/package.json +7 -4
package/dist/lib/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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:
|
|
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
|
|
311
|
-
send({type:'browser-error',name:name,data:{level:'console.error',message:
|
|
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
|
-
|
|
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: "
|
|
471
|
+
hostname: "localhost",
|
|
337
472
|
port: targetPort,
|
|
338
473
|
path: targetPath,
|
|
339
474
|
method: req.method,
|
|
340
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1553
|
+
// POST /api/playgrounds/:name/resolve-file — resolve 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
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
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");
|