sanjang 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.ts ADDED
@@ -0,0 +1,337 @@
1
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { DetectedApp, DetectedProject, GenerateConfigResult, SanjangConfig } from "./types.ts";
5
+ import { deepFindEnvFiles, detectSetupIssues } from "./engine/smart-init.ts";
6
+
7
+ const CONFIG_FILE: string = "sanjang.config.js";
8
+
9
+ const DEFAULTS: SanjangConfig = {
10
+ dev: {
11
+ command: "npm run dev",
12
+ port: 3000,
13
+ portFlag: "--port",
14
+ cwd: ".",
15
+ env: {},
16
+ },
17
+ setup: null,
18
+ copyFiles: [],
19
+ backend: null,
20
+ ports: {
21
+ fe: { base: 3000, slots: 8 },
22
+ be: { base: 8000, slots: 8 },
23
+ },
24
+ };
25
+
26
+ /**
27
+ * Load sanjang.config.js from project root.
28
+ * Returns merged config with defaults.
29
+ */
30
+ export async function loadConfig(projectRoot: string): Promise<SanjangConfig> {
31
+ const configPath = join(projectRoot, CONFIG_FILE);
32
+
33
+ if (!existsSync(configPath)) {
34
+ console.warn("⚠️ sanjang.config.js를 찾을 수 없습니다. 기본 설정을 사용합니다.");
35
+ console.warn(" → sanjang init 으로 프로젝트에 맞는 설정을 생성하세요.");
36
+ return { ...DEFAULTS, _autoDetected: false };
37
+ }
38
+
39
+ try {
40
+ const mod = await import(pathToFileURL(configPath).href);
41
+ const userConfig = mod.default || mod;
42
+ return mergeConfig(userConfig);
43
+ } catch (err) {
44
+ console.error(`sanjang.config.js 로드 실패: ${(err as Error).message}`);
45
+ return { ...DEFAULTS, _autoDetected: false };
46
+ }
47
+ }
48
+
49
+ function mergeConfig(user: Record<string, unknown>): SanjangConfig {
50
+ const config: SanjangConfig = { ...DEFAULTS };
51
+
52
+ if (typeof user.dev === "string") {
53
+ config.dev = { ...DEFAULTS.dev, command: user.dev };
54
+ } else if (user.dev) {
55
+ config.dev = { ...DEFAULTS.dev, ...(user.dev as Partial<SanjangConfig["dev"]>) };
56
+ }
57
+
58
+ if (user.setup) config.setup = user.setup as string;
59
+ if (user.copyFiles) config.copyFiles = user.copyFiles as string[];
60
+ if (user.backend) config.backend = user.backend as SanjangConfig["backend"];
61
+ if (user.ports) {
62
+ const userPorts = user.ports as Partial<SanjangConfig["ports"]>;
63
+ config.ports = {
64
+ fe: { ...DEFAULTS.ports.fe, ...userPorts.fe },
65
+ be: { ...DEFAULTS.ports.be, ...userPorts.be },
66
+ };
67
+ }
68
+
69
+ return config;
70
+ }
71
+
72
+ /**
73
+ * Auto-detect project type and generate config.
74
+ */
75
+ export function detectProject(projectRoot: string): DetectedProject {
76
+ const has = (f: string): boolean => existsSync(join(projectRoot, f));
77
+ const readJson = (f: string): Record<string, unknown> | null => {
78
+ try {
79
+ return JSON.parse(readFileSync(join(projectRoot, f), "utf8")) as Record<string, unknown>;
80
+ } catch {
81
+ return null;
82
+ }
83
+ };
84
+
85
+ // Framework detection
86
+ if (has("next.config.js") || has("next.config.mjs") || has("next.config.ts")) {
87
+ return {
88
+ framework: "Next.js",
89
+ dev: { command: "npx next dev", port: 3000, portFlag: "-p", cwd: ".", env: {} },
90
+ setup: has("bun.lockb") ? "bun install" : has("pnpm-lock.yaml") ? "pnpm install" : "npm install",
91
+ copyFiles: findEnvFiles(projectRoot),
92
+ };
93
+ }
94
+
95
+ if (has("nuxt.config.js") || has("nuxt.config.ts")) {
96
+ return {
97
+ framework: "Nuxt",
98
+ dev: { command: "npx nuxt dev", port: 3000, portFlag: "--port", cwd: ".", env: {} },
99
+ setup: detectPackageManager(projectRoot),
100
+ copyFiles: findEnvFiles(projectRoot),
101
+ };
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
+
113
+ if (has("angular.json")) {
114
+ return {
115
+ framework: "Angular",
116
+ dev: { command: "npx ng serve", port: 4200, portFlag: "--port", cwd: ".", env: {} },
117
+ setup: "npm install",
118
+ copyFiles: findEnvFiles(projectRoot),
119
+ };
120
+ }
121
+
122
+ if (has("vite.config.js") || has("vite.config.ts") || has("vite.config.mjs")) {
123
+ return {
124
+ framework: "Vite",
125
+ dev: { command: "npx vite dev", port: 5173, portFlag: "--port", cwd: ".", env: {} },
126
+ setup: detectPackageManager(projectRoot),
127
+ copyFiles: findEnvFiles(projectRoot),
128
+ };
129
+ }
130
+
131
+ // ClojureScript / shadow-cljs (root or common subdirectories)
132
+ const shadowDirs = [".", "frontend", "client", "web", "app"];
133
+ for (const dir of shadowDirs) {
134
+ const prefix = dir === "." ? "" : `${dir}/`;
135
+ if (has(`${prefix}shadow-cljs.edn`)) {
136
+ const hasBb = has(`${prefix}bb.edn`);
137
+ return {
138
+ framework: "shadow-cljs",
139
+ dev: { command: hasBb ? "bb dev" : "npx shadow-cljs watch app", port: 3000, portFlag: null, cwd: dir, env: {} },
140
+ setup: "npm install",
141
+ copyFiles: findEnvFiles(projectRoot),
142
+ };
143
+ }
144
+ }
145
+
146
+ // Monorepo detection
147
+ if (has("turbo.json")) {
148
+ const mainApp = detectTurboMainApp(projectRoot);
149
+ const filter = mainApp ? ` --filter=${mainApp.name}` : "";
150
+ const port = mainApp?.port ?? 3000;
151
+ return {
152
+ framework: "Turborepo",
153
+ dev: { command: `npx turbo run dev${filter}`, port, portFlag: null, cwd: ".", env: {} },
154
+ setup: detectPackageManager(projectRoot),
155
+ copyFiles: findEnvFiles(projectRoot),
156
+ _note: mainApp
157
+ ? `Turborepo: filtered to ${mainApp.name} (port ${port}).`
158
+ : "Turborepo detected. You may need to adjust the dev command to filter a specific app.",
159
+ };
160
+ }
161
+
162
+ // Fallback: package.json scripts
163
+ const pkg = readJson("package.json");
164
+ if ((pkg?.scripts as Record<string, unknown> | undefined)?.dev) {
165
+ return {
166
+ framework: "Node.js",
167
+ dev: { command: "npm run dev", port: 3000, portFlag: "--port", cwd: ".", env: {} },
168
+ setup: detectPackageManager(projectRoot),
169
+ copyFiles: findEnvFiles(projectRoot),
170
+ };
171
+ }
172
+
173
+ return {
174
+ framework: "unknown",
175
+ dev: { command: "npm run dev", port: 3000, portFlag: "--port", cwd: ".", env: {} },
176
+ setup: "npm install",
177
+ copyFiles: [],
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Scan first-level subdirectories for app candidates.
183
+ * Returns array of { dir, framework, detected } sorted by dir name.
184
+ */
185
+ export function detectApps(projectRoot: string): DetectedApp[] {
186
+ const entries = readdirSync(projectRoot, { withFileTypes: true });
187
+ const ignore = new Set(["node_modules", ".git", ".sanjang", "dist", "build", ".next", ".nuxt"]);
188
+
189
+ const apps: DetectedApp[] = [];
190
+ for (const entry of entries) {
191
+ if (!entry.isDirectory()) continue;
192
+ if (entry.name.startsWith(".") || ignore.has(entry.name)) continue;
193
+
194
+ const subPath = join(projectRoot, entry.name);
195
+ const detected = detectProject(subPath);
196
+ if (detected.framework === "unknown") continue;
197
+
198
+ apps.push({
199
+ dir: entry.name,
200
+ framework: detected.framework,
201
+ detected,
202
+ });
203
+ }
204
+
205
+ return apps.sort((a, b) => a.dir.localeCompare(b.dir));
206
+ }
207
+
208
+ interface TurboAppInfo {
209
+ name: string;
210
+ port: number;
211
+ }
212
+
213
+ function detectTurboMainApp(root: string): TurboAppInfo | null {
214
+ // Scan apps/*/package.json for the main app (has dev script with --port or vite/next)
215
+ const appDirs = ["apps", "packages"];
216
+ const candidates: TurboAppInfo[] = [];
217
+
218
+ for (const dir of appDirs) {
219
+ const base = join(root, dir);
220
+ if (!existsSync(base)) continue;
221
+ let entries;
222
+ try { entries = readdirSync(base, { withFileTypes: true }); } catch { continue; }
223
+ for (const entry of entries) {
224
+ if (!entry.isDirectory()) continue;
225
+ // Skip storybook, demo, docs apps
226
+ if (/storybook|demo|docs|e2e|test/i.test(entry.name)) continue;
227
+ const pkgPath = join(base, entry.name, "package.json");
228
+ if (!existsSync(pkgPath)) continue;
229
+ try {
230
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as Record<string, unknown>;
231
+ const scripts = pkg.scripts as Record<string, string> | undefined;
232
+ const devScript = scripts?.dev;
233
+ if (!devScript) continue;
234
+ const portMatch = devScript.match(/--port\s+(\d+)/);
235
+ const port = portMatch?.[1] ? parseInt(portMatch[1], 10) : 3000;
236
+ candidates.push({ name: entry.name, port });
237
+ } catch { continue; }
238
+ }
239
+ }
240
+
241
+ // Prefer app with explicit port, then first candidate
242
+ return candidates.find(c => c.port !== 3000) ?? candidates[0] ?? null;
243
+ }
244
+
245
+ function detectPackageManager(root: string): string {
246
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock"))) return "bun install";
247
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm install";
248
+ if (existsSync(join(root, "yarn.lock"))) return "yarn install";
249
+ return "npm install";
250
+ }
251
+
252
+ function findEnvFiles(root: string): string[] {
253
+ const envFiles = [".env", ".env.local", ".env.development", ".env.development.local"];
254
+ return envFiles.filter((f) => existsSync(join(root, f)));
255
+ }
256
+
257
+ export function generateConfig(
258
+ projectRoot: string,
259
+ options: { appDir?: string; force?: boolean } = {},
260
+ ): GenerateConfigResult {
261
+ const { appDir, force } = options;
262
+ const configPath = join(projectRoot, CONFIG_FILE);
263
+
264
+ if (existsSync(configPath) && !force) {
265
+ return { created: false, message: "sanjang.config.js already exists." };
266
+ }
267
+
268
+ // Detect from selected app subdirectory or root
269
+ const detectRoot = appDir ? join(projectRoot, appDir) : projectRoot;
270
+ const detected = detectProject(detectRoot);
271
+
272
+ // Override cwd and setup for subdirectory apps
273
+ if (appDir && appDir !== ".") {
274
+ detected.dev.cwd = appDir;
275
+ if (detected.setup) {
276
+ detected.setup = `cd '${appDir.replace(/'/g, "'\\''")}' && ${detected.setup}`;
277
+ }
278
+ }
279
+
280
+ // Smart env detection — scan entire project tree, not just root
281
+ detected.copyFiles = deepFindEnvFiles(projectRoot).filter(
282
+ // Exclude .env.example, .env.test, .env.template
283
+ (f) => !f.includes("example") && !f.includes("template") && !f.includes(".test"),
284
+ );
285
+
286
+ // Detect potential issues
287
+ const issues = detectSetupIssues(detectRoot);
288
+
289
+ const lines = [
290
+ "export default {",
291
+ ` // ${detected.framework} detected`,
292
+ "",
293
+ " // Dev server command",
294
+ " dev: {",
295
+ ` command: '${detected.dev.command}',`,
296
+ ` port: ${detected.dev.port},`,
297
+ ` portFlag: ${detected.dev.portFlag ? `'${detected.dev.portFlag}'` : "null"},`,
298
+ ` cwd: '${detected.dev.cwd}',`,
299
+ " },",
300
+ "",
301
+ ];
302
+
303
+ if (detected.setup) {
304
+ lines.push(` // Install dependencies after creating a camp`);
305
+ lines.push(` setup: ${JSON.stringify(detected.setup)},`);
306
+ lines.push("");
307
+ }
308
+
309
+ if (detected.copyFiles.length) {
310
+ lines.push(" // Copy gitignored files from main repo");
311
+ lines.push(` copyFiles: ${JSON.stringify(detected.copyFiles)},`);
312
+ lines.push("");
313
+ }
314
+
315
+ if (detected._note) {
316
+ lines.push(` // NOTE: ${detected._note}`);
317
+ lines.push("");
318
+ }
319
+
320
+ lines.push(" // (optional) Backend server");
321
+ lines.push(" // backend: {");
322
+ lines.push(" // command: 'npm run start:api',");
323
+ lines.push(" // port: 8000,");
324
+ lines.push(" // healthCheck: '/health',");
325
+ lines.push(" // },");
326
+ lines.push("};");
327
+ lines.push("");
328
+
329
+ writeFileSync(configPath, lines.join("\n"), "utf8");
330
+
331
+ return {
332
+ created: true,
333
+ framework: detected.framework,
334
+ configPath,
335
+ message: `sanjang.config.js created (${detected.framework} detected).`,
336
+ };
337
+ }
@@ -0,0 +1,218 @@
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
+ }