sanjang 0.3.1 → 0.3.3

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 (69) hide show
  1. package/dashboard/app.js +304 -44
  2. package/dashboard/index.html +43 -6
  3. package/dashboard/style.css +306 -0
  4. package/dist/bin/sanjang.d.ts +1 -0
  5. package/dist/bin/sanjang.js +138 -0
  6. package/dist/lib/config.d.ts +19 -0
  7. package/dist/lib/config.js +318 -0
  8. package/dist/lib/engine/cache.d.ts +7 -0
  9. package/dist/lib/engine/cache.js +183 -0
  10. package/dist/lib/engine/config-hotfix.d.ts +7 -0
  11. package/dist/lib/engine/config-hotfix.js +129 -0
  12. package/dist/lib/engine/conflict.d.ts +12 -0
  13. package/dist/lib/engine/conflict.js +32 -0
  14. package/dist/lib/engine/diagnostics.d.ts +15 -0
  15. package/dist/lib/engine/diagnostics.js +58 -0
  16. package/dist/lib/engine/naming.d.ts +10 -0
  17. package/dist/lib/engine/naming.js +83 -0
  18. package/dist/lib/engine/ports.d.ts +9 -0
  19. package/dist/lib/engine/ports.js +55 -0
  20. package/dist/lib/engine/pr.d.ts +27 -0
  21. package/dist/lib/engine/pr.js +54 -0
  22. package/dist/lib/engine/process.d.ts +15 -0
  23. package/dist/lib/engine/process.js +250 -0
  24. package/dist/lib/engine/self-heal.d.ts +12 -0
  25. package/dist/lib/engine/self-heal.js +98 -0
  26. package/dist/lib/engine/smart-init.d.ts +7 -0
  27. package/dist/lib/engine/smart-init.js +138 -0
  28. package/dist/lib/engine/smart-pr.d.ts +19 -0
  29. package/dist/lib/engine/smart-pr.js +105 -0
  30. package/dist/lib/engine/snapshot.d.ts +10 -0
  31. package/dist/lib/engine/snapshot.js +35 -0
  32. package/dist/lib/engine/state.d.ts +7 -0
  33. package/dist/lib/engine/state.js +53 -0
  34. package/dist/lib/engine/suggest.d.ts +21 -0
  35. package/dist/lib/engine/suggest.js +121 -0
  36. package/dist/lib/engine/warp.d.ts +23 -0
  37. package/dist/lib/engine/warp.js +32 -0
  38. package/dist/lib/engine/watcher.d.ts +11 -0
  39. package/dist/lib/engine/watcher.js +43 -0
  40. package/dist/lib/engine/worktree.d.ts +13 -0
  41. package/dist/lib/engine/worktree.js +91 -0
  42. package/dist/lib/server.d.ts +20 -0
  43. package/dist/lib/server.js +1466 -0
  44. package/dist/lib/types.d.ts +109 -0
  45. package/dist/lib/types.js +2 -0
  46. package/package.json +5 -4
  47. package/bin/__tests__/sanjang.test.ts +0 -42
  48. package/bin/sanjang.js +0 -17
  49. package/bin/sanjang.ts +0 -144
  50. package/lib/config.ts +0 -337
  51. package/lib/engine/cache.ts +0 -218
  52. package/lib/engine/config-hotfix.ts +0 -161
  53. package/lib/engine/conflict.ts +0 -33
  54. package/lib/engine/diagnostics.ts +0 -81
  55. package/lib/engine/naming.ts +0 -93
  56. package/lib/engine/ports.ts +0 -61
  57. package/lib/engine/pr.ts +0 -71
  58. package/lib/engine/process.ts +0 -283
  59. package/lib/engine/self-heal.ts +0 -130
  60. package/lib/engine/smart-init.ts +0 -136
  61. package/lib/engine/smart-pr.ts +0 -130
  62. package/lib/engine/snapshot.ts +0 -45
  63. package/lib/engine/state.ts +0 -60
  64. package/lib/engine/suggest.ts +0 -169
  65. package/lib/engine/warp.ts +0 -47
  66. package/lib/engine/watcher.ts +0 -40
  67. package/lib/engine/worktree.ts +0 -100
  68. package/lib/server.ts +0 -1560
  69. package/lib/types.ts +0 -130
@@ -0,0 +1,318 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ import { deepFindEnvFiles, detectSetupIssues } from "./engine/smart-init.js";
13
+ const CONFIG_FILE = "sanjang.config.js";
14
+ const DEFAULTS = {
15
+ dev: {
16
+ command: "npm run dev",
17
+ port: 3000,
18
+ portFlag: "--port",
19
+ cwd: ".",
20
+ env: {},
21
+ },
22
+ setup: null,
23
+ copyFiles: [],
24
+ backend: null,
25
+ ports: {
26
+ fe: { base: 3000, slots: 8 },
27
+ be: { base: 8000, slots: 8 },
28
+ },
29
+ };
30
+ /**
31
+ * Load sanjang.config.js from project root.
32
+ * Returns merged config with defaults.
33
+ */
34
+ export async function loadConfig(projectRoot) {
35
+ const configPath = join(projectRoot, CONFIG_FILE);
36
+ if (!existsSync(configPath)) {
37
+ console.warn("⚠️ sanjang.config.js를 찾을 수 없습니다. 기본 설정을 사용합니다.");
38
+ console.warn(" → sanjang init 으로 프로젝트에 맞는 설정을 생성하세요.");
39
+ return { ...DEFAULTS, _autoDetected: false };
40
+ }
41
+ try {
42
+ const mod = await import(__rewriteRelativeImportExtension(pathToFileURL(configPath).href));
43
+ const userConfig = mod.default || mod;
44
+ return mergeConfig(userConfig);
45
+ }
46
+ catch (err) {
47
+ console.error(`sanjang.config.js 로드 실패: ${err.message}`);
48
+ return { ...DEFAULTS, _autoDetected: false };
49
+ }
50
+ }
51
+ function mergeConfig(user) {
52
+ const config = { ...DEFAULTS };
53
+ if (typeof user.dev === "string") {
54
+ config.dev = { ...DEFAULTS.dev, command: user.dev };
55
+ }
56
+ else if (user.dev) {
57
+ config.dev = { ...DEFAULTS.dev, ...user.dev };
58
+ }
59
+ if (user.setup)
60
+ config.setup = user.setup;
61
+ if (user.copyFiles)
62
+ config.copyFiles = user.copyFiles;
63
+ if (user.backend)
64
+ config.backend = user.backend;
65
+ if (user.ports) {
66
+ const userPorts = user.ports;
67
+ config.ports = {
68
+ fe: { ...DEFAULTS.ports.fe, ...userPorts.fe },
69
+ be: { ...DEFAULTS.ports.be, ...userPorts.be },
70
+ };
71
+ }
72
+ return config;
73
+ }
74
+ /**
75
+ * Auto-detect project type and generate config.
76
+ */
77
+ export function detectProject(projectRoot) {
78
+ const has = (f) => existsSync(join(projectRoot, f));
79
+ const readJson = (f) => {
80
+ try {
81
+ return JSON.parse(readFileSync(join(projectRoot, f), "utf8"));
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ };
87
+ // Framework detection
88
+ if (has("next.config.js") || has("next.config.mjs") || has("next.config.ts")) {
89
+ return {
90
+ framework: "Next.js",
91
+ dev: { command: "npx next dev", port: 3000, portFlag: "-p", cwd: ".", env: {} },
92
+ setup: has("bun.lockb") ? "bun install" : has("pnpm-lock.yaml") ? "pnpm install" : "npm install",
93
+ copyFiles: findEnvFiles(projectRoot),
94
+ };
95
+ }
96
+ if (has("nuxt.config.js") || has("nuxt.config.ts")) {
97
+ return {
98
+ framework: "Nuxt",
99
+ dev: { command: "npx nuxt dev", port: 3000, portFlag: "--port", cwd: ".", env: {} },
100
+ setup: detectPackageManager(projectRoot),
101
+ copyFiles: findEnvFiles(projectRoot),
102
+ };
103
+ }
104
+ if (has("svelte.config.js") || has("svelte.config.ts")) {
105
+ return {
106
+ framework: "SvelteKit",
107
+ dev: { command: "npx vite dev", port: 5173, portFlag: "--port", cwd: ".", env: {} },
108
+ setup: detectPackageManager(projectRoot),
109
+ copyFiles: findEnvFiles(projectRoot),
110
+ };
111
+ }
112
+ if (has("angular.json")) {
113
+ return {
114
+ framework: "Angular",
115
+ dev: { command: "npx ng serve", port: 4200, portFlag: "--port", cwd: ".", env: {} },
116
+ setup: "npm install",
117
+ copyFiles: findEnvFiles(projectRoot),
118
+ };
119
+ }
120
+ if (has("vite.config.js") || has("vite.config.ts") || has("vite.config.mjs")) {
121
+ return {
122
+ framework: "Vite",
123
+ dev: { command: "npx vite dev", port: 5173, portFlag: "--port", cwd: ".", env: {} },
124
+ setup: detectPackageManager(projectRoot),
125
+ copyFiles: findEnvFiles(projectRoot),
126
+ };
127
+ }
128
+ // ClojureScript / shadow-cljs (root or common subdirectories)
129
+ const shadowDirs = [".", "frontend", "client", "web", "app"];
130
+ for (const dir of shadowDirs) {
131
+ const prefix = dir === "." ? "" : `${dir}/`;
132
+ if (has(`${prefix}shadow-cljs.edn`)) {
133
+ const hasBb = has(`${prefix}bb.edn`);
134
+ return {
135
+ framework: "shadow-cljs",
136
+ dev: { command: hasBb ? "bb dev" : "npx shadow-cljs watch app", port: 3000, portFlag: null, cwd: dir, env: {} },
137
+ setup: "npm install",
138
+ copyFiles: findEnvFiles(projectRoot),
139
+ };
140
+ }
141
+ }
142
+ // Monorepo detection
143
+ if (has("turbo.json")) {
144
+ const mainApp = detectTurboMainApp(projectRoot);
145
+ const filter = mainApp ? ` --filter=${mainApp.name}` : "";
146
+ const port = mainApp?.port ?? 3000;
147
+ return {
148
+ framework: "Turborepo",
149
+ dev: { command: `npx turbo run dev${filter}`, port, portFlag: null, cwd: ".", env: {} },
150
+ setup: detectPackageManager(projectRoot),
151
+ copyFiles: findEnvFiles(projectRoot),
152
+ _note: mainApp
153
+ ? `Turborepo: filtered to ${mainApp.name} (port ${port}).`
154
+ : "Turborepo detected. You may need to adjust the dev command to filter a specific app.",
155
+ };
156
+ }
157
+ // Fallback: package.json scripts
158
+ const pkg = readJson("package.json");
159
+ if (pkg?.scripts?.dev) {
160
+ return {
161
+ framework: "Node.js",
162
+ dev: { command: "npm run dev", port: 3000, portFlag: "--port", cwd: ".", env: {} },
163
+ setup: detectPackageManager(projectRoot),
164
+ copyFiles: findEnvFiles(projectRoot),
165
+ };
166
+ }
167
+ return {
168
+ framework: "unknown",
169
+ dev: { command: "npm run dev", port: 3000, portFlag: "--port", cwd: ".", env: {} },
170
+ setup: "npm install",
171
+ copyFiles: [],
172
+ };
173
+ }
174
+ /**
175
+ * Scan first-level subdirectories for app candidates.
176
+ * Returns array of { dir, framework, detected } sorted by dir name.
177
+ */
178
+ export function detectApps(projectRoot) {
179
+ const entries = readdirSync(projectRoot, { withFileTypes: true });
180
+ const ignore = new Set(["node_modules", ".git", ".sanjang", "dist", "build", ".next", ".nuxt"]);
181
+ const apps = [];
182
+ for (const entry of entries) {
183
+ if (!entry.isDirectory())
184
+ continue;
185
+ if (entry.name.startsWith(".") || ignore.has(entry.name))
186
+ continue;
187
+ const subPath = join(projectRoot, entry.name);
188
+ const detected = detectProject(subPath);
189
+ if (detected.framework === "unknown")
190
+ continue;
191
+ apps.push({
192
+ dir: entry.name,
193
+ framework: detected.framework,
194
+ detected,
195
+ });
196
+ }
197
+ return apps.sort((a, b) => a.dir.localeCompare(b.dir));
198
+ }
199
+ function detectTurboMainApp(root) {
200
+ // Scan apps/*/package.json for the main app (has dev script with --port or vite/next)
201
+ const appDirs = ["apps", "packages"];
202
+ const candidates = [];
203
+ for (const dir of appDirs) {
204
+ const base = join(root, dir);
205
+ if (!existsSync(base))
206
+ continue;
207
+ let entries;
208
+ try {
209
+ entries = readdirSync(base, { withFileTypes: true });
210
+ }
211
+ catch {
212
+ continue;
213
+ }
214
+ for (const entry of entries) {
215
+ if (!entry.isDirectory())
216
+ continue;
217
+ // Skip storybook, demo, docs apps
218
+ if (/storybook|demo|docs|e2e|test/i.test(entry.name))
219
+ continue;
220
+ const pkgPath = join(base, entry.name, "package.json");
221
+ if (!existsSync(pkgPath))
222
+ continue;
223
+ try {
224
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
225
+ const scripts = pkg.scripts;
226
+ const devScript = scripts?.dev;
227
+ if (!devScript)
228
+ continue;
229
+ const portMatch = devScript.match(/--port\s+(\d+)/);
230
+ const port = portMatch?.[1] ? parseInt(portMatch[1], 10) : 3000;
231
+ candidates.push({ name: entry.name, port });
232
+ }
233
+ catch {
234
+ continue;
235
+ }
236
+ }
237
+ }
238
+ // Prefer app with explicit port, then first candidate
239
+ return candidates.find(c => c.port !== 3000) ?? candidates[0] ?? null;
240
+ }
241
+ function detectPackageManager(root) {
242
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
243
+ return "bun install";
244
+ if (existsSync(join(root, "pnpm-lock.yaml")))
245
+ return "pnpm install";
246
+ if (existsSync(join(root, "yarn.lock")))
247
+ return "yarn install";
248
+ return "npm install";
249
+ }
250
+ function findEnvFiles(root) {
251
+ const envFiles = [".env", ".env.local", ".env.development", ".env.development.local"];
252
+ return envFiles.filter((f) => existsSync(join(root, f)));
253
+ }
254
+ export function generateConfig(projectRoot, options = {}) {
255
+ const { appDir, force } = options;
256
+ const configPath = join(projectRoot, CONFIG_FILE);
257
+ if (existsSync(configPath) && !force) {
258
+ return { created: false, message: "sanjang.config.js already exists." };
259
+ }
260
+ // Detect from selected app subdirectory or root
261
+ const detectRoot = appDir ? join(projectRoot, appDir) : projectRoot;
262
+ const detected = detectProject(detectRoot);
263
+ // Override cwd and setup for subdirectory apps
264
+ if (appDir && appDir !== ".") {
265
+ detected.dev.cwd = appDir;
266
+ if (detected.setup) {
267
+ detected.setup = `cd '${appDir.replace(/'/g, "'\\''")}' && ${detected.setup}`;
268
+ }
269
+ }
270
+ // Smart env detection — scan entire project tree, not just root
271
+ detected.copyFiles = deepFindEnvFiles(projectRoot).filter(
272
+ // Exclude .env.example, .env.test, .env.template
273
+ (f) => !f.includes("example") && !f.includes("template") && !f.includes(".test"));
274
+ // Detect potential issues
275
+ const issues = detectSetupIssues(detectRoot);
276
+ const lines = [
277
+ "export default {",
278
+ ` // ${detected.framework} detected`,
279
+ "",
280
+ " // Dev server command",
281
+ " dev: {",
282
+ ` command: '${detected.dev.command}',`,
283
+ ` port: ${detected.dev.port},`,
284
+ ` portFlag: ${detected.dev.portFlag ? `'${detected.dev.portFlag}'` : "null"},`,
285
+ ` cwd: '${detected.dev.cwd}',`,
286
+ " },",
287
+ "",
288
+ ];
289
+ if (detected.setup) {
290
+ lines.push(` // Install dependencies after creating a camp`);
291
+ lines.push(` setup: ${JSON.stringify(detected.setup)},`);
292
+ lines.push("");
293
+ }
294
+ if (detected.copyFiles.length) {
295
+ lines.push(" // Copy gitignored files from main repo");
296
+ lines.push(` copyFiles: ${JSON.stringify(detected.copyFiles)},`);
297
+ lines.push("");
298
+ }
299
+ if (detected._note) {
300
+ lines.push(` // NOTE: ${detected._note}`);
301
+ lines.push("");
302
+ }
303
+ lines.push(" // (optional) Backend server");
304
+ lines.push(" // backend: {");
305
+ lines.push(" // command: 'npm run start:api',");
306
+ lines.push(" // port: 8000,");
307
+ lines.push(" // healthCheck: '/health',");
308
+ lines.push(" // },");
309
+ lines.push("};");
310
+ lines.push("");
311
+ writeFileSync(configPath, lines.join("\n"), "utf8");
312
+ return {
313
+ created: true,
314
+ framework: detected.framework,
315
+ configPath,
316
+ message: `sanjang.config.js created (${detected.framework} detected).`,
317
+ };
318
+ }
@@ -0,0 +1,7 @@
1
+ import type { CacheApplyResult, CacheBuildResult, CacheValidation, LockfileInfo, SanjangConfig } from "../types.ts";
2
+ export declare function findLockfile(dir: string): LockfileInfo | null;
3
+ export declare function hashLockfile(lockfilePath: string): string;
4
+ export declare function getCacheDir(projectRoot: string): string;
5
+ export declare function isCacheValid(projectRoot: string, setupCwd: string): CacheValidation;
6
+ export declare function buildCache(projectRoot: string, config: Pick<SanjangConfig, "dev" | "setup">, onLog?: (msg: string) => void): Promise<CacheBuildResult>;
7
+ export declare function applyCacheToWorktree(projectRoot: string, wtPath: string, setupCwd: string): CacheApplyResult;
@@ -0,0 +1,183 @@
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
+ const LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock"];
6
+ // ---------------------------------------------------------------------------
7
+ // Lockfile helpers
8
+ // ---------------------------------------------------------------------------
9
+ export function findLockfile(dir) {
10
+ for (const name of LOCKFILES) {
11
+ const p = join(dir, name);
12
+ if (existsSync(p))
13
+ return { path: p, name };
14
+ }
15
+ return null;
16
+ }
17
+ export function hashLockfile(lockfilePath) {
18
+ const content = readFileSync(lockfilePath);
19
+ return createHash("sha256").update(content).digest("hex");
20
+ }
21
+ // ---------------------------------------------------------------------------
22
+ // Cache directory
23
+ // ---------------------------------------------------------------------------
24
+ export function getCacheDir(projectRoot) {
25
+ return join(projectRoot, ".sanjang", "cache");
26
+ }
27
+ function getCacheModulesDir(projectRoot, setupCwd) {
28
+ const base = getCacheDir(projectRoot);
29
+ return setupCwd === "." ? join(base, "node_modules") : join(base, setupCwd, "node_modules");
30
+ }
31
+ function getHashFile(projectRoot, setupCwd = ".") {
32
+ const base = getCacheDir(projectRoot);
33
+ const name = setupCwd === "." ? "lockfile.hash" : `lockfile-${setupCwd.replace(/\//g, "-")}.hash`;
34
+ return join(base, name);
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Cache validation
38
+ // ---------------------------------------------------------------------------
39
+ export function isCacheValid(projectRoot, setupCwd) {
40
+ const cacheModules = getCacheModulesDir(projectRoot, setupCwd);
41
+ if (!existsSync(cacheModules)) {
42
+ return { valid: false, reason: "cache not found" };
43
+ }
44
+ const hashFile = getHashFile(projectRoot, setupCwd);
45
+ if (!existsSync(hashFile)) {
46
+ return { valid: false, reason: "no hash file" };
47
+ }
48
+ const srcDir = setupCwd === "." ? projectRoot : join(projectRoot, setupCwd);
49
+ const lockfile = findLockfile(srcDir);
50
+ if (!lockfile) {
51
+ return { valid: false, reason: "no lockfile in project" };
52
+ }
53
+ const storedHash = readFileSync(hashFile, "utf8").trim();
54
+ const currentHash = hashLockfile(lockfile.path);
55
+ if (storedHash !== currentHash) {
56
+ return { valid: false, reason: "lockfile changed" };
57
+ }
58
+ return { valid: true };
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Find all node_modules dirs (monorepo support)
62
+ // ---------------------------------------------------------------------------
63
+ function findAllNodeModules(baseDir, maxDepth = 4) {
64
+ const results = [];
65
+ function walk(dir, depth) {
66
+ if (depth > maxDepth)
67
+ return;
68
+ if (!existsSync(dir))
69
+ return;
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(dir, { withFileTypes: true });
73
+ }
74
+ catch {
75
+ return;
76
+ }
77
+ for (const entry of entries) {
78
+ if (!entry.isDirectory())
79
+ continue;
80
+ if (entry.name === "node_modules") {
81
+ results.push(join(dir, entry.name));
82
+ }
83
+ else if (!entry.name.startsWith(".")) {
84
+ walk(join(dir, entry.name), depth + 1);
85
+ }
86
+ }
87
+ }
88
+ walk(baseDir, 0);
89
+ return results;
90
+ }
91
+ // ---------------------------------------------------------------------------
92
+ // Build cache
93
+ // ---------------------------------------------------------------------------
94
+ export async function buildCache(projectRoot, config, onLog) {
95
+ const start = Date.now();
96
+ const setupCwd = config.dev?.cwd || ".";
97
+ const srcDir = setupCwd === "." ? projectRoot : join(projectRoot, setupCwd);
98
+ const modulesDir = join(srcDir, "node_modules");
99
+ const lockfile = findLockfile(srcDir);
100
+ if (!lockfile) {
101
+ return { success: false, error: "lockfile not found", duration: Date.now() - start };
102
+ }
103
+ if (!existsSync(modulesDir)) {
104
+ if (!config.setup) {
105
+ return { success: false, error: "no setup command and no node_modules", duration: Date.now() - start };
106
+ }
107
+ onLog?.("node_modules가 없습니다. 설치를 실행합니다...");
108
+ const exitCode = await runSetup(config.setup, projectRoot, onLog);
109
+ if (exitCode !== 0) {
110
+ return { success: false, error: `setup failed (exit ${exitCode})`, duration: Date.now() - start };
111
+ }
112
+ if (!existsSync(modulesDir)) {
113
+ return { success: false, error: "setup completed but node_modules not found", duration: Date.now() - start };
114
+ }
115
+ }
116
+ const allModules = findAllNodeModules(srcDir);
117
+ onLog?.(`캐시에 node_modules를 저장합니다... (${allModules.length}개 디렉토리)`);
118
+ const cacheDir = getCacheDir(projectRoot);
119
+ const cacheBase = setupCwd === "." ? cacheDir : join(cacheDir, setupCwd);
120
+ if (existsSync(cacheBase)) {
121
+ rmSync(cacheBase, { recursive: true, force: true });
122
+ }
123
+ mkdirSync(cacheBase, { recursive: true });
124
+ try {
125
+ for (const modDir of allModules) {
126
+ const rel = relative(srcDir, modDir);
127
+ const target = join(cacheBase, rel);
128
+ mkdirSync(join(target, ".."), { recursive: true });
129
+ cpSync(modDir, target, { recursive: true });
130
+ }
131
+ }
132
+ catch (err) {
133
+ return { success: false, error: `cache copy failed: ${err.message}`, duration: Date.now() - start };
134
+ }
135
+ writeFileSync(getHashFile(projectRoot, setupCwd), hashLockfile(lockfile.path), "utf8");
136
+ const duration = Date.now() - start;
137
+ onLog?.(`캐시 저장 완료 (${allModules.length}개, ${(duration / 1000).toFixed(1)}초)`);
138
+ return { success: true, duration };
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // Apply cache to worktree
142
+ // ---------------------------------------------------------------------------
143
+ export function applyCacheToWorktree(projectRoot, wtPath, setupCwd) {
144
+ const start = Date.now();
145
+ const validity = isCacheValid(projectRoot, setupCwd);
146
+ if (!validity.valid) {
147
+ return { applied: false, reason: validity.reason };
148
+ }
149
+ const cacheBase = setupCwd === "." ? getCacheDir(projectRoot) : join(getCacheDir(projectRoot), setupCwd);
150
+ const targetBase = setupCwd === "." ? wtPath : join(wtPath, setupCwd);
151
+ const cachedModules = findAllNodeModules(cacheBase);
152
+ if (cachedModules.length === 0) {
153
+ return { applied: false, reason: "no cached node_modules found" };
154
+ }
155
+ try {
156
+ for (const cachedDir of cachedModules) {
157
+ const rel = relative(cacheBase, cachedDir);
158
+ const target = join(targetBase, rel);
159
+ mkdirSync(join(target, ".."), { recursive: true });
160
+ cpSync(cachedDir, target, { recursive: true });
161
+ }
162
+ }
163
+ catch (err) {
164
+ return { applied: false, reason: `clone failed: ${err.message}` };
165
+ }
166
+ return { applied: true, duration: Date.now() - start, count: cachedModules.length };
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // Internal: run setup command
170
+ // ---------------------------------------------------------------------------
171
+ function runSetup(command, cwd, onLog) {
172
+ return new Promise((resolve) => {
173
+ const proc = spawn(command, [], {
174
+ cwd,
175
+ shell: true,
176
+ stdio: ["ignore", "pipe", "pipe"],
177
+ });
178
+ proc.stdout.on("data", (d) => onLog?.(d.toString().trimEnd()));
179
+ proc.stderr.on("data", (d) => onLog?.(d.toString().trimEnd()));
180
+ proc.on("close", (code) => resolve(code ?? 1));
181
+ proc.on("error", () => resolve(1));
182
+ });
183
+ }
@@ -0,0 +1,7 @@
1
+ export interface ConfigFix {
2
+ type: "add-copyfiles" | "update-setup" | "info";
3
+ description: string;
4
+ patch: Record<string, unknown>;
5
+ }
6
+ export declare function suggestConfigFix(projectRoot: string, logs: string[]): ConfigFix | null;
7
+ export declare function applyConfigFix(projectRoot: string, fix: ConfigFix): boolean;
@@ -0,0 +1,129 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { deepFindEnvFiles } from "./smart-init.js";
4
+ const PATTERNS = [
5
+ {
6
+ // SvelteKit / Vite: "does not provide an export named 'PUBLIC_*'"
7
+ test: /does not provide an export named '(PUBLIC_\w+)'/,
8
+ buildFix(projectRoot, _match) {
9
+ const envFiles = deepFindEnvFiles(projectRoot).filter((f) => !f.includes("example") && !f.includes("template") && !f.includes(".test"));
10
+ if (envFiles.length === 0)
11
+ return null;
12
+ return {
13
+ type: "add-copyfiles",
14
+ description: `환경변수 참조 오류 — copyFiles에 ${envFiles.join(", ")}을 추가합니다.`,
15
+ patch: { copyFiles: envFiles },
16
+ };
17
+ },
18
+ },
19
+ {
20
+ // Port mismatch: "Port X is in use, trying another one"
21
+ test: /Port (\d+) is in use/,
22
+ buildFix(_projectRoot, match) {
23
+ const port = match[1];
24
+ return {
25
+ type: "info",
26
+ description: `포트 ${port}이(가) 이미 사용 중입니다. 다른 캠프가 실행 중인지 확인하세요.`,
27
+ patch: { _conflictPort: Number(port) },
28
+ };
29
+ },
30
+ },
31
+ {
32
+ // Module not found after fresh install → setup command might be wrong
33
+ test: /Cannot find module '([^']+)'/,
34
+ buildFix(_projectRoot, match) {
35
+ const moduleName = match[1];
36
+ return {
37
+ type: "update-setup",
38
+ description: `모듈 '${moduleName}'을(를) 찾을 수 없습니다. setup 명령이 올바른지 확인하세요.`,
39
+ patch: { _missingModule: moduleName },
40
+ };
41
+ },
42
+ },
43
+ ];
44
+ // ---------------------------------------------------------------------------
45
+ // suggestConfigFix — analyze logs and return a fix, or null
46
+ // ---------------------------------------------------------------------------
47
+ export function suggestConfigFix(projectRoot, logs) {
48
+ const combined = logs.join("\n");
49
+ for (const pattern of PATTERNS) {
50
+ const match = combined.match(pattern.test);
51
+ if (match) {
52
+ const fix = pattern.buildFix(projectRoot, match);
53
+ if (fix)
54
+ return fix;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // applyConfigFix — modify sanjang.config.js in place
61
+ // ---------------------------------------------------------------------------
62
+ const CONFIG_FILE = "sanjang.config.js";
63
+ export function applyConfigFix(projectRoot, fix) {
64
+ const configPath = join(projectRoot, CONFIG_FILE);
65
+ if (!existsSync(configPath))
66
+ return false;
67
+ let content;
68
+ try {
69
+ content = readFileSync(configPath, "utf8");
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ switch (fix.type) {
75
+ case "add-copyfiles": {
76
+ const newFiles = fix.patch.copyFiles;
77
+ if (!newFiles || newFiles.length === 0)
78
+ return false;
79
+ content = mergeCopyFiles(content, newFiles);
80
+ break;
81
+ }
82
+ case "update-setup": {
83
+ // Informational — we don't auto-change setup without explicit user input
84
+ return false;
85
+ }
86
+ case "info": {
87
+ // Purely informational, nothing to write
88
+ return false;
89
+ }
90
+ default:
91
+ return false;
92
+ }
93
+ try {
94
+ writeFileSync(configPath, content, "utf8");
95
+ return true;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Helpers
103
+ // ---------------------------------------------------------------------------
104
+ /**
105
+ * Merge new file paths into the existing copyFiles array inside the config
106
+ * source text. If copyFiles doesn't exist, insert it before the closing `};`.
107
+ */
108
+ function mergeCopyFiles(source, newFiles) {
109
+ // Try to find existing copyFiles: [...]
110
+ const copyFilesRe = /copyFiles:\s*\[([^\]]*)\]/;
111
+ const match = source.match(copyFilesRe);
112
+ if (match) {
113
+ // Parse existing entries
114
+ const existing = match[1]
115
+ .split(",")
116
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
117
+ .filter(Boolean);
118
+ const merged = [...new Set([...existing, ...newFiles])];
119
+ const formatted = merged.map((f) => `'${f}'`).join(", ");
120
+ return source.replace(copyFilesRe, `copyFiles: [${formatted}]`);
121
+ }
122
+ // No copyFiles field — insert before the last `};`
123
+ const formatted = newFiles.map((f) => `'${f}'`).join(", ");
124
+ const insertion = ` copyFiles: [${formatted}],\n`;
125
+ const closingIdx = source.lastIndexOf("};");
126
+ if (closingIdx === -1)
127
+ return source; // malformed config, bail
128
+ return source.slice(0, closingIdx) + insertion + source.slice(closingIdx);
129
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Conflict detection and Claude-based resolution helpers.
3
+ */
4
+ /**
5
+ * Parse `git status --porcelain` output to find conflicted files.
6
+ * Conflict markers: UU (both modified), AA (both added), DD, AU, UA, DU, UD
7
+ */
8
+ export declare function parseConflictFiles(statusOutput: string | null | undefined): string[];
9
+ /**
10
+ * Build a Claude prompt to resolve merge conflicts.
11
+ */
12
+ export declare function buildConflictPrompt(conflictFiles: string[]): string;