sanjang 0.3.0 → 0.3.2

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.
Files changed (66) hide show
  1. package/dist/bin/sanjang.d.ts +1 -0
  2. package/dist/bin/sanjang.js +138 -0
  3. package/dist/lib/config.d.ts +19 -0
  4. package/dist/lib/config.js +318 -0
  5. package/dist/lib/engine/cache.d.ts +7 -0
  6. package/dist/lib/engine/cache.js +183 -0
  7. package/dist/lib/engine/config-hotfix.d.ts +7 -0
  8. package/dist/lib/engine/config-hotfix.js +129 -0
  9. package/dist/lib/engine/conflict.d.ts +12 -0
  10. package/dist/lib/engine/conflict.js +32 -0
  11. package/dist/lib/engine/diagnostics.d.ts +15 -0
  12. package/dist/lib/engine/diagnostics.js +58 -0
  13. package/dist/lib/engine/naming.d.ts +10 -0
  14. package/dist/lib/engine/naming.js +83 -0
  15. package/dist/lib/engine/ports.d.ts +9 -0
  16. package/dist/lib/engine/ports.js +55 -0
  17. package/dist/lib/engine/pr.d.ts +27 -0
  18. package/dist/lib/engine/pr.js +54 -0
  19. package/dist/lib/engine/process.d.ts +15 -0
  20. package/dist/lib/engine/process.js +250 -0
  21. package/dist/lib/engine/self-heal.d.ts +12 -0
  22. package/dist/lib/engine/self-heal.js +98 -0
  23. package/dist/lib/engine/smart-init.d.ts +7 -0
  24. package/dist/lib/engine/smart-init.js +138 -0
  25. package/dist/lib/engine/smart-pr.d.ts +19 -0
  26. package/dist/lib/engine/smart-pr.js +105 -0
  27. package/dist/lib/engine/snapshot.d.ts +10 -0
  28. package/dist/lib/engine/snapshot.js +35 -0
  29. package/dist/lib/engine/state.d.ts +7 -0
  30. package/dist/lib/engine/state.js +53 -0
  31. package/dist/lib/engine/suggest.d.ts +21 -0
  32. package/dist/lib/engine/suggest.js +121 -0
  33. package/dist/lib/engine/warp.d.ts +23 -0
  34. package/dist/lib/engine/warp.js +32 -0
  35. package/dist/lib/engine/watcher.d.ts +11 -0
  36. package/dist/lib/engine/watcher.js +43 -0
  37. package/dist/lib/engine/worktree.d.ts +13 -0
  38. package/dist/lib/engine/worktree.js +91 -0
  39. package/dist/lib/server.d.ts +20 -0
  40. package/dist/lib/server.js +1399 -0
  41. package/dist/lib/types.d.ts +109 -0
  42. package/dist/lib/types.js +2 -0
  43. package/package.json +5 -5
  44. package/bin/__tests__/sanjang.test.ts +0 -42
  45. package/bin/sanjang.js +0 -17
  46. package/bin/sanjang.ts +0 -144
  47. package/lib/config.ts +0 -337
  48. package/lib/engine/cache.ts +0 -218
  49. package/lib/engine/config-hotfix.ts +0 -161
  50. package/lib/engine/conflict.ts +0 -33
  51. package/lib/engine/diagnostics.ts +0 -81
  52. package/lib/engine/naming.ts +0 -93
  53. package/lib/engine/ports.ts +0 -61
  54. package/lib/engine/pr.ts +0 -71
  55. package/lib/engine/process.ts +0 -283
  56. package/lib/engine/self-heal.ts +0 -130
  57. package/lib/engine/smart-init.ts +0 -136
  58. package/lib/engine/smart-pr.ts +0 -130
  59. package/lib/engine/snapshot.ts +0 -45
  60. package/lib/engine/state.ts +0 -60
  61. package/lib/engine/suggest.ts +0 -169
  62. package/lib/engine/warp.ts +0 -47
  63. package/lib/engine/watcher.ts +0 -40
  64. package/lib/engine/worktree.ts +0 -100
  65. package/lib/server.ts +0 -1560
  66. package/lib/types.ts +0 -130
@@ -1,218 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
- import { join, relative } from "node:path";
5
- import type { CacheApplyResult, CacheBuildResult, CacheValidation, LockfileInfo, SanjangConfig } from "../types.ts";
6
-
7
- const LOCKFILES: readonly string[] = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock"];
8
-
9
- // ---------------------------------------------------------------------------
10
- // Lockfile helpers
11
- // ---------------------------------------------------------------------------
12
-
13
- export function findLockfile(dir: string): LockfileInfo | null {
14
- for (const name of LOCKFILES) {
15
- const p = join(dir, name);
16
- if (existsSync(p)) return { path: p, name };
17
- }
18
- return null;
19
- }
20
-
21
- export function hashLockfile(lockfilePath: string): string {
22
- const content = readFileSync(lockfilePath);
23
- return createHash("sha256").update(content).digest("hex");
24
- }
25
-
26
- // ---------------------------------------------------------------------------
27
- // Cache directory
28
- // ---------------------------------------------------------------------------
29
-
30
- export function getCacheDir(projectRoot: string): string {
31
- return join(projectRoot, ".sanjang", "cache");
32
- }
33
-
34
- function getCacheModulesDir(projectRoot: string, setupCwd: string): string {
35
- const base = getCacheDir(projectRoot);
36
- return setupCwd === "." ? join(base, "node_modules") : join(base, setupCwd, "node_modules");
37
- }
38
-
39
- function getHashFile(projectRoot: string, setupCwd: string = "."): string {
40
- const base = getCacheDir(projectRoot);
41
- const name = setupCwd === "." ? "lockfile.hash" : `lockfile-${setupCwd.replace(/\//g, "-")}.hash`;
42
- return join(base, name);
43
- }
44
-
45
- // ---------------------------------------------------------------------------
46
- // Cache validation
47
- // ---------------------------------------------------------------------------
48
-
49
- export function isCacheValid(projectRoot: string, setupCwd: string): CacheValidation {
50
- const cacheModules = getCacheModulesDir(projectRoot, setupCwd);
51
- if (!existsSync(cacheModules)) {
52
- return { valid: false, reason: "cache not found" };
53
- }
54
-
55
- const hashFile = getHashFile(projectRoot, setupCwd);
56
- if (!existsSync(hashFile)) {
57
- return { valid: false, reason: "no hash file" };
58
- }
59
-
60
- const srcDir = setupCwd === "." ? projectRoot : join(projectRoot, setupCwd);
61
- const lockfile = findLockfile(srcDir);
62
- if (!lockfile) {
63
- return { valid: false, reason: "no lockfile in project" };
64
- }
65
-
66
- const storedHash = readFileSync(hashFile, "utf8").trim();
67
- const currentHash = hashLockfile(lockfile.path);
68
-
69
- if (storedHash !== currentHash) {
70
- return { valid: false, reason: "lockfile changed" };
71
- }
72
-
73
- return { valid: true };
74
- }
75
-
76
- // ---------------------------------------------------------------------------
77
- // Find all node_modules dirs (monorepo support)
78
- // ---------------------------------------------------------------------------
79
-
80
- function findAllNodeModules(baseDir: string, maxDepth: number = 4): string[] {
81
- const results: string[] = [];
82
- function walk(dir: string, depth: number): void {
83
- if (depth > maxDepth) return;
84
- if (!existsSync(dir)) return;
85
- let entries: import("node:fs").Dirent[];
86
- try {
87
- entries = readdirSync(dir, { withFileTypes: true });
88
- } catch {
89
- return;
90
- }
91
- for (const entry of entries) {
92
- if (!entry.isDirectory()) continue;
93
- if (entry.name === "node_modules") {
94
- results.push(join(dir, entry.name));
95
- } else if (!entry.name.startsWith(".")) {
96
- walk(join(dir, entry.name), depth + 1);
97
- }
98
- }
99
- }
100
- walk(baseDir, 0);
101
- return results;
102
- }
103
-
104
- // ---------------------------------------------------------------------------
105
- // Build cache
106
- // ---------------------------------------------------------------------------
107
-
108
- export async function buildCache(
109
- projectRoot: string,
110
- config: Pick<SanjangConfig, "dev" | "setup">,
111
- onLog?: (msg: string) => void,
112
- ): Promise<CacheBuildResult> {
113
- const start = Date.now();
114
- const setupCwd = config.dev?.cwd || ".";
115
- const srcDir = setupCwd === "." ? projectRoot : join(projectRoot, setupCwd);
116
- const modulesDir = join(srcDir, "node_modules");
117
-
118
- const lockfile = findLockfile(srcDir);
119
- if (!lockfile) {
120
- return { success: false, error: "lockfile not found", duration: Date.now() - start };
121
- }
122
-
123
- if (!existsSync(modulesDir)) {
124
- if (!config.setup) {
125
- return { success: false, error: "no setup command and no node_modules", duration: Date.now() - start };
126
- }
127
-
128
- onLog?.("node_modules가 없습니다. 설치를 실행합니다...");
129
- const exitCode = await runSetup(config.setup, projectRoot, onLog);
130
- if (exitCode !== 0) {
131
- return { success: false, error: `setup failed (exit ${exitCode})`, duration: Date.now() - start };
132
- }
133
-
134
- if (!existsSync(modulesDir)) {
135
- return { success: false, error: "setup completed but node_modules not found", duration: Date.now() - start };
136
- }
137
- }
138
-
139
- const allModules = findAllNodeModules(srcDir);
140
- onLog?.(`캐시에 node_modules를 저장합니다... (${allModules.length}개 디렉토리)`);
141
-
142
- const cacheDir = getCacheDir(projectRoot);
143
- const cacheBase = setupCwd === "." ? cacheDir : join(cacheDir, setupCwd);
144
-
145
- if (existsSync(cacheBase)) {
146
- rmSync(cacheBase, { recursive: true, force: true });
147
- }
148
- mkdirSync(cacheBase, { recursive: true });
149
-
150
- try {
151
- for (const modDir of allModules) {
152
- const rel = relative(srcDir, modDir);
153
- const target = join(cacheBase, rel);
154
- mkdirSync(join(target, ".."), { recursive: true });
155
- cpSync(modDir, target, { recursive: true });
156
- }
157
- } catch (err) {
158
- return { success: false, error: `cache copy failed: ${(err as Error).message}`, duration: Date.now() - start };
159
- }
160
-
161
- writeFileSync(getHashFile(projectRoot, setupCwd), hashLockfile(lockfile.path), "utf8");
162
-
163
- const duration = Date.now() - start;
164
- onLog?.(`캐시 저장 완료 (${allModules.length}개, ${(duration / 1000).toFixed(1)}초)`);
165
- return { success: true, duration };
166
- }
167
-
168
- // ---------------------------------------------------------------------------
169
- // Apply cache to worktree
170
- // ---------------------------------------------------------------------------
171
-
172
- export function applyCacheToWorktree(projectRoot: string, wtPath: string, setupCwd: string): CacheApplyResult {
173
- const start = Date.now();
174
- const validity = isCacheValid(projectRoot, setupCwd);
175
-
176
- if (!validity.valid) {
177
- return { applied: false, reason: validity.reason };
178
- }
179
-
180
- const cacheBase = setupCwd === "." ? getCacheDir(projectRoot) : join(getCacheDir(projectRoot), setupCwd);
181
- const targetBase = setupCwd === "." ? wtPath : join(wtPath, setupCwd);
182
-
183
- const cachedModules = findAllNodeModules(cacheBase);
184
- if (cachedModules.length === 0) {
185
- return { applied: false, reason: "no cached node_modules found" };
186
- }
187
-
188
- try {
189
- for (const cachedDir of cachedModules) {
190
- const rel = relative(cacheBase, cachedDir);
191
- const target = join(targetBase, rel);
192
- mkdirSync(join(target, ".."), { recursive: true });
193
- cpSync(cachedDir, target, { recursive: true });
194
- }
195
- } catch (err) {
196
- return { applied: false, reason: `clone failed: ${(err as Error).message}` };
197
- }
198
-
199
- return { applied: true, duration: Date.now() - start, count: cachedModules.length };
200
- }
201
-
202
- // ---------------------------------------------------------------------------
203
- // Internal: run setup command
204
- // ---------------------------------------------------------------------------
205
-
206
- function runSetup(command: string, cwd: string, onLog?: (msg: string) => void): Promise<number> {
207
- return new Promise((resolve) => {
208
- const proc = spawn(command, [], {
209
- cwd,
210
- shell: true,
211
- stdio: ["ignore", "pipe", "pipe"],
212
- });
213
- proc.stdout.on("data", (d: Buffer) => onLog?.(d.toString().trimEnd()));
214
- proc.stderr.on("data", (d: Buffer) => onLog?.(d.toString().trimEnd()));
215
- proc.on("close", (code: number | null) => resolve(code ?? 1));
216
- proc.on("error", () => resolve(1));
217
- });
218
- }
@@ -1,161 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
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
- }
@@ -1,81 +0,0 @@
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
- }
@@ -1,93 +0,0 @@
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
- }
@@ -1,61 +0,0 @@
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 {}