sanjang 0.3.4 → 0.3.5

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.
@@ -24,7 +24,10 @@ function detectPortFromStdout(logs, timeoutMs) {
24
24
  const port = parseInt(match[1], 10);
25
25
  // Wait briefly for the port to actually be ready
26
26
  const sock = createConnection({ port, host: "localhost" });
27
- sock.once("connect", () => { sock.destroy(); resolve(port); });
27
+ sock.once("connect", () => {
28
+ sock.destroy();
29
+ resolve(port);
30
+ });
28
31
  sock.once("error", () => {
29
32
  sock.destroy();
30
33
  // Port printed but not ready yet, retry
@@ -1,6 +1,6 @@
1
- import { existsSync, copyFileSync, mkdirSync } from "node:fs";
2
- import { join, dirname } from "node:path";
3
1
  import { execSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
4
  const PATTERNS = [
5
5
  {
6
6
  test: /Cannot find module|MODULE_NOT_FOUND/i,
@@ -64,7 +64,12 @@ export function executeHeal(action, campPath, projectRoot, setupCommand, copyFil
64
64
  if (!setupCommand)
65
65
  return { action, success: false, detail: "설치 명령이 없습니다." };
66
66
  try {
67
- execSync(setupCommand, { cwd: campPath, stdio: "pipe", timeout: 120_000, shell: true });
67
+ execSync(setupCommand, {
68
+ cwd: campPath,
69
+ stdio: "pipe",
70
+ timeout: 120_000,
71
+ shell: true,
72
+ });
68
73
  return { action, success: true };
69
74
  }
70
75
  catch {
@@ -82,10 +87,16 @@ export function executeHeal(action, campPath, projectRoot, setupCommand, copyFil
82
87
  copyFileSync(src, dst);
83
88
  copied++;
84
89
  }
85
- catch { /* skip */ }
90
+ catch {
91
+ /* skip */
92
+ }
86
93
  }
87
94
  }
88
- return { action, success: copied > 0, detail: copied > 0 ? `${copied}개 파일 복사됨` : "복사할 파일이 없습니다." };
95
+ return {
96
+ action,
97
+ success: copied > 0,
98
+ detail: copied > 0 ? `${copied}개 파일 복사됨` : "복사할 파일이 없습니다.",
99
+ };
89
100
  }
90
101
  case "restart":
91
102
  // Restart is handled by the caller (stop + start)
@@ -32,7 +32,7 @@ export function deepFindEnvFiles(projectRoot, maxDepth = 4) {
32
32
  export function detectSetupIssues(projectRoot) {
33
33
  const issues = [];
34
34
  // 1. Check for env variable references without .env files
35
- const hasEnvFile = ENV_PATTERNS.some(f => existsSync(join(projectRoot, f)));
35
+ const hasEnvFile = ENV_PATTERNS.some((f) => existsSync(join(projectRoot, f)));
36
36
  if (!hasEnvFile) {
37
37
  const envRefFound = scanForEnvReferences(projectRoot);
38
38
  if (envRefFound) {
@@ -52,7 +52,7 @@ export function detectSetupIssues(projectRoot) {
52
52
  }
53
53
  // 3. Check for missing lockfile
54
54
  const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock"];
55
- if (!lockfiles.some(f => existsSync(join(projectRoot, f)))) {
55
+ if (!lockfiles.some((f) => existsSync(join(projectRoot, f)))) {
56
56
  issues.push({
57
57
  type: "missing-lockfile",
58
58
  message: "lockfile이 없습니다. 의존성 설치가 느릴 수 있습니다.",
@@ -97,7 +97,10 @@ function scanForEnvReferences(dir, depth = 0) {
97
97
  continue;
98
98
  }
99
99
  }
100
- if (entry.isDirectory() && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".") && entry.name !== "node_modules") {
100
+ if (entry.isDirectory() &&
101
+ !SKIP_DIRS.has(entry.name) &&
102
+ !entry.name.startsWith(".") &&
103
+ entry.name !== "node_modules") {
101
104
  if (scanForEnvReferences(join(dir, entry.name), depth + 1))
102
105
  return true;
103
106
  }
@@ -129,9 +132,7 @@ function countTurboApps(root) {
129
132
  if (scripts?.dev)
130
133
  count++;
131
134
  }
132
- catch {
133
- continue;
134
- }
135
+ catch { }
135
136
  }
136
137
  }
137
138
  return count;
@@ -29,7 +29,7 @@ function read() {
29
29
  function write(records) {
30
30
  ensureDir();
31
31
  // Atomic write: write to temp file then rename to prevent corruption
32
- const tmp = stateFile() + ".tmp";
32
+ const tmp = `${stateFile()}.tmp`;
33
33
  writeFileSync(tmp, JSON.stringify(records, null, 2), "utf8");
34
34
  renameSync(tmp, stateFile());
35
35
  }
@@ -97,10 +97,7 @@ async function fetchRecentCommits(cwd) {
97
97
  export async function suggestTasks(projectRoot) {
98
98
  const results = [];
99
99
  // gh-dependent fetches — tolerate failure (gh not installed / no repo)
100
- const [issues, prs] = await Promise.allSettled([
101
- fetchIssues(projectRoot),
102
- fetchMyPrs(projectRoot),
103
- ]);
100
+ const [issues, prs] = await Promise.allSettled([fetchIssues(projectRoot), fetchMyPrs(projectRoot)]);
104
101
  // PRs first — most actionable ("이어하기")
105
102
  if (prs.status === "fulfilled") {
106
103
  results.push(...prs.value);
@@ -15,7 +15,7 @@ export declare function detectWarp(): WarpDetectResult;
15
15
  * Opens as a new tab in the existing Warp window (not a new window).
16
16
  * The tab title naturally shows the directory name (= camp name).
17
17
  */
18
- export declare function openWarpTab(campName: string, worktreePath: string): WarpOpenResult;
18
+ export declare function openWarpTab(_campName: string, worktreePath: string): WarpOpenResult;
19
19
  /**
20
20
  * No-op cleanup (launch config removed — using open -a instead).
21
21
  */
@@ -12,7 +12,7 @@ export function detectWarp() {
12
12
  * Opens as a new tab in the existing Warp window (not a new window).
13
13
  * The tab title naturally shows the directory name (= camp name).
14
14
  */
15
- export function openWarpTab(campName, worktreePath) {
15
+ export function openWarpTab(_campName, worktreePath) {
16
16
  const { installed } = detectWarp();
17
17
  if (!installed) {
18
18
  return { opened: false, terminal: null, path: worktreePath };
@@ -7,20 +7,22 @@ import express from "express";
7
7
  import { WebSocket, WebSocketServer } from "ws";
8
8
  import { loadConfig } from "./config.js";
9
9
  import { applyCacheToWorktree, buildCache, isCacheValid } from "./engine/cache.js";
10
+ import { buildChangeReport, generateReportSummary } from "./engine/change-report.js";
11
+ import { applyConfigFix, suggestConfigFix } from "./engine/config-hotfix.js";
10
12
  import { buildConflictPrompt, parseConflictFiles } from "./engine/conflict.js";
11
13
  import { buildDiagnostics } from "./engine/diagnostics.js";
14
+ import { getMainServerState, startMainServer, stopMainServer } from "./engine/main-server.js";
12
15
  import { aiSlugify, slugify } from "./engine/naming.js";
13
16
  import { allocate, scanPorts, setPortConfig } from "./engine/ports.js";
14
17
  import { buildClaudePrPrompt, buildFallbackPrBody } from "./engine/pr.js";
15
18
  import { getProcessInfo, setConfig, startCamp, stopAllCamps, stopCamp } from "./engine/process.js";
19
+ import { diagnoseFromLogs, executeHeal } from "./engine/self-heal.js";
20
+ import { generatePrDescription } from "./engine/smart-pr.js";
16
21
  import { listSnapshots, restoreSnapshot, saveSnapshot } from "./engine/snapshot.js";
17
22
  import { getAll, getOne, remove, setCampsDir, upsert } from "./engine/state.js";
23
+ import { suggestTasks } from "./engine/suggest.js";
18
24
  import { detectWarp, openWarpTab, removeLaunchConfig } from "./engine/warp.js";
19
25
  import { CampWatcher } from "./engine/watcher.js";
20
- import { diagnoseFromLogs, executeHeal } from "./engine/self-heal.js";
21
- import { suggestTasks } from "./engine/suggest.js";
22
- import { generatePrDescription } from "./engine/smart-pr.js";
23
- import { suggestConfigFix, applyConfigFix } from "./engine/config-hotfix.js";
24
26
  import { addWorktree, campPath, getProjectRoot, listBranches, removeWorktree, setProjectRoot, } from "./engine/worktree.js";
25
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
28
  // ---------------------------------------------------------------------------
@@ -192,9 +194,15 @@ export async function createApp(projectRoot, options = {}) {
192
194
  return;
193
195
  try {
194
196
  // Reattach if detached
195
- const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], { encoding: "utf8", stdio: "pipe" });
197
+ const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
198
+ encoding: "utf8",
199
+ stdio: "pipe",
200
+ });
196
201
  if (headRef.status !== 0 && pg.branch) {
197
- const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
202
+ const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
203
+ encoding: "utf8",
204
+ stdio: "pipe",
205
+ }).stdout?.trim();
198
206
  if (cur) {
199
207
  spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, cur], { stdio: "pipe" });
200
208
  spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
@@ -202,10 +210,16 @@ export async function createApp(projectRoot, options = {}) {
202
210
  }
203
211
  runGit(["-C", wtPath, "add", "-A"], wtPath);
204
212
  runGit(["-C", wtPath, "commit", "-m", `[auto] ${files.length}개 파일 자동 세이브`], wtPath);
205
- broadcast({ type: "playground-saved", name, data: { message: `[auto] ${files.length}개 파일 자동 세이브`, auto: true } });
213
+ broadcast({
214
+ type: "playground-saved",
215
+ name,
216
+ data: { message: `[auto] ${files.length}개 파일 자동 세이브`, auto: true },
217
+ });
206
218
  broadcast({ type: "autosaved", name });
207
219
  }
208
- catch { /* ignore */ }
220
+ catch {
221
+ /* ignore */
222
+ }
209
223
  }, AUTOSAVE_DELAY));
210
224
  }
211
225
  function startWatcher(name) {
@@ -224,7 +238,9 @@ export async function createApp(projectRoot, options = {}) {
224
238
  if (autosaveEnabled.has(name))
225
239
  resetAutosaveTimer(name);
226
240
  }
227
- catch { /* ignore — worktree may be deleted */ }
241
+ catch {
242
+ /* ignore — worktree may be deleted */
243
+ }
228
244
  }, 800);
229
245
  watcher.start();
230
246
  watchers.set(name, watcher);
@@ -267,7 +283,9 @@ export async function createApp(projectRoot, options = {}) {
267
283
  broadcast({ type: "browser-error", name: msg.name, data: msg.data });
268
284
  }
269
285
  }
270
- catch { /* ignore non-JSON */ }
286
+ catch {
287
+ /* ignore non-JSON */
288
+ }
271
289
  });
272
290
  });
273
291
  // -------------------------------------------------------------------------
@@ -306,13 +324,21 @@ export async function createApp(projectRoot, options = {}) {
306
324
  }
307
325
  // Only allow proxying to known camp ports (prevent SSRF to arbitrary local services)
308
326
  const camps = getAll();
309
- const camp = camps.find(c => c.fePort === targetPort);
310
- if (!camp) {
327
+ const camp = camps.find((c) => c.fePort === targetPort);
328
+ const mainState = getMainServerState();
329
+ const isMainPort = mainState.status === "running" && mainState.port === targetPort;
330
+ if (!camp && !isMainPort) {
311
331
  return res.status(403).send("이 포트는 활성 캠프가 아닙니다.");
312
332
  }
313
- const campName = camp.name;
333
+ const campName = camp?.name ?? "__main__";
314
334
  const targetPath = req.url || "/";
315
- const proxyReq = httpRequest({ hostname: "127.0.0.1", port: targetPort, path: targetPath, method: req.method, headers: { ...req.headers, host: `localhost:${targetPort}` } }, (proxyRes) => {
335
+ const proxyReq = httpRequest({
336
+ hostname: "127.0.0.1",
337
+ port: targetPort,
338
+ path: targetPath,
339
+ method: req.method,
340
+ headers: { ...req.headers, host: `localhost:${targetPort}` },
341
+ }, (proxyRes) => {
316
342
  const contentType = proxyRes.headers["content-type"] || "";
317
343
  const isHtml = contentType.includes("text/html");
318
344
  if (!isHtml) {
@@ -326,8 +352,7 @@ export async function createApp(projectRoot, options = {}) {
326
352
  proxyRes.on("data", (chunk) => chunks.push(chunk));
327
353
  proxyRes.on("end", () => {
328
354
  let body = Buffer.concat(chunks).toString("utf8");
329
- const script = INJECTED_SCRIPT
330
- .replace("data-camp'", `data-camp'`) // placeholder
355
+ const script = INJECTED_SCRIPT.replace("data-camp'", `data-camp'`) // placeholder
331
356
  .replace("data-sanjang-injected>", `data-sanjang-injected data-camp="${campName}">`);
332
357
  // Replace _sp port placeholder with actual sanjang server port
333
358
  const finalScript = script.replace("new URLSearchParams(location.search.slice(1)||'').get('_sp')||location.port", `'${port}'`);
@@ -349,8 +374,7 @@ export async function createApp(projectRoot, options = {}) {
349
374
  // Remove X-Frame-Options / frame-ancestors to allow iframe embedding
350
375
  delete headers["x-frame-options"];
351
376
  if (typeof headers["content-security-policy"] === "string") {
352
- headers["content-security-policy"] = headers["content-security-policy"]
353
- .replace(/frame-ancestors[^;]*(;|$)/gi, "");
377
+ headers["content-security-policy"] = headers["content-security-policy"].replace(/frame-ancestors[^;]*(;|$)/gi, "");
354
378
  }
355
379
  res.writeHead(proxyRes.statusCode ?? 200, headers);
356
380
  res.end(body);
@@ -408,8 +432,18 @@ export async function createApp(projectRoot, options = {}) {
408
432
  copyCampFiles(projectRoot, wtPath, freshConfig.copyFiles, (msg) => {
409
433
  broadcast({ type: "log", name, source: "sanjang", data: msg });
410
434
  });
411
- const baseCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || undefined;
412
- const record = { name, branch, slot, fePort: actualFePort, bePort, status: "setting-up", baseCommit, parentBranch: branch };
435
+ const baseCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() ||
436
+ undefined;
437
+ const record = {
438
+ name,
439
+ branch,
440
+ slot,
441
+ fePort: actualFePort,
442
+ bePort,
443
+ status: "setting-up",
444
+ baseCommit,
445
+ parentBranch: branch,
446
+ };
413
447
  upsert(record);
414
448
  broadcast({ type: "playground-created", name, data: record });
415
449
  res.status(201).json(record);
@@ -455,7 +489,7 @@ export async function createApp(projectRoot, options = {}) {
455
489
  const processInfo = getProcessInfo(name) ?? { feLogs: [], feExitCode: null };
456
490
  // Self-heal: try to auto-fix before giving up
457
491
  const healActions = diagnoseFromLogs(processInfo.feLogs);
458
- const autoFixable = healActions.filter(a => a.auto);
492
+ const autoFixable = healActions.filter((a) => a.auto);
459
493
  if (autoFixable.length > 0) {
460
494
  broadcast({ type: "log", name, source: "sanjang", data: "문제를 발견했습니다. 자동으로 고치는 중..." });
461
495
  const freshConfig = await loadConfig(projectRoot);
@@ -583,10 +617,16 @@ export async function createApp(projectRoot, options = {}) {
583
617
  return res.json({ saved: false, reason: "변경사항이 없습니다." });
584
618
  try {
585
619
  // Reattach to branch if in detached HEAD state
586
- const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], { encoding: "utf8", stdio: "pipe" });
620
+ const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
621
+ encoding: "utf8",
622
+ stdio: "pipe",
623
+ });
587
624
  if (headRef.status !== 0 && pg.branch) {
588
625
  // Detached HEAD — move branch pointer to current commit and checkout
589
- const currentCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
626
+ const currentCommit = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
627
+ encoding: "utf8",
628
+ stdio: "pipe",
629
+ }).stdout?.trim();
590
630
  if (currentCommit) {
591
631
  spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, currentCommit], { stdio: "pipe" });
592
632
  spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
@@ -595,10 +635,16 @@ export async function createApp(projectRoot, options = {}) {
595
635
  // Stage all changes
596
636
  runGit(["-C", wtPath, "add", "-A"], wtPath);
597
637
  // Generate commit message with AI
598
- const diff = spawnSync("git", ["-C", wtPath, "diff", "--cached", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
638
+ const diff = spawnSync("git", ["-C", wtPath, "diff", "--cached", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout ||
639
+ "";
599
640
  let message = `${files.length}개 파일 변경`;
600
641
  try {
601
- const aiResult = spawnSync("claude", ["-p", "--model", "haiku", `이 git diff를 한국어 커밋 메시지로 작성해. 한 줄, 50자 이내, 설명 없이 메시지만:\n\n${diff.slice(0, 2000)}`], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
642
+ const aiResult = spawnSync("claude", [
643
+ "-p",
644
+ "--model",
645
+ "haiku",
646
+ `이 git diff를 한국어 커밋 메시지로 작성해. 한 줄, 50자 이내, 설명 없이 메시지만:\n\n${diff.slice(0, 2000)}`,
647
+ ], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
602
648
  if (aiResult.status === 0 && aiResult.stdout?.trim()) {
603
649
  message = aiResult.stdout.trim();
604
650
  }
@@ -768,22 +814,95 @@ export async function createApp(projectRoot, options = {}) {
768
814
  res.status(500).json({ error: err.message });
769
815
  }
770
816
  });
771
- // GET /api/playgrounds/:name/changes-summaryAI 변경 요약
772
- app.get("/api/playgrounds/:name/changes-summary", async (req, res) => {
817
+ // GET /api/playgrounds/:name/change-report구조화된 변경 리포트 (changes-summary 대체)
818
+ app.get("/api/playgrounds/:name/change-report", async (req, res) => {
773
819
  const { name } = req.params;
774
820
  if (!getOne(name))
775
821
  return res.status(404).json({ error: "not found" });
776
822
  try {
777
823
  const wtPath = campPath(name);
778
- const diff = spawnSync("git", ["-C", wtPath, "diff", "--stat"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
779
- if (!diff.trim())
780
- return res.json({ summary: null });
781
- const result = spawnSync("claude", ["-p", "--model", "haiku", `이 git diff를 한국어 한 줄(20자 이내)로 요약해. 설명 없이 요약만:\n\n${diff.slice(0, 2000)}`], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
782
- const summary = result.status === 0 ? (result.stdout ?? "").trim() : null;
783
- res.json({ summary });
824
+ const files = getChangedFiles(wtPath);
825
+ if (files.length === 0) {
826
+ return res.json({
827
+ files: [],
828
+ totalCount: 0,
829
+ byCategory: {},
830
+ warnings: [],
831
+ summary: null,
832
+ humanDescription: null,
833
+ categoryDetails: null,
834
+ });
835
+ }
836
+ const validStatuses = new Set(["수정", "추가", "삭제", "새 파일"]);
837
+ const reportFiles = files.map((f) => ({
838
+ path: f.path,
839
+ status: (validStatuses.has(f.status) ? f.status : "수정"),
840
+ }));
841
+ let report = buildChangeReport(reportFiles);
842
+ // AI 요약은 ?ai=true 일 때만 (비용 절약)
843
+ if (req.query.ai === "true") {
844
+ const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat"], {
845
+ encoding: "utf8",
846
+ stdio: "pipe",
847
+ }).stdout || "";
848
+ const diff = spawnSync("git", ["-C", wtPath, "diff"], {
849
+ encoding: "utf8",
850
+ stdio: "pipe",
851
+ }).stdout || "";
852
+ report = generateReportSummary(diffStat, diff, report);
853
+ }
854
+ res.json(report);
784
855
  }
785
- catch {
786
- res.json({ summary: null });
856
+ catch (err) {
857
+ res.status(500).json({ error: err.message });
858
+ }
859
+ });
860
+ // GET /api/playgrounds/:name/commit-report/:hash — 특정 커밋의 변경 리포트
861
+ app.get("/api/playgrounds/:name/commit-report/:hash", async (req, res) => {
862
+ const { name, hash } = req.params;
863
+ if (!getOne(name))
864
+ return res.status(404).json({ error: "not found" });
865
+ try {
866
+ const wtPath = campPath(name);
867
+ // 해당 커밋의 diff (부모 대비)
868
+ const diffNames = spawnSync("git", ["-C", wtPath, "diff", "--name-status", `${hash}~1..${hash}`], {
869
+ encoding: "utf8",
870
+ stdio: "pipe",
871
+ }).stdout?.trim() || "";
872
+ if (!diffNames)
873
+ return res.json({
874
+ files: [],
875
+ totalCount: 0,
876
+ byCategory: {},
877
+ warnings: [],
878
+ summary: null,
879
+ humanDescription: null,
880
+ categoryDetails: null,
881
+ });
882
+ const validStatuses = new Set(["수정", "추가", "삭제", "새 파일"]);
883
+ const statusMap = { M: "수정", A: "새 파일", D: "삭제" };
884
+ const parsedFiles = diffNames.split("\n").map((line) => {
885
+ const [st, ...pathParts] = line.split("\t");
886
+ const mapped = statusMap[st || ""] || "수정";
887
+ return {
888
+ path: pathParts.join("\t"),
889
+ status: (validStatuses.has(mapped) ? mapped : "수정"),
890
+ };
891
+ });
892
+ let report = buildChangeReport(parsedFiles);
893
+ if (req.query.ai === "true") {
894
+ const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat", `${hash}~1..${hash}`], {
895
+ encoding: "utf8",
896
+ stdio: "pipe",
897
+ }).stdout || "";
898
+ const diff = spawnSync("git", ["-C", wtPath, "diff", `${hash}~1..${hash}`], { encoding: "utf8", stdio: "pipe" })
899
+ .stdout || "";
900
+ report = generateReportSummary(diffStat, diff, report);
901
+ }
902
+ res.json(report);
903
+ }
904
+ catch (err) {
905
+ res.status(500).json({ error: err.message });
787
906
  }
788
907
  });
789
908
  app.post("/api/playgrounds/:name/ship", async (req, res) => {
@@ -797,9 +916,15 @@ export async function createApp(projectRoot, options = {}) {
797
916
  try {
798
917
  const wtPath = campPath(name);
799
918
  // 1. Reattach to branch if detached
800
- const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], { encoding: "utf8", stdio: "pipe" });
919
+ const headRef = spawnSync("git", ["-C", wtPath, "symbolic-ref", "--quiet", "HEAD"], {
920
+ encoding: "utf8",
921
+ stdio: "pipe",
922
+ });
801
923
  if (headRef.status !== 0 && pg.branch) {
802
- const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
924
+ const cur = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], {
925
+ encoding: "utf8",
926
+ stdio: "pipe",
927
+ }).stdout?.trim();
803
928
  if (cur) {
804
929
  spawnSync("git", ["-C", wtPath, "branch", "-f", pg.branch, cur], { stdio: "pipe" });
805
930
  spawnSync("git", ["-C", wtPath, "checkout", pg.branch], { stdio: "pipe" });
@@ -818,8 +943,14 @@ export async function createApp(projectRoot, options = {}) {
818
943
  // 3. Check there are commits to ship (vs base)
819
944
  const base = pg.baseCommit;
820
945
  const logCheck = base
821
- ? spawnSync("git", ["-C", wtPath, "log", "--oneline", `${base}..HEAD`], { encoding: "utf8", stdio: "pipe" }).stdout?.trim()
822
- : spawnSync("git", ["-C", wtPath, "log", "--oneline", "-1", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim();
946
+ ? spawnSync("git", ["-C", wtPath, "log", "--oneline", `${base}..HEAD`], {
947
+ encoding: "utf8",
948
+ stdio: "pipe",
949
+ }).stdout?.trim()
950
+ : spawnSync("git", ["-C", wtPath, "log", "--oneline", "-1", "HEAD"], {
951
+ encoding: "utf8",
952
+ stdio: "pipe",
953
+ }).stdout?.trim();
823
954
  if (!logCheck) {
824
955
  return res.status(400).json({ error: "보낼 변경사항이 없습니다." });
825
956
  }
@@ -851,6 +982,33 @@ export async function createApp(projectRoot, options = {}) {
851
982
  const diffStat = spawnSync("git", ["-C", wtPath, "diff", "--stat", "HEAD~1"], { encoding: "utf8", stdio: "pipe" }).stdout ||
852
983
  "";
853
984
  const diff = spawnSync("git", ["-C", wtPath, "diff", "HEAD~1"], { encoding: "utf8", stdio: "pipe" }).stdout || "";
985
+ // Change report warnings for PR body — based on full diff since camp creation
986
+ let reportWarnings = "";
987
+ try {
988
+ const base = pg.baseCommit;
989
+ if (base) {
990
+ const diffFiles = spawnSync("git", ["-C", wtPath, "diff", "--name-status", `${base}..HEAD`], {
991
+ encoding: "utf8",
992
+ stdio: "pipe",
993
+ }).stdout?.trim() || "";
994
+ if (diffFiles) {
995
+ const parsedFiles = diffFiles.split("\n").map((line) => {
996
+ const [st, ...pathParts] = line.split("\t");
997
+ return {
998
+ path: pathParts.join("\t"),
999
+ status: (st === "M" ? "수정" : st === "D" ? "삭제" : st === "A" ? "추가" : "수정"),
1000
+ };
1001
+ });
1002
+ const shipReport = buildChangeReport(parsedFiles);
1003
+ if (shipReport.warnings.length > 0) {
1004
+ reportWarnings = "\n\n### ⚠️ 주의사항\n" + shipReport.warnings.map((w) => `- ${w.message}`).join("\n");
1005
+ }
1006
+ }
1007
+ }
1008
+ }
1009
+ catch {
1010
+ /* non-critical */
1011
+ }
854
1012
  // Try Claude for rich PR body, fallback to simple
855
1013
  const claudeCheck = spawnSync("which", ["claude"], { stdio: "pipe" });
856
1014
  let prBody;
@@ -865,11 +1023,11 @@ export async function createApp(projectRoot, options = {}) {
865
1023
  });
866
1024
  prBody =
867
1025
  claudeResult.status === 0 && claudeResult.stdout?.trim()
868
- ? claudeResult.stdout.trim()
869
- : buildFallbackPrBody({ message, actions, diffStat });
1026
+ ? claudeResult.stdout.trim() + reportWarnings
1027
+ : buildFallbackPrBody({ message, actions, diffStat }) + reportWarnings;
870
1028
  }
871
1029
  else {
872
- prBody = buildFallbackPrBody({ message, actions, diffStat });
1030
+ prBody = buildFallbackPrBody({ message, actions, diffStat }) + reportWarnings;
873
1031
  }
874
1032
  const prResult = spawnSync("gh", ["pr", "create", "--title", message, "--body", prBody, "--head", branchName], { cwd: wtPath, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
875
1033
  if (prResult.status === 0) {
@@ -899,7 +1057,12 @@ export async function createApp(projectRoot, options = {}) {
899
1057
  if (claudeCheck.status !== 0) {
900
1058
  return res.json({ passed: true, skipped: true, reason: "claude CLI 없음 — 테스트 생략" });
901
1059
  }
902
- const result = spawnSync("claude", ["-p", "--model", "haiku", `이 프로젝트에서 PR 전에 돌려야 할 테스트 명령어를 찾아서 실행해줘. .github/workflows/ 디렉토리, package.json scripts, Makefile 등을 확인해. typecheck과 test를 우선 실행하고 결과를 알려줘. 명령어와 결과만 간단히.`], { cwd: wtPath, encoding: "utf8", stdio: "pipe", timeout: 120_000 });
1060
+ const result = spawnSync("claude", [
1061
+ "-p",
1062
+ "--model",
1063
+ "haiku",
1064
+ `이 프로젝트에서 PR 전에 돌려야 할 테스트 명령어를 찾아서 실행해줘. .github/workflows/ 디렉토리, package.json scripts, Makefile 등을 확인해. typecheck과 test를 우선 실행하고 결과를 알려줘. 명령어와 결과만 간단히.`,
1065
+ ], { cwd: wtPath, encoding: "utf8", stdio: "pipe", timeout: 120_000 });
903
1066
  const output = result.stdout?.trim() || "";
904
1067
  const failed = result.status !== 0 || /fail|error|FAIL|ERROR/i.test(output);
905
1068
  broadcast({ type: "log", name, source: "sanjang", data: failed ? "❌ 테스트 실패" : "✅ 테스트 통과" });
@@ -921,7 +1084,11 @@ export async function createApp(projectRoot, options = {}) {
921
1084
  return res.status(400).json({ error: "되돌릴 파일을 선택해주세요." });
922
1085
  // Validate file paths against traversal and shell injection
923
1086
  for (const file of files) {
924
- if (typeof file !== "string" || file.includes("..") || file.startsWith("/") || /[`$;"'\\|&]/.test(file)) {
1087
+ if (typeof file !== "string" ||
1088
+ file.includes("..") ||
1089
+ file.startsWith("/") ||
1090
+ file.startsWith("-") ||
1091
+ /[`$;"'\\|&]/.test(file)) {
925
1092
  return res.status(400).json({ error: `invalid file path: ${file}` });
926
1093
  }
927
1094
  }
@@ -1131,7 +1298,7 @@ export async function createApp(projectRoot, options = {}) {
1131
1298
  : ["-C", wtPath, "log", "--oneline", "--format=%h\t%s\t%cr", "--max-count=5", "HEAD"];
1132
1299
  const log = spawnSync("git", logArgs, { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || "";
1133
1300
  if (log) {
1134
- commits = log.split("\n").map(line => {
1301
+ commits = log.split("\n").map((line) => {
1135
1302
  const [hash = "", message = "", date = ""] = line.split("\t");
1136
1303
  return { hash, message, date };
1137
1304
  });
@@ -1277,7 +1444,8 @@ export async function createApp(projectRoot, options = {}) {
1277
1444
  copyCampFiles(projectRoot, wtPath, freshConfig2.copyFiles, (msg) => {
1278
1445
  broadcast({ type: "log", name, source: "sanjang", data: msg });
1279
1446
  });
1280
- const baseCommit2 = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() || undefined;
1447
+ const baseCommit2 = spawnSync("git", ["-C", wtPath, "rev-parse", "HEAD"], { encoding: "utf8", stdio: "pipe" }).stdout?.trim() ||
1448
+ undefined;
1281
1449
  const record = {
1282
1450
  name,
1283
1451
  branch,
@@ -1353,7 +1521,7 @@ export async function createApp(projectRoot, options = {}) {
1353
1521
  startWatcher(name);
1354
1522
  return res.json({ fixed: true, description: fix.description });
1355
1523
  }
1356
- catch (retryErr) {
1524
+ catch (_retryErr) {
1357
1525
  return res.json({ fixed: false, description: "설정을 고쳤지만 시작에 실패했습니다." });
1358
1526
  }
1359
1527
  }
@@ -1362,6 +1530,31 @@ export async function createApp(projectRoot, options = {}) {
1362
1530
  });
1363
1531
  let activityCache = null;
1364
1532
  const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
1533
+ // GET /api/compare/status — main 서버 상태
1534
+ app.get("/api/compare/status", (_req, res) => {
1535
+ res.json(getMainServerState());
1536
+ });
1537
+ // POST /api/compare/start — main 서버 시작
1538
+ app.post("/api/compare/start", async (_req, res) => {
1539
+ const mainState = getMainServerState();
1540
+ if (mainState.status === "running") {
1541
+ return res.json(mainState);
1542
+ }
1543
+ try {
1544
+ await startMainServer(projectRoot, config, (port) => {
1545
+ broadcast({ type: "compare-ready", data: { port } });
1546
+ });
1547
+ res.json(getMainServerState());
1548
+ }
1549
+ catch (err) {
1550
+ res.status(500).json({ error: err.message });
1551
+ }
1552
+ });
1553
+ // POST /api/compare/stop — main 서버 중지
1554
+ app.post("/api/compare/stop", (_req, res) => {
1555
+ stopMainServer();
1556
+ res.json({ ok: true });
1557
+ });
1365
1558
  app.get("/api/activity", (_req, res) => {
1366
1559
  if (activityCache && Date.now() - activityCache.ts < ACTIVITY_CACHE_TTL) {
1367
1560
  return res.json(activityCache.data);
@@ -1413,8 +1606,6 @@ export async function createApp(projectRoot, options = {}) {
1413
1606
  streak++;
1414
1607
  }
1415
1608
  else if (key === todayStr) {
1416
- // Today having 0 commits is ok — streak can start from yesterday
1417
- continue;
1418
1609
  }
1419
1610
  else {
1420
1611
  break;
@@ -1456,6 +1647,7 @@ export async function startServer(projectRoot, options = {}) {
1456
1647
  w.stop();
1457
1648
  watchers.clear();
1458
1649
  stopAllCamps();
1650
+ stopMainServer();
1459
1651
  server.close(() => process.exit(0));
1460
1652
  // Force exit after 10s if cleanup hangs
1461
1653
  setTimeout(() => process.exit(1), 10_000);