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.
- package/dist/bin/sanjang.d.ts +1 -0
- package/dist/bin/sanjang.js +138 -0
- package/dist/lib/config.d.ts +19 -0
- package/dist/lib/config.js +318 -0
- package/dist/lib/engine/cache.d.ts +7 -0
- package/dist/lib/engine/cache.js +183 -0
- package/dist/lib/engine/config-hotfix.d.ts +7 -0
- package/dist/lib/engine/config-hotfix.js +129 -0
- package/dist/lib/engine/conflict.d.ts +12 -0
- package/dist/lib/engine/conflict.js +32 -0
- package/dist/lib/engine/diagnostics.d.ts +15 -0
- package/dist/lib/engine/diagnostics.js +58 -0
- package/dist/lib/engine/naming.d.ts +10 -0
- package/dist/lib/engine/naming.js +83 -0
- package/dist/lib/engine/ports.d.ts +9 -0
- package/dist/lib/engine/ports.js +55 -0
- package/dist/lib/engine/pr.d.ts +27 -0
- package/dist/lib/engine/pr.js +54 -0
- package/dist/lib/engine/process.d.ts +15 -0
- package/dist/lib/engine/process.js +250 -0
- package/dist/lib/engine/self-heal.d.ts +12 -0
- package/dist/lib/engine/self-heal.js +98 -0
- package/dist/lib/engine/smart-init.d.ts +7 -0
- package/dist/lib/engine/smart-init.js +138 -0
- package/dist/lib/engine/smart-pr.d.ts +19 -0
- package/dist/lib/engine/smart-pr.js +105 -0
- package/dist/lib/engine/snapshot.d.ts +10 -0
- package/dist/lib/engine/snapshot.js +35 -0
- package/dist/lib/engine/state.d.ts +7 -0
- package/dist/lib/engine/state.js +53 -0
- package/dist/lib/engine/suggest.d.ts +21 -0
- package/dist/lib/engine/suggest.js +121 -0
- package/dist/lib/engine/warp.d.ts +23 -0
- package/dist/lib/engine/warp.js +32 -0
- package/dist/lib/engine/watcher.d.ts +11 -0
- package/dist/lib/engine/watcher.js +43 -0
- package/dist/lib/engine/worktree.d.ts +13 -0
- package/dist/lib/engine/worktree.js +91 -0
- package/dist/lib/server.d.ts +20 -0
- package/dist/lib/server.js +1399 -0
- package/dist/lib/types.d.ts +109 -0
- package/dist/lib/types.js +2 -0
- package/package.json +5 -5
- package/bin/__tests__/sanjang.test.ts +0 -42
- package/bin/sanjang.js +0 -17
- package/bin/sanjang.ts +0 -144
- package/lib/config.ts +0 -337
- package/lib/engine/cache.ts +0 -218
- package/lib/engine/config-hotfix.ts +0 -161
- package/lib/engine/conflict.ts +0 -33
- package/lib/engine/diagnostics.ts +0 -81
- package/lib/engine/naming.ts +0 -93
- package/lib/engine/ports.ts +0 -61
- package/lib/engine/pr.ts +0 -71
- package/lib/engine/process.ts +0 -283
- package/lib/engine/self-heal.ts +0 -130
- package/lib/engine/smart-init.ts +0 -136
- package/lib/engine/smart-pr.ts +0 -130
- package/lib/engine/snapshot.ts +0 -45
- package/lib/engine/state.ts +0 -60
- package/lib/engine/suggest.ts +0 -169
- package/lib/engine/warp.ts +0 -47
- package/lib/engine/watcher.ts +0 -40
- package/lib/engine/worktree.ts +0 -100
- package/lib/server.ts +0 -1560
- package/lib/types.ts +0 -130
package/lib/engine/cache.ts
DELETED
|
@@ -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
|
-
}
|
package/lib/engine/conflict.ts
DELETED
|
@@ -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
|
-
}
|
package/lib/engine/naming.ts
DELETED
|
@@ -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
|
-
}
|
package/lib/engine/ports.ts
DELETED
|
@@ -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 {}
|