sanjang 0.3.0
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/LICENSE +21 -0
- package/README.md +218 -0
- package/bin/__tests__/sanjang.test.ts +42 -0
- package/bin/sanjang.js +17 -0
- package/bin/sanjang.ts +144 -0
- package/dashboard/app.js +1888 -0
- package/dashboard/app.test.js +2 -0
- package/dashboard/index.html +275 -0
- package/dashboard/style.css +2112 -0
- package/lib/config.ts +337 -0
- package/lib/engine/cache.ts +218 -0
- package/lib/engine/config-hotfix.ts +161 -0
- package/lib/engine/conflict.ts +33 -0
- package/lib/engine/diagnostics.ts +81 -0
- package/lib/engine/naming.ts +93 -0
- package/lib/engine/ports.ts +61 -0
- package/lib/engine/pr.ts +71 -0
- package/lib/engine/process.ts +283 -0
- package/lib/engine/self-heal.ts +130 -0
- package/lib/engine/smart-init.ts +136 -0
- package/lib/engine/smart-pr.ts +130 -0
- package/lib/engine/snapshot.ts +45 -0
- package/lib/engine/state.ts +60 -0
- package/lib/engine/suggest.ts +169 -0
- package/lib/engine/warp.ts +47 -0
- package/lib/engine/watcher.ts +40 -0
- package/lib/engine/worktree.ts +100 -0
- package/lib/server.ts +1560 -0
- package/lib/types.ts +130 -0
- package/package.json +48 -0
- package/templates/sanjang.config.js +32 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { deepFindEnvFiles } from "./smart-init.ts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Types
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export interface ConfigFix {
|
|
10
|
+
type: "add-copyfiles" | "update-setup" | "info";
|
|
11
|
+
description: string;
|
|
12
|
+
patch: Record<string, unknown>; // the values to add/change
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Pattern matchers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
interface PatternMatcher {
|
|
20
|
+
test: RegExp;
|
|
21
|
+
buildFix: (projectRoot: string, match: RegExpMatchArray) => ConfigFix | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PATTERNS: PatternMatcher[] = [
|
|
25
|
+
{
|
|
26
|
+
// SvelteKit / Vite: "does not provide an export named 'PUBLIC_*'"
|
|
27
|
+
test: /does not provide an export named '(PUBLIC_\w+)'/,
|
|
28
|
+
buildFix(projectRoot: string, _match: RegExpMatchArray): ConfigFix | null {
|
|
29
|
+
const envFiles = deepFindEnvFiles(projectRoot).filter(
|
|
30
|
+
(f) => !f.includes("example") && !f.includes("template") && !f.includes(".test"),
|
|
31
|
+
);
|
|
32
|
+
if (envFiles.length === 0) return null;
|
|
33
|
+
return {
|
|
34
|
+
type: "add-copyfiles",
|
|
35
|
+
description: `환경변수 참조 오류 — copyFiles에 ${envFiles.join(", ")}을 추가합니다.`,
|
|
36
|
+
patch: { copyFiles: envFiles },
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
// Port mismatch: "Port X is in use, trying another one"
|
|
42
|
+
test: /Port (\d+) is in use/,
|
|
43
|
+
buildFix(_projectRoot: string, match: RegExpMatchArray): ConfigFix | null {
|
|
44
|
+
const port = match[1];
|
|
45
|
+
return {
|
|
46
|
+
type: "info",
|
|
47
|
+
description: `포트 ${port}이(가) 이미 사용 중입니다. 다른 캠프가 실행 중인지 확인하세요.`,
|
|
48
|
+
patch: { _conflictPort: Number(port) },
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
// Module not found after fresh install → setup command might be wrong
|
|
54
|
+
test: /Cannot find module '([^']+)'/,
|
|
55
|
+
buildFix(_projectRoot: string, match: RegExpMatchArray): ConfigFix | null {
|
|
56
|
+
const moduleName = match[1];
|
|
57
|
+
return {
|
|
58
|
+
type: "update-setup",
|
|
59
|
+
description: `모듈 '${moduleName}'을(를) 찾을 수 없습니다. setup 명령이 올바른지 확인하세요.`,
|
|
60
|
+
patch: { _missingModule: moduleName },
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// suggestConfigFix — analyze logs and return a fix, or null
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
export function suggestConfigFix(projectRoot: string, logs: string[]): ConfigFix | null {
|
|
71
|
+
const combined = logs.join("\n");
|
|
72
|
+
|
|
73
|
+
for (const pattern of PATTERNS) {
|
|
74
|
+
const match = combined.match(pattern.test);
|
|
75
|
+
if (match) {
|
|
76
|
+
const fix = pattern.buildFix(projectRoot, match);
|
|
77
|
+
if (fix) return fix;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// applyConfigFix — modify sanjang.config.js in place
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
const CONFIG_FILE = "sanjang.config.js";
|
|
89
|
+
|
|
90
|
+
export function applyConfigFix(projectRoot: string, fix: ConfigFix): boolean {
|
|
91
|
+
const configPath = join(projectRoot, CONFIG_FILE);
|
|
92
|
+
if (!existsSync(configPath)) return false;
|
|
93
|
+
|
|
94
|
+
let content: string;
|
|
95
|
+
try {
|
|
96
|
+
content = readFileSync(configPath, "utf8");
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
switch (fix.type) {
|
|
102
|
+
case "add-copyfiles": {
|
|
103
|
+
const newFiles = fix.patch.copyFiles as string[];
|
|
104
|
+
if (!newFiles || newFiles.length === 0) return false;
|
|
105
|
+
content = mergeCopyFiles(content, newFiles);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "update-setup": {
|
|
109
|
+
// Informational — we don't auto-change setup without explicit user input
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
case "info": {
|
|
113
|
+
// Purely informational, nothing to write
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
default:
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
writeFileSync(configPath, content, "utf8");
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Helpers
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Merge new file paths into the existing copyFiles array inside the config
|
|
134
|
+
* source text. If copyFiles doesn't exist, insert it before the closing `};`.
|
|
135
|
+
*/
|
|
136
|
+
function mergeCopyFiles(source: string, newFiles: string[]): string {
|
|
137
|
+
// Try to find existing copyFiles: [...]
|
|
138
|
+
const copyFilesRe = /copyFiles:\s*\[([^\]]*)\]/;
|
|
139
|
+
const match = source.match(copyFilesRe);
|
|
140
|
+
|
|
141
|
+
if (match) {
|
|
142
|
+
// Parse existing entries
|
|
143
|
+
const existing = match[1]!
|
|
144
|
+
.split(",")
|
|
145
|
+
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
|
|
148
|
+
const merged = [...new Set([...existing, ...newFiles])];
|
|
149
|
+
const formatted = merged.map((f) => `'${f}'`).join(", ");
|
|
150
|
+
return source.replace(copyFilesRe, `copyFiles: [${formatted}]`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// No copyFiles field — insert before the last `};`
|
|
154
|
+
const formatted = newFiles.map((f) => `'${f}'`).join(", ");
|
|
155
|
+
const insertion = ` copyFiles: [${formatted}],\n`;
|
|
156
|
+
|
|
157
|
+
const closingIdx = source.lastIndexOf("};");
|
|
158
|
+
if (closingIdx === -1) return source; // malformed config, bail
|
|
159
|
+
|
|
160
|
+
return source.slice(0, closingIdx) + insertion + source.slice(closingIdx);
|
|
161
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conflict detection and Claude-based resolution helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse `git status --porcelain` output to find conflicted files.
|
|
7
|
+
* Conflict markers: UU (both modified), AA (both added), DD, AU, UA, DU, UD
|
|
8
|
+
*/
|
|
9
|
+
export function parseConflictFiles(statusOutput: string | null | undefined): string[] {
|
|
10
|
+
if (!statusOutput?.trim()) return [];
|
|
11
|
+
return statusOutput
|
|
12
|
+
.trim()
|
|
13
|
+
.split("\n")
|
|
14
|
+
.filter((line) => /^(UU|AA|DD|AU|UA|DU|UD)\s/.test(line))
|
|
15
|
+
.map((line) => line.slice(3).trim());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a Claude prompt to resolve merge conflicts.
|
|
20
|
+
*/
|
|
21
|
+
export function buildConflictPrompt(conflictFiles: string[]): string {
|
|
22
|
+
return [
|
|
23
|
+
"아래 파일들에 git merge 충돌이 발생했습니다.",
|
|
24
|
+
"각 파일의 충돌 마커(<<<<<<< ======= >>>>>>>)를 읽고,",
|
|
25
|
+
"두 버전의 의도를 모두 살려서 충돌을 해결해주세요.",
|
|
26
|
+
"해결 후 충돌 마커는 완전히 제거해야 합니다.",
|
|
27
|
+
"",
|
|
28
|
+
"충돌 파일 목록:",
|
|
29
|
+
...conflictFiles.map((f) => `- ${f}`),
|
|
30
|
+
"",
|
|
31
|
+
"각 파일을 읽고 수정해주세요.",
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
interface ProcessInfo {
|
|
4
|
+
feLogs?: string[];
|
|
5
|
+
feExitCode: number | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface PlaygroundInfo {
|
|
9
|
+
fePort: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface DiagnosticCheck {
|
|
13
|
+
name: string;
|
|
14
|
+
status: string;
|
|
15
|
+
detail: string;
|
|
16
|
+
guide: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function tryExec(cmd: string): string | null {
|
|
20
|
+
try {
|
|
21
|
+
return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkPortConflict(processInfo: ProcessInfo): DiagnosticCheck {
|
|
28
|
+
const combined = (processInfo.feLogs ?? []).join("");
|
|
29
|
+
const hit = /address already in use/i.test(combined);
|
|
30
|
+
return {
|
|
31
|
+
name: "port-conflict",
|
|
32
|
+
status: hit ? "error" : "ok",
|
|
33
|
+
detail: hit ? "다른 프로그램과 충돌이 발생했습니다." : "정상.",
|
|
34
|
+
guide: hit
|
|
35
|
+
? '"중지" → "시작"을 눌러보세요. 계속되면 "삭제" 후 다시 만들어보세요.'
|
|
36
|
+
: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function checkFrontendExit(processInfo: ProcessInfo): DiagnosticCheck {
|
|
41
|
+
const { feExitCode, feLogs } = processInfo;
|
|
42
|
+
|
|
43
|
+
if (feExitCode === null || feExitCode === 0) {
|
|
44
|
+
return {
|
|
45
|
+
name: "frontend-exit",
|
|
46
|
+
status: "ok",
|
|
47
|
+
detail: feExitCode === 0 ? "Frontend가 정상 종료되었습니다." : "Frontend 프로세스 실행 중.",
|
|
48
|
+
guide: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tail = (feLogs ?? []).join("").slice(-500);
|
|
53
|
+
const isModuleError = /MODULE_NOT_FOUND|Cannot find module/i.test(tail);
|
|
54
|
+
const guide = isModuleError
|
|
55
|
+
? '필요한 패키지가 없어요. "처음부터 다시"를 눌러 의존성을 다시 설치해보세요.'
|
|
56
|
+
: '서버가 에러로 종료됐어요. "처음부터 다시"를 누르거나, "디버그" 버튼으로 로그를 복사해서 Claude에게 물어보세요.';
|
|
57
|
+
return {
|
|
58
|
+
name: "frontend-exit",
|
|
59
|
+
status: "error",
|
|
60
|
+
detail: `Frontend가 비정상 종료되었습니다 (코드 ${feExitCode}).`,
|
|
61
|
+
guide,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkFePort(pg: PlaygroundInfo): DiagnosticCheck {
|
|
66
|
+
const port = pg.fePort;
|
|
67
|
+
const output = tryExec(`lsof -i :${port} -t`);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: "fe-status",
|
|
71
|
+
status: output?.length ? "ok" : "warn",
|
|
72
|
+
detail: output?.length
|
|
73
|
+
? "Frontend 서버가 실행 중입니다."
|
|
74
|
+
: "Frontend 서버가 응답하지 않습니다.",
|
|
75
|
+
guide: !output?.length ? '"시작" 버튼을 눌러보세요.' : null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function buildDiagnostics(pg: PlaygroundInfo, processInfo: ProcessInfo): Promise<DiagnosticCheck[]> {
|
|
80
|
+
return [checkPortConflict(processInfo), checkFrontendExit(processInfo), checkFePort(pg)];
|
|
81
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generate a short English slug from a task description using AI.
|
|
5
|
+
* Returns null if AI is unavailable — caller should fallback to slugify().
|
|
6
|
+
*/
|
|
7
|
+
export function aiSlugify(description: string): string | null {
|
|
8
|
+
try {
|
|
9
|
+
const result = spawnSync(
|
|
10
|
+
"claude",
|
|
11
|
+
["-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}"`],
|
|
12
|
+
{ encoding: "utf8", stdio: "pipe", timeout: 10_000 },
|
|
13
|
+
);
|
|
14
|
+
if (result.status !== 0) return null;
|
|
15
|
+
const slug = (result.stdout ?? "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/^-+|-+$/g, "");
|
|
16
|
+
if (!slug || slug.length > 30) return null;
|
|
17
|
+
return slug;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Korean → romanized mappings for common dev terms
|
|
24
|
+
const KOREAN_MAP: Record<string, string> = {
|
|
25
|
+
로그인: "login",
|
|
26
|
+
버튼: "button",
|
|
27
|
+
페이지: "page",
|
|
28
|
+
추가: "add",
|
|
29
|
+
수정: "fix",
|
|
30
|
+
삭제: "delete",
|
|
31
|
+
변경: "change",
|
|
32
|
+
개선: "improve",
|
|
33
|
+
색상: "color",
|
|
34
|
+
대시보드: "dashboard",
|
|
35
|
+
설정: "settings",
|
|
36
|
+
사용자: "user",
|
|
37
|
+
관리: "manage",
|
|
38
|
+
목록: "list",
|
|
39
|
+
검색: "search",
|
|
40
|
+
필터: "filter",
|
|
41
|
+
정렬: "sort",
|
|
42
|
+
알림: "notification",
|
|
43
|
+
권한: "permission",
|
|
44
|
+
기기: "device",
|
|
45
|
+
소프트웨어: "software",
|
|
46
|
+
자산: "asset",
|
|
47
|
+
구성원: "member",
|
|
48
|
+
보고서: "report",
|
|
49
|
+
차트: "chart",
|
|
50
|
+
테이블: "table",
|
|
51
|
+
폼: "form",
|
|
52
|
+
모달: "modal",
|
|
53
|
+
메뉴: "menu",
|
|
54
|
+
헤더: "header",
|
|
55
|
+
푸터: "footer",
|
|
56
|
+
사이드바: "sidebar",
|
|
57
|
+
카드: "card",
|
|
58
|
+
탭: "tab",
|
|
59
|
+
에러: "error",
|
|
60
|
+
로딩: "loading",
|
|
61
|
+
빈: "empty",
|
|
62
|
+
새: "new",
|
|
63
|
+
기존: "existing",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Convert a task description (Korean or English) to a kebab-case branch-safe slug.
|
|
68
|
+
* Max 50 chars.
|
|
69
|
+
*/
|
|
70
|
+
export function slugify(text: string): string {
|
|
71
|
+
let result: string = text.toLowerCase();
|
|
72
|
+
|
|
73
|
+
// Replace known Korean words with English
|
|
74
|
+
for (const [ko, en] of Object.entries(KOREAN_MAP)) {
|
|
75
|
+
result = result.replaceAll(ko, en);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Remove remaining non-ASCII (unmapped Korean etc.)
|
|
79
|
+
result = result.replace(/[^\x00-\x7F]/g, "");
|
|
80
|
+
|
|
81
|
+
// Replace non-alphanumeric with hyphens
|
|
82
|
+
result = result.replace(/[^a-z0-9]+/g, "-");
|
|
83
|
+
|
|
84
|
+
// Clean up leading/trailing/double hyphens
|
|
85
|
+
result = result.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
86
|
+
|
|
87
|
+
// Truncate to 50 chars at word boundary
|
|
88
|
+
if (result.length > 50) {
|
|
89
|
+
result = result.slice(0, 50).replace(/-[^-]*$/, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result || "camp";
|
|
93
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import type { Camp, PortAllocation, PortStatus, PortsConfig } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
const portConfig: PortsConfig = {
|
|
5
|
+
fe: { base: 3000, slots: 8 },
|
|
6
|
+
be: { base: 8000, slots: 8 },
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function setPortConfig(config: Partial<PortsConfig>): void {
|
|
10
|
+
if (config?.fe) portConfig.fe = config.fe;
|
|
11
|
+
if (config?.be) portConfig.be = config.be;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function portsForSlot(slot: number): { fePort: number; bePort: number } {
|
|
15
|
+
return {
|
|
16
|
+
fePort: portConfig.fe.base + slot,
|
|
17
|
+
bePort: portConfig.be.base + slot,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isPortBusy(port: number): boolean {
|
|
22
|
+
try {
|
|
23
|
+
const out = execSync(`lsof -i :${port} -t 2>/dev/null`, { encoding: "utf8" }).trim();
|
|
24
|
+
return out.length > 0;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function scanPorts(): PortStatus[] {
|
|
31
|
+
const status: PortStatus[] = [];
|
|
32
|
+
const maxSlots = Math.max(portConfig.fe.slots, portConfig.be.slots);
|
|
33
|
+
for (let slot = 0; slot < maxSlots; slot++) {
|
|
34
|
+
const { fePort, bePort } = portsForSlot(slot);
|
|
35
|
+
status.push({
|
|
36
|
+
slot,
|
|
37
|
+
fePort,
|
|
38
|
+
feBusy: isPortBusy(fePort),
|
|
39
|
+
bePort,
|
|
40
|
+
beBusy: isPortBusy(bePort),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return status;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function allocate(existingCamps: Pick<Camp, "slot">[]): PortAllocation {
|
|
47
|
+
const usedSlots = new Set(existingCamps.map((p) => p.slot));
|
|
48
|
+
const maxSlots = portConfig.fe.slots;
|
|
49
|
+
|
|
50
|
+
for (let slot = 1; slot < maxSlots; slot++) {
|
|
51
|
+
if (usedSlots.has(slot)) continue;
|
|
52
|
+
const { fePort, bePort } = portsForSlot(slot);
|
|
53
|
+
if (!isPortBusy(fePort) && !isPortBusy(bePort)) {
|
|
54
|
+
return { slot, fePort, bePort };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error("사용 가능한 포트가 없습니다. 다른 프로그램이 포트를 점유하고 있거나, 캠프를 정리해주세요.");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function release(_name: string): void {}
|
package/lib/engine/pr.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR body generation helpers.
|
|
3
|
+
*
|
|
4
|
+
* buildFallbackPrBody — used when Claude CLI is unavailable.
|
|
5
|
+
* buildClaudePrPrompt — prompt fed to `claude -p` for a rich PR body.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface FallbackPrBodyOptions {
|
|
9
|
+
message: string;
|
|
10
|
+
actions: Array<{ description: string }>;
|
|
11
|
+
diffStat: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ClaudePrPromptOptions {
|
|
15
|
+
message: string;
|
|
16
|
+
diffStat: string;
|
|
17
|
+
diff: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a fallback PR body when Claude CLI is unavailable.
|
|
22
|
+
*/
|
|
23
|
+
export function buildFallbackPrBody({ message, actions, diffStat }: FallbackPrBodyOptions): string {
|
|
24
|
+
const lines: string[] = ["## Summary", "", message];
|
|
25
|
+
|
|
26
|
+
if (actions?.length > 0) {
|
|
27
|
+
lines.push("", "### 작업 내역");
|
|
28
|
+
for (const a of actions) {
|
|
29
|
+
lines.push(`- ${a.description}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (diffStat?.trim()) {
|
|
34
|
+
lines.push("", "### 변경된 파일", "```", diffStat.trim(), "```");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
lines.push("", "---", "_🏔 산장에서 보냄_");
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a prompt for Claude to generate a rich PR body from the diff.
|
|
43
|
+
*/
|
|
44
|
+
export function buildClaudePrPrompt({ message, diffStat, diff }: ClaudePrPromptOptions): string {
|
|
45
|
+
return [
|
|
46
|
+
"You are writing a GitHub Pull Request description.",
|
|
47
|
+
'The author described the change as: "' + message + '"',
|
|
48
|
+
"",
|
|
49
|
+
"Here is the diff stat:",
|
|
50
|
+
diffStat || "(no stat)",
|
|
51
|
+
"",
|
|
52
|
+
"Here is the full diff (may be truncated):",
|
|
53
|
+
(diff || "").slice(0, 8000),
|
|
54
|
+
"",
|
|
55
|
+
"Write a PR body in this format:",
|
|
56
|
+
"## Summary",
|
|
57
|
+
"<2-3 bullet points explaining what changed and why>",
|
|
58
|
+
"",
|
|
59
|
+
"## Changes",
|
|
60
|
+
"<brief description of each modified file/component>",
|
|
61
|
+
"",
|
|
62
|
+
"## Test plan",
|
|
63
|
+
"<how to verify this works>",
|
|
64
|
+
"",
|
|
65
|
+
"---",
|
|
66
|
+
"_🏔 산장에서 보냄_",
|
|
67
|
+
"",
|
|
68
|
+
"Write in Korean if the commit message is Korean, English otherwise.",
|
|
69
|
+
"Be concise. No filler. Just the facts.",
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|