sanjang 0.3.1 → 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 -4
  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
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ const args = process.argv.slice(2);
6
+ const command = args[0];
7
+ // Parse options
8
+ let projectRoot = process.cwd();
9
+ let port = 4000;
10
+ let force = false;
11
+ for (let i = 0; i < args.length; i++) {
12
+ if (args[i] === "--project" && args[i + 1]) {
13
+ projectRoot = resolve(args[i + 1]);
14
+ i++;
15
+ }
16
+ if (args[i] === "--port" && args[i + 1]) {
17
+ port = parseInt(args[i + 1]);
18
+ i++;
19
+ }
20
+ if (args[i] === "--force") {
21
+ force = true;
22
+ }
23
+ }
24
+ // Find git root
25
+ try {
26
+ projectRoot = execSync("git rev-parse --show-toplevel", {
27
+ cwd: projectRoot,
28
+ encoding: "utf8",
29
+ stdio: ["pipe", "pipe", "pipe"],
30
+ }).trim();
31
+ }
32
+ catch {
33
+ console.error("⛰ 산장: git 저장소를 찾을 수 없습니다.");
34
+ console.error(" git 저장소 안에서 실행해주세요.");
35
+ process.exit(1);
36
+ }
37
+ if (command === "init") {
38
+ const { generateConfig, detectApps } = await import("../lib/config.js");
39
+ // Detect apps in subdirectories
40
+ const apps = detectApps(projectRoot);
41
+ let appDir;
42
+ if (apps.length >= 2) {
43
+ // Multi-app interview
44
+ console.log("");
45
+ console.log("⛰ 여러 앱이 감지되었습니다:");
46
+ for (let i = 0; i < apps.length; i++) {
47
+ console.log(` ${i + 1}) ${apps[i].dir}/\t(${apps[i].framework})`);
48
+ }
49
+ console.log("");
50
+ const { createInterface } = await import("node:readline");
51
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
52
+ const answer = await new Promise((resolve) => {
53
+ rl.question(" 어떤 앱을 띄울까요? [번호]: ", resolve);
54
+ });
55
+ rl.close();
56
+ const idx = parseInt(answer) - 1;
57
+ if (idx < 0 || idx >= apps.length || isNaN(idx)) {
58
+ console.error("⛰ 잘못된 선택입니다.");
59
+ process.exit(1);
60
+ }
61
+ appDir = apps[idx].dir;
62
+ console.log(` → ${appDir}/ (${apps[idx].framework}) 선택됨`);
63
+ }
64
+ else if (apps.length === 1) {
65
+ appDir = apps[0].dir;
66
+ }
67
+ const result = generateConfig(projectRoot, { appDir, force });
68
+ if (result.created) {
69
+ console.log(`⛰ ${result.message}`);
70
+ console.log(` 프레임워크: ${result.framework}`);
71
+ console.log(` 설정 파일: ${result.configPath}`);
72
+ }
73
+ else {
74
+ console.log(`⛰ ${result.message}`);
75
+ }
76
+ // Add .sanjang to .gitignore if not present
77
+ const gitignorePath = resolve(projectRoot, ".gitignore");
78
+ if (existsSync(gitignorePath)) {
79
+ const { readFileSync, appendFileSync } = await import("node:fs");
80
+ const content = readFileSync(gitignorePath, "utf8");
81
+ if (!content.includes(".sanjang")) {
82
+ appendFileSync(gitignorePath, "\n# Sanjang local dev camps\n.sanjang/\n");
83
+ console.log(" .gitignore에 .sanjang/ 추가됨");
84
+ }
85
+ }
86
+ // Prebuild dependency cache
87
+ const { loadConfig } = await import("../lib/config.js");
88
+ const initConfig = await loadConfig(projectRoot);
89
+ if (initConfig.setup) {
90
+ console.log("");
91
+ console.log(" 의존성 캐시를 빌드합니다...");
92
+ const { buildCache } = await import("../lib/engine/cache.js");
93
+ const cacheResult = await buildCache(projectRoot, initConfig, (msg) => {
94
+ console.log(` ${msg}`);
95
+ });
96
+ if (cacheResult.success) {
97
+ console.log(` 캐시 빌드 완료 ✓ (${(cacheResult.duration / 1000).toFixed(1)}초)`);
98
+ }
99
+ else {
100
+ console.log(` ⚠️ 캐시 빌드 실패: ${cacheResult.error}`);
101
+ console.log(" 캠프 생성 시 일반 설치를 사용합니다.");
102
+ }
103
+ }
104
+ // Auto-start server unless --no-start
105
+ const noStart = args.includes("--no-start");
106
+ if (!noStart) {
107
+ console.log("");
108
+ console.log(" 서버를 시작합니다...");
109
+ const { startServer } = await import("../lib/server.js");
110
+ await startServer(projectRoot, { port });
111
+ }
112
+ else {
113
+ console.log("");
114
+ console.log(" 다음 단계: sanjang 또는 npx sanjang 으로 서버를 시작하세요.");
115
+ }
116
+ }
117
+ else if (command === "help" || command === "--help" || command === "-h") {
118
+ console.log(`
119
+ ⛰ 산장 (Sanjang) — 바이브코더를 위한 로컬 개발 환경 매니저
120
+
121
+ 사용법:
122
+ sanjang 서버 시작 (대시보드: http://localhost:4000)
123
+ sanjang init 프로젝트 분석 → sanjang.config.js 생성
124
+ sanjang help 이 도움말
125
+
126
+ 옵션:
127
+ --port <N> 대시보드 포트 (기본: 4000)
128
+ --project <path> 프로젝트 경로 (기본: 현재 디렉토리)
129
+ --force 기존 설정을 덮어쓰고 다시 생성
130
+
131
+ 자세히: https://github.com/paul-sherpas/sanjang
132
+ `);
133
+ }
134
+ else {
135
+ // Default: start server
136
+ const { startServer } = await import("../lib/server.js");
137
+ await startServer(projectRoot, { port });
138
+ }
@@ -0,0 +1,19 @@
1
+ import type { DetectedApp, DetectedProject, GenerateConfigResult, SanjangConfig } from "./types.ts";
2
+ /**
3
+ * Load sanjang.config.js from project root.
4
+ * Returns merged config with defaults.
5
+ */
6
+ export declare function loadConfig(projectRoot: string): Promise<SanjangConfig>;
7
+ /**
8
+ * Auto-detect project type and generate config.
9
+ */
10
+ export declare function detectProject(projectRoot: string): DetectedProject;
11
+ /**
12
+ * Scan first-level subdirectories for app candidates.
13
+ * Returns array of { dir, framework, detected } sorted by dir name.
14
+ */
15
+ export declare function detectApps(projectRoot: string): DetectedApp[];
16
+ export declare function generateConfig(projectRoot: string, options?: {
17
+ appDir?: string;
18
+ force?: boolean;
19
+ }): GenerateConfigResult;
@@ -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;