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.
@@ -26,6 +26,7 @@ const DEFAULTS = {
26
26
  fe: { base: 3000, slots: 8 },
27
27
  be: { base: 8000, slots: 8 },
28
28
  },
29
+ test: null,
29
30
  };
30
31
  /**
31
32
  * Load sanjang.config.js from project root.
@@ -69,8 +70,33 @@ function mergeConfig(user) {
69
70
  be: { ...DEFAULTS.ports.be, ...userPorts.be },
70
71
  };
71
72
  }
73
+ if (user.test) {
74
+ config.test = typeof user.test === "string"
75
+ ? { command: user.test }
76
+ : user.test;
77
+ }
72
78
  return config;
73
79
  }
80
+ /**
81
+ * Auto-detect a test command from package.json.
82
+ * Returns null if no test script found or it's the npm default placeholder.
83
+ */
84
+ export function detectTestCommand(projectRoot, cwd = ".") {
85
+ const pkgPath = join(projectRoot, cwd, "package.json");
86
+ if (!existsSync(pkgPath))
87
+ return null;
88
+ try {
89
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
90
+ const testScript = pkg?.scripts?.test;
91
+ if (typeof testScript === "string" && !testScript.includes("no test specified")) {
92
+ return "npm test";
93
+ }
94
+ }
95
+ catch {
96
+ // ignore
97
+ }
98
+ return null;
99
+ }
74
100
  /**
75
101
  * Auto-detect project type and generate config.
76
102
  */
@@ -230,13 +256,11 @@ function detectTurboMainApp(root) {
230
256
  const port = portMatch?.[1] ? parseInt(portMatch[1], 10) : 3000;
231
257
  candidates.push({ name: entry.name, port });
232
258
  }
233
- catch {
234
- continue;
235
- }
259
+ catch { }
236
260
  }
237
261
  }
238
262
  // Prefer app with explicit port, then first candidate
239
- return candidates.find(c => c.port !== 3000) ?? candidates[0] ?? null;
263
+ return candidates.find((c) => c.port !== 3000) ?? candidates[0] ?? null;
240
264
  }
241
265
  function detectPackageManager(root) {
242
266
  if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
@@ -272,7 +296,7 @@ export function generateConfig(projectRoot, options = {}) {
272
296
  // Exclude .env.example, .env.test, .env.template
273
297
  (f) => !f.includes("example") && !f.includes("template") && !f.includes(".test"));
274
298
  // Detect potential issues
275
- const issues = detectSetupIssues(detectRoot);
299
+ const _issues = detectSetupIssues(detectRoot);
276
300
  const lines = [
277
301
  "export default {",
278
302
  ` // ${detected.framework} detected`,
@@ -0,0 +1,27 @@
1
+ import type { ChangeReport, ChangeReportFile, ChangeReportWarning } from "../types.ts";
2
+ type FileCategory = "ui" | "api" | "config" | "test" | "docs" | "other";
3
+ /**
4
+ * Classify a file path into one of the known categories.
5
+ * Rules are checked in priority order: test > ui > api > docs > config > other
6
+ */
7
+ export declare function categorizeFile(filePath: string): FileCategory;
8
+ /**
9
+ * Detect warnings from a list of categorized files.
10
+ * Returns deduplicated warnings (one per type).
11
+ */
12
+ export declare function detectWarnings(files: ChangeReportFile[]): ChangeReportWarning[];
13
+ /**
14
+ * Build a ChangeReport from raw file list (path + status).
15
+ * summary and humanDescription are set to null — call generateReportSummary to enrich.
16
+ */
17
+ export declare function buildChangeReport(rawFiles: {
18
+ path: string;
19
+ status: ChangeReportFile["status"];
20
+ }[]): ChangeReport;
21
+ /**
22
+ * Enrich a ChangeReport with AI-generated category-level descriptions.
23
+ * Each category gets human-readable bullet points explaining what changed.
24
+ * Tries `claude -p --model haiku` and falls back to file-based summary.
25
+ */
26
+ export declare function generateReportSummary(diffStat: string, diff: string, report: ChangeReport): ChangeReport;
27
+ export {};
@@ -0,0 +1,233 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { basename, extname } from "node:path";
3
+ /**
4
+ * Classify a file path into one of the known categories.
5
+ * Rules are checked in priority order: test > ui > api > docs > config > other
6
+ */
7
+ export function categorizeFile(filePath) {
8
+ const name = basename(filePath);
9
+ const ext = extname(filePath).toLowerCase();
10
+ const lower = filePath.toLowerCase();
11
+ // 1. test
12
+ if (/\.(test|spec)\.[^.]+$/.test(lower))
13
+ return "test";
14
+ if (/[/\\](test|tests|__tests__|__mocks__|fixtures)[/\\]/.test(lower))
15
+ return "test";
16
+ if (/^(test|tests|__tests__|__mocks__|fixtures)[/\\]/.test(lower))
17
+ return "test";
18
+ // 2. ui
19
+ const uiExts = new Set([
20
+ ".css",
21
+ ".scss",
22
+ ".less",
23
+ ".sass",
24
+ ".styl",
25
+ ".html",
26
+ ".htm",
27
+ ".svg",
28
+ ".tsx",
29
+ ".jsx",
30
+ ".vue",
31
+ ".svelte",
32
+ ]);
33
+ if (uiExts.has(ext))
34
+ return "ui";
35
+ if (/[/\\](pages|views|components|layouts|styles|public)[/\\]/.test(lower))
36
+ return "ui";
37
+ if (/^(pages|views|components|layouts|styles|public)[/\\]/.test(lower))
38
+ return "ui";
39
+ // 3. api
40
+ if (/[/\\](api|routes|controllers|handlers|middleware|graphql|resolvers|schema)[/\\]/.test(lower))
41
+ return "api";
42
+ if (/^(api|routes|controllers|handlers|middleware|graphql|resolvers|schema)[/\\]/.test(lower))
43
+ return "api";
44
+ // files named "server" (any extension)
45
+ if (/[/\\]server\.[^/\\]+$/.test(lower) || /^server\.[^/\\]+$/.test(lower))
46
+ return "api";
47
+ // 4. docs
48
+ if (ext === ".md")
49
+ return "docs";
50
+ if (/[/\\]docs[/\\]/.test(lower) || /^docs[/\\]/.test(lower))
51
+ return "docs";
52
+ if (/^(README|CHANGELOG|LICENSE|CONTRIBUTING)(\.|$)/i.test(name))
53
+ return "docs";
54
+ // 5. config
55
+ const configPrefixes = [
56
+ "package",
57
+ "tsconfig",
58
+ "jest.config",
59
+ "vite.config",
60
+ "next.config",
61
+ "webpack.config",
62
+ "biome",
63
+ ".eslint",
64
+ ".prettier",
65
+ ".babel",
66
+ ];
67
+ const nameLower = name.toLowerCase();
68
+ if (configPrefixes.some((p) => nameLower.startsWith(p.toLowerCase())))
69
+ return "config";
70
+ if (name.startsWith("."))
71
+ return "config";
72
+ if ([".json", ".yaml", ".yml", ".toml"].includes(ext))
73
+ return "config";
74
+ // 6. other
75
+ return "other";
76
+ }
77
+ /**
78
+ * Detect warnings from a list of categorized files.
79
+ * Returns deduplicated warnings (one per type).
80
+ */
81
+ export function detectWarnings(files) {
82
+ const seen = new Set();
83
+ const warnings = [];
84
+ const add = (type, message, file) => {
85
+ if (seen.has(type))
86
+ return;
87
+ seen.add(type);
88
+ warnings.push({ type, message, file });
89
+ };
90
+ for (const f of files) {
91
+ const p = f.path.toLowerCase();
92
+ const name = basename(f.path);
93
+ // env
94
+ if (/\.env($|\.)/.test(name.toLowerCase())) {
95
+ add("env", "환경 변수 파일이 변경되었습니다", f.path);
96
+ }
97
+ // db
98
+ if (/migrat|schema|\.sql|prisma/.test(p)) {
99
+ add("db", "데이터베이스 스키마 또는 마이그레이션이 변경되었습니다", f.path);
100
+ }
101
+ // infra
102
+ if (/dockerfile|docker-compose|\.github\/|deploy\/|[/\\]k8s[/\\]|^k8s[/\\]|terraform|infrastructure/.test(p)) {
103
+ add("infra", "인프라 설정이 변경되었습니다", f.path);
104
+ }
105
+ // config (package manager files)
106
+ if (/package\.json|package-lock|yarn\.lock|pnpm-lock/.test(p)) {
107
+ add("config", "패키지 의존성이 변경되었습니다", f.path);
108
+ }
109
+ // security
110
+ if (/auth|security|token|secret|credential|password/.test(p)) {
111
+ add("security", "보안 관련 파일이 변경되었습니다", f.path);
112
+ }
113
+ }
114
+ return warnings;
115
+ }
116
+ /**
117
+ * Build a ChangeReport from raw file list (path + status).
118
+ * summary and humanDescription are set to null — call generateReportSummary to enrich.
119
+ */
120
+ export function buildChangeReport(rawFiles) {
121
+ const files = rawFiles.map((f) => ({
122
+ path: f.path,
123
+ status: f.status,
124
+ category: categorizeFile(f.path),
125
+ }));
126
+ const byCategory = {};
127
+ for (const f of files) {
128
+ const cat = f.category;
129
+ if (!byCategory[cat])
130
+ byCategory[cat] = [];
131
+ byCategory[cat].push(f);
132
+ }
133
+ const warnings = detectWarnings(files);
134
+ // 기본 카테고리 설명 — AI 없이도 즉시 표시
135
+ const categoryDetails = {};
136
+ for (const [cat, catFiles] of Object.entries(byCategory)) {
137
+ categoryDetails[cat] = catFiles.map((f) => {
138
+ const name = f.path.split("/").pop() || f.path;
139
+ return f.status === "새 파일" ? `${name} 추가됨` : `${name} 수정됨`;
140
+ });
141
+ }
142
+ return {
143
+ files,
144
+ totalCount: files.length,
145
+ byCategory,
146
+ warnings,
147
+ summary: null,
148
+ humanDescription: null,
149
+ categoryDetails,
150
+ };
151
+ }
152
+ /**
153
+ * Enrich a ChangeReport with AI-generated category-level descriptions.
154
+ * Each category gets human-readable bullet points explaining what changed.
155
+ * Tries `claude -p --model haiku` and falls back to file-based summary.
156
+ */
157
+ export function generateReportSummary(diffStat, diff, report) {
158
+ const categoryNames = {
159
+ ui: "화면",
160
+ api: "서버/API",
161
+ config: "설정",
162
+ test: "테스트",
163
+ docs: "문서",
164
+ other: "기타",
165
+ };
166
+ const categoryList = Object.keys(report.byCategory)
167
+ .map((cat) => `${categoryNames[cat] || cat}: ${(report.byCategory[cat] ?? []).length}개`)
168
+ .join(", ");
169
+ const fallbackSummary = `${categoryList} 변경`;
170
+ // Build per-category diff sections for the prompt
171
+ const categoryDiffSections = Object.entries(report.byCategory)
172
+ .map(([cat, files]) => {
173
+ const fileList = files.map((f) => ` ${f.status} ${f.path}`).join("\n");
174
+ return `[${categoryNames[cat] || cat}]\n${fileList}`;
175
+ })
176
+ .join("\n\n");
177
+ const prompt = `너는 비개발자에게 코드 변경사항을 설명하는 도우미야.
178
+ 아래 git diff를 분석하고, 카테고리별로 "실제로 뭐가 바뀌었는지"를 설명해.
179
+
180
+ 규칙:
181
+ - 파일명이 아니라 사용자 관점에서 뭐가 바뀌었는지 설명 (예: "로그인 버튼이 보라색으로 바뀌었어요")
182
+ - 각 항목은 한국어, '~했어요/~됐어요' 체, 한 줄
183
+ - 새 파일이면 "추가됐어요", 수정이면 구체적으로 뭐가 바뀌었는지
184
+
185
+ 카테고리별 파일:
186
+ ${categoryDiffSections}
187
+
188
+ diff:
189
+ ${diff.slice(0, 4000)}
190
+
191
+ JSON으로만 응답해 (다른 텍스트 없이):
192
+ {"summary": "전체 한 줄 요약 (30자 이내)", "categories": {"ui": ["설명1", "설명2"], "api": ["설명1"], ...}}
193
+
194
+ categories의 키는 반드시 다음 중 하나: ${Object.keys(report.byCategory).join(", ")}`;
195
+ try {
196
+ const result = spawnSync("claude", ["-p", prompt], {
197
+ encoding: "utf8",
198
+ timeout: 30_000,
199
+ });
200
+ if (result.status === 0 && result.stdout) {
201
+ const output = result.stdout.trim();
202
+ const jsonMatch = output.match(/\{[\s\S]*"summary"[\s\S]*"categories"[\s\S]*\}/);
203
+ if (jsonMatch) {
204
+ const parsed = JSON.parse(jsonMatch[0]);
205
+ if (parsed.summary && parsed.categories) {
206
+ return {
207
+ ...report,
208
+ summary: parsed.summary,
209
+ humanDescription: null,
210
+ categoryDetails: parsed.categories,
211
+ };
212
+ }
213
+ }
214
+ }
215
+ }
216
+ catch {
217
+ // Fall through to fallback
218
+ }
219
+ // Fallback: 파일 기반 설명 생성
220
+ const fallbackDetails = {};
221
+ for (const [cat, files] of Object.entries(report.byCategory)) {
222
+ fallbackDetails[cat] = files.map((f) => {
223
+ const name = f.path.split("/").pop() || f.path;
224
+ return f.status === "새 파일" ? `${name} 파일이 추가됐어요` : `${name} 파일이 수정됐어요`;
225
+ });
226
+ }
227
+ return {
228
+ ...report,
229
+ summary: fallbackSummary,
230
+ humanDescription: null,
231
+ categoryDetails: fallbackDetails,
232
+ };
233
+ }
@@ -6,6 +6,19 @@
6
6
  * Conflict markers: UU (both modified), AA (both added), DD, AU, UA, DU, UD
7
7
  */
8
8
  export declare function parseConflictFiles(statusOutput: string | null | undefined): string[];
9
+ /**
10
+ * A single conflict section within a file.
11
+ */
12
+ export interface ConflictSection {
13
+ ours: string;
14
+ theirs: string;
15
+ startLine: number;
16
+ }
17
+ /**
18
+ * Parse conflict markers from file content.
19
+ * Returns an array of conflict sections found in the file.
20
+ */
21
+ export declare function parseConflictSections(content: string): ConflictSection[];
9
22
  /**
10
23
  * Build a Claude prompt to resolve merge conflicts.
11
24
  */
@@ -14,6 +14,47 @@ export function parseConflictFiles(statusOutput) {
14
14
  .filter((line) => /^(UU|AA|DD|AU|UA|DU|UD)\s/.test(line))
15
15
  .map((line) => line.slice(3).trim());
16
16
  }
17
+ /**
18
+ * Parse conflict markers from file content.
19
+ * Returns an array of conflict sections found in the file.
20
+ */
21
+ export function parseConflictSections(content) {
22
+ const lines = content.split("\n");
23
+ const sections = [];
24
+ let i = 0;
25
+ while (i < lines.length) {
26
+ if (lines[i].startsWith("<<<<<<<")) {
27
+ const startLine = i + 1;
28
+ const oursLines = [];
29
+ const theirsLines = [];
30
+ let inTheirs = false;
31
+ i++;
32
+ while (i < lines.length) {
33
+ const line = lines[i];
34
+ if (line.startsWith("=======")) {
35
+ inTheirs = true;
36
+ }
37
+ else if (line.startsWith(">>>>>>>")) {
38
+ break;
39
+ }
40
+ else if (inTheirs) {
41
+ theirsLines.push(line);
42
+ }
43
+ else {
44
+ oursLines.push(line);
45
+ }
46
+ i++;
47
+ }
48
+ sections.push({
49
+ ours: oursLines.join("\n"),
50
+ theirs: theirsLines.join("\n"),
51
+ startLine,
52
+ });
53
+ }
54
+ i++;
55
+ }
56
+ return sections;
57
+ }
17
58
  /**
18
59
  * Build a Claude prompt to resolve merge conflicts.
19
60
  */
@@ -14,9 +14,7 @@ function checkPortConflict(processInfo) {
14
14
  name: "port-conflict",
15
15
  status: hit ? "error" : "ok",
16
16
  detail: hit ? "다른 프로그램과 충돌이 발생했습니다." : "정상.",
17
- guide: hit
18
- ? '"중지" → "시작"을 눌러보세요. 계속되면 "삭제" 후 다시 만들어보세요.'
19
- : null,
17
+ guide: hit ? '"중지" → "시작"을 눌러보세요. 계속되면 "삭제" 후 다시 만들어보세요.' : null,
20
18
  };
21
19
  }
22
20
  function checkFrontendExit(processInfo) {
@@ -47,9 +45,7 @@ function checkFePort(pg) {
47
45
  return {
48
46
  name: "fe-status",
49
47
  status: output?.length ? "ok" : "warn",
50
- detail: output?.length
51
- ? "Frontend 서버가 실행 중입니다."
52
- : "Frontend 서버가 응답하지 않습니다.",
48
+ detail: output?.length ? "Frontend 서버가 실행 중입니다." : "Frontend 서버가 응답하지 않습니다.",
53
49
  guide: !output?.length ? '"시작" 버튼을 눌러보세요.' : null,
54
50
  };
55
51
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Main branch dev server manager.
3
+ * Creates a git worktree from origin's default branch and runs a dev server
4
+ * for side-by-side comparison with the camp's changes.
5
+ */
6
+ import type { SanjangConfig } from "../types.ts";
7
+ interface MainServerState {
8
+ status: "stopped" | "starting" | "running" | "error";
9
+ port: number | null;
10
+ error: string | null;
11
+ }
12
+ export declare function getMainServerState(): MainServerState;
13
+ export declare function getMainServerLogs(): string[];
14
+ export declare function startMainServer(projectRoot: string, config: SanjangConfig, callbacks?: {
15
+ onReady?: (port: number) => void;
16
+ onLog?: (msg: string) => void;
17
+ }): Promise<void>;
18
+ export declare function stopMainServer(): void;
19
+ export {};
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Main branch dev server manager.
3
+ * Creates a git worktree from origin's default branch and runs a dev server
4
+ * for side-by-side comparison with the camp's changes.
5
+ */
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { buildDevCommand, detectPortFromLogs, killProcessGroup } from "./process-utils.js";
10
+ let state = { status: "stopped", port: null, error: null };
11
+ let proc = null;
12
+ let logs = [];
13
+ let worktreePath = null;
14
+ let savedProjectRoot = null;
15
+ export function getMainServerState() {
16
+ return { ...state };
17
+ }
18
+ export function getMainServerLogs() {
19
+ return [...logs];
20
+ }
21
+ /** Detect the default branch from origin (main, master, dev, etc.) */
22
+ function detectDefaultBranch(projectRoot) {
23
+ const headRef = spawnSync("git", ["-C", projectRoot, "symbolic-ref", "refs/remotes/origin/HEAD"], {
24
+ encoding: "utf8",
25
+ stdio: "pipe",
26
+ });
27
+ if (headRef.status === 0 && headRef.stdout.trim()) {
28
+ return headRef.stdout.trim().replace("refs/remotes/origin/", "");
29
+ }
30
+ for (const name of ["main", "master", "dev", "develop"]) {
31
+ const check = spawnSync("git", ["-C", projectRoot, "rev-parse", "--verify", `origin/${name}`], {
32
+ encoding: "utf8",
33
+ stdio: "pipe",
34
+ });
35
+ if (check.status === 0)
36
+ return name;
37
+ }
38
+ return "main";
39
+ }
40
+ /** Create a git worktree for the comparison server */
41
+ function ensureWorktree(projectRoot, branch) {
42
+ const campsDir = join(projectRoot, ".sanjang", "camps");
43
+ const wtPath = join(campsDir, "__main__");
44
+ if (existsSync(wtPath)) {
45
+ spawnSync("git", ["-C", projectRoot, "worktree", "remove", "--force", wtPath], { stdio: "pipe" });
46
+ if (existsSync(wtPath)) {
47
+ rmSync(wtPath, { recursive: true, force: true });
48
+ }
49
+ }
50
+ const hasOrigin = spawnSync("git", ["-C", projectRoot, "remote"], {
51
+ encoding: "utf8",
52
+ stdio: "pipe",
53
+ }).stdout.trim().includes("origin");
54
+ let ref;
55
+ if (hasOrigin) {
56
+ spawnSync("git", ["-C", projectRoot, "fetch", "origin", branch], { stdio: "pipe" });
57
+ ref = `origin/${branch}`;
58
+ }
59
+ else {
60
+ ref = branch;
61
+ }
62
+ if (!existsSync(campsDir))
63
+ mkdirSync(campsDir, { recursive: true });
64
+ const result = spawnSync("git", ["-C", projectRoot, "worktree", "add", "--detach", wtPath, ref], {
65
+ encoding: "utf8",
66
+ stdio: "pipe",
67
+ });
68
+ if (result.status !== 0) {
69
+ throw new Error(`worktree 생성 실패: ${result.stderr?.trim() || "unknown error"}`);
70
+ }
71
+ return wtPath;
72
+ }
73
+ function runSetup(wtPath, config, onLog) {
74
+ if (!config.setup)
75
+ return;
76
+ const setupCwd = config.dev.cwd ? join(wtPath, config.dev.cwd) : wtPath;
77
+ onLog(`의존성 설치 중: ${config.setup}`);
78
+ const result = spawnSync(config.setup, [], {
79
+ cwd: setupCwd,
80
+ shell: true,
81
+ stdio: "pipe",
82
+ encoding: "utf8",
83
+ timeout: 120_000,
84
+ });
85
+ if (result.status !== 0) {
86
+ onLog(`⚠️ setup 실패 (exit ${result.status}), 계속 진행합니다`);
87
+ }
88
+ else {
89
+ onLog("의존성 설치 완료 ✓");
90
+ }
91
+ }
92
+ export async function startMainServer(projectRoot, config, callbacks) {
93
+ if (state.status === "running" || state.status === "starting")
94
+ return;
95
+ const log = callbacks?.onLog ?? (() => { });
96
+ state = { status: "starting", port: null, error: null };
97
+ logs = [];
98
+ savedProjectRoot = projectRoot;
99
+ try {
100
+ const branch = detectDefaultBranch(projectRoot);
101
+ log(`비교 기준: origin/${branch}`);
102
+ log("원본 소스 준비 중...");
103
+ const wtPath = ensureWorktree(projectRoot, branch);
104
+ worktreePath = wtPath;
105
+ runSetup(wtPath, config, log);
106
+ const basePort = config.dev.port + 100;
107
+ const fullCommand = buildDevCommand(config.dev.command, config.dev.portFlag, basePort);
108
+ const cwd = config.dev.cwd ? join(wtPath, config.dev.cwd) : wtPath;
109
+ log(`dev 서버 시작: ${fullCommand}`);
110
+ proc = spawn(fullCommand, [], {
111
+ cwd,
112
+ stdio: ["ignore", "pipe", "pipe"],
113
+ env: { ...process.env, ...config.dev.env, FORCE_COLOR: "0", NO_COLOR: "1" },
114
+ shell: true,
115
+ detached: true,
116
+ });
117
+ proc.stdout?.on("data", (chunk) => {
118
+ const line = chunk.toString();
119
+ logs.push(line);
120
+ if (logs.length > 100)
121
+ logs.shift();
122
+ });
123
+ proc.stderr?.on("data", (chunk) => {
124
+ const line = chunk.toString();
125
+ logs.push(line);
126
+ if (logs.length > 100)
127
+ logs.shift();
128
+ });
129
+ proc.on("close", (code) => {
130
+ if (state.status !== "stopped") {
131
+ state = { status: "stopped", port: null, error: code ? `exit ${code}` : null };
132
+ }
133
+ proc = null;
134
+ });
135
+ proc.on("error", (err) => {
136
+ state = { status: "error", port: null, error: err.message };
137
+ proc = null;
138
+ });
139
+ const detectedPort = await detectPortFromLogs(logs, 60_000);
140
+ if (detectedPort) {
141
+ state = { status: "running", port: detectedPort, error: null };
142
+ log(`비교 서버 준비 완료 ✓ (origin/${branch}, :${detectedPort})`);
143
+ callbacks?.onReady?.(detectedPort);
144
+ }
145
+ else {
146
+ if (proc) {
147
+ killProcessGroup(proc);
148
+ proc = null;
149
+ }
150
+ const lastLog = logs.slice(-5).join("\n").trim();
151
+ state = { status: "error", port: null, error: lastLog || "포트를 감지하지 못했어요" };
152
+ }
153
+ }
154
+ catch (err) {
155
+ const message = err instanceof Error ? err.message : String(err);
156
+ state = { status: "error", port: null, error: message };
157
+ log(`❌ 비교 서버 시작 실패: ${message}`);
158
+ }
159
+ }
160
+ export function stopMainServer() {
161
+ state = { status: "stopped", port: null, error: null };
162
+ if (proc) {
163
+ killProcessGroup(proc);
164
+ proc = null;
165
+ }
166
+ logs = [];
167
+ if (worktreePath && existsSync(worktreePath) && savedProjectRoot) {
168
+ try {
169
+ spawnSync("git", ["-C", savedProjectRoot, "worktree", "remove", "--force", worktreePath], {
170
+ stdio: "pipe",
171
+ });
172
+ }
173
+ catch {
174
+ try {
175
+ rmSync(worktreePath, { recursive: true, force: true });
176
+ }
177
+ catch { }
178
+ }
179
+ worktreePath = null;
180
+ }
181
+ }
@@ -5,10 +5,19 @@ import { spawnSync } from "node:child_process";
5
5
  */
6
6
  export function aiSlugify(description) {
7
7
  try {
8
- const result = spawnSync("claude", ["-p", "--model", "haiku", `Convert this task description to a kebab-case English slug. Rules: 3-5 words, max 30 chars, lowercase, descriptive (not just one word), no explanation, output ONLY the slug.\n\nExample: "로그인 버튼 색상 변경" → "login-button-color-change"\nExample: "대시보드 차트 추가" → "dashboard-chart-add"\n\nTask: "${description}"`], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
8
+ const result = spawnSync("claude", [
9
+ "-p",
10
+ "--model",
11
+ "haiku",
12
+ `Convert this task description to a kebab-case English slug. Rules: 3-5 words, max 30 chars, lowercase, descriptive (not just one word), no explanation, output ONLY the slug.\n\nExample: "로그인 버튼 색상 변경" → "login-button-color-change"\nExample: "대시보드 차트 추가" → "dashboard-chart-add"\n\nTask: "${description}"`,
13
+ ], { encoding: "utf8", stdio: "pipe", timeout: 10_000 });
9
14
  if (result.status !== 0)
10
15
  return null;
11
- const slug = (result.stdout ?? "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
16
+ const slug = (result.stdout ?? "")
17
+ .trim()
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9-]/g, "")
20
+ .replace(/^-+|-+$/g, "");
12
21
  if (!slug || slug.length > 30)
13
22
  return null;
14
23
  return slug;
@@ -4,6 +4,6 @@ export declare function portsForSlot(slot: number): {
4
4
  fePort: number;
5
5
  bePort: number;
6
6
  };
7
- export declare function scanPorts(): PortStatus[];
8
- export declare function allocate(existingCamps: Pick<Camp, "slot">[]): PortAllocation;
7
+ export declare function scanPorts(): Promise<PortStatus[]>;
8
+ export declare function allocate(existingCamps: Pick<Camp, "slot" | "fePort" | "bePort">[]): Promise<PortAllocation>;
9
9
  export declare function release(_name: string): void;