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.
- package/README.md +25 -13
- package/dashboard/app.js +941 -38
- package/dashboard/index.html +151 -38
- package/dashboard/style.css +374 -7
- package/dist/bin/sanjang.js +152 -7
- package/dist/lib/config.d.ts +5 -0
- package/dist/lib/config.js +29 -5
- package/dist/lib/engine/change-report.d.ts +27 -0
- package/dist/lib/engine/change-report.js +233 -0
- package/dist/lib/engine/conflict.d.ts +13 -0
- package/dist/lib/engine/conflict.js +41 -0
- package/dist/lib/engine/diagnostics.js +2 -6
- package/dist/lib/engine/main-server.d.ts +19 -0
- package/dist/lib/engine/main-server.js +181 -0
- package/dist/lib/engine/naming.js +11 -2
- package/dist/lib/engine/ports.d.ts +2 -2
- package/dist/lib/engine/ports.js +33 -23
- package/dist/lib/engine/pr.js +1 -1
- 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 -39
- package/dist/lib/engine/self-heal.js +16 -5
- package/dist/lib/engine/smart-init.js +7 -6
- package/dist/lib/engine/state.js +14 -5
- package/dist/lib/engine/suggest.js +1 -4
- package/dist/lib/engine/warp.d.ts +1 -1
- package/dist/lib/engine/warp.js +1 -1
- 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 +701 -94
- package/dist/lib/types.d.ts +25 -0
- package/package.json +2 -2
package/dist/lib/server.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"], {
|
|
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"], {
|
|
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({
|
|
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 {
|
|
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 {
|
|
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({
|
|
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:
|
|
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 {
|
|
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
|
|
293
|
-
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}});
|
|
294
406
|
origError.apply(console,arguments);
|
|
295
407
|
};
|
|
296
408
|
window.addEventListener('unhandledrejection',function(e){
|
|
297
|
-
|
|
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
|
-
|
|
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
|
|
468
|
+
const campName = camp?.name ?? "__main__";
|
|
314
469
|
const targetPath = req.url || "/";
|
|
315
|
-
const proxyReq = httpRequest({
|
|
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() ||
|
|
412
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
|
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"], {
|
|
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"], {
|
|
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", [
|
|
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/
|
|
772
|
-
app.get("/api/playgrounds/:name/
|
|
1009
|
+
// GET /api/playgrounds/:name/diff — full 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
|
|
779
|
-
if (
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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({
|
|
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"], {
|
|
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"], {
|
|
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`], {
|
|
822
|
-
|
|
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", [
|
|
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" ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1521
|
+
// POST /api/playgrounds/:name/resolve-file — resolve 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() ||
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
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
|
-
|
|
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);
|