@mandujs/cli 0.9.24 → 0.9.43

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.
@@ -1,14 +1,13 @@
1
- import {
2
- startServer,
3
- registerApiHandler,
4
- registerPageLoader,
5
- registerPageHandler,
6
- registerLayoutLoader,
1
+ import {
2
+ startServer,
3
+ registerApiHandler,
4
+ registerPageLoader,
5
+ registerPageHandler,
6
+ registerLayoutLoader,
7
7
  startDevBundler,
8
8
  createHMRServer,
9
9
  needsHydration,
10
10
  loadEnv,
11
- generateManifest,
12
11
  watchFSRoutes,
13
12
  clearDefaultRegistry,
14
13
  createGuardWatcher,
@@ -17,77 +16,89 @@ import {
17
16
  formatReportForAgent,
18
17
  formatReportAsAgentJSON,
19
18
  getPreset,
19
+ validateAndReport,
20
+ isTailwindProject,
21
+ startCSSWatch,
20
22
  type RoutesManifest,
21
23
  type GuardConfig,
22
- type GuardPreset,
23
24
  type Violation,
25
+ type CSSWatcher,
24
26
  } from "@mandujs/core";
25
- import { isDirectory, resolveFromCwd } from "../util/fs";
26
- import { resolveOutputFormat, type OutputFormat } from "../util/output";
27
- import path from "path";
28
-
27
+ import { resolveFromCwd } from "../util/fs";
28
+ import { resolveOutputFormat } from "../util/output";
29
+ import { CLI_ERROR_CODES, printCLIError } from "../errors";
30
+ import { importFresh } from "../util/bun";
31
+ import { resolveManifest } from "../util/manifest";
32
+ import { resolveAvailablePort } from "../util/port";
33
+ import path from "path";
34
+
29
35
  export interface DevOptions {
30
36
  port?: number;
31
- /** HMR 비활성화 */
32
- noHmr?: boolean;
33
- /** FS Routes 비활성화 (레거시 모드) */
34
- legacy?: boolean;
35
- /** Architecture Guard 활성화 */
36
- guard?: boolean;
37
- /** Guard 프리셋 */
38
- guardPreset?: GuardPreset;
39
- /** Guard 출력 형식 */
40
- guardFormat?: OutputFormat;
41
37
  }
42
-
38
+
43
39
  export async function dev(options: DevOptions = {}): Promise<void> {
44
40
  const rootDir = resolveFromCwd(".");
41
+ const config = await validateAndReport(rootDir);
42
+
43
+ if (!config) {
44
+ printCLIError(CLI_ERROR_CODES.CONFIG_VALIDATION_FAILED);
45
+ process.exit(1);
46
+ }
47
+
48
+ const serverConfig = config.server ?? {};
49
+ const devConfig = config.dev ?? {};
50
+ const guardConfigFromFile = config.guard ?? {};
51
+ const HMR_OFFSET = 1;
52
+
53
+ console.log(`🥟 Mandu Dev Server`);
54
+
55
+ // .env 파일 로드
56
+ const envResult = await loadEnv({
57
+ rootDir,
58
+ env: "development",
59
+ });
60
+
61
+ if (envResult.loaded.length > 0) {
62
+ console.log(`🔐 환경 변수 로드: ${envResult.loaded.join(", ")}`);
63
+ }
45
64
 
46
- console.log(`🥟 Mandu Dev Server (FS Routes)`);
47
-
48
- // .env 파일 로드
49
- const envResult = await loadEnv({
50
- rootDir,
51
- env: "development",
52
- });
53
-
54
- if (envResult.loaded.length > 0) {
55
- console.log(`🔐 환경 변수 로드: ${envResult.loaded.join(", ")}`);
56
- }
57
-
58
- // FS Routes 스캔
59
- console.log(`📂 app/ 폴더 스캔 중...`);
60
-
61
- const result = await generateManifest(rootDir, {
62
- skipLegacy: true,
63
- });
64
-
65
- if (result.manifest.routes.length === 0) {
66
- console.log("");
67
- console.log("📭 라우트가 없습니다.");
68
- console.log("");
69
- console.log("💡 app/ 폴더에 page.tsx 파일을 생성하세요:");
70
- console.log("");
71
- console.log(" app/page.tsx → /");
72
- console.log(" app/blog/page.tsx → /blog");
73
- console.log(" app/api/users/route.ts /api/users");
74
- console.log("");
75
- process.exit(1);
76
- }
77
-
78
- let manifest = result.manifest;
79
- console.log(`✅ ${manifest.routes.length}개 라우트 발견\n`);
80
-
81
- const enableFsRoutes = !options.legacy && await isDirectory(path.resolve(rootDir, "app"));
82
- const guardPreset = options.guardPreset || "mandu";
83
- const guardFormat = resolveOutputFormat(options.guardFormat);
65
+ // 라우트 스캔 (FS Routes 우선, 없으면 spec manifest)
66
+ console.log(`📂 라우트 스캔 중...`);
67
+ let manifest: RoutesManifest;
68
+ let enableFsRoutes = false;
69
+
70
+ try {
71
+ const resolved = await resolveManifest(rootDir, { fsRoutes: config.fsRoutes });
72
+ manifest = resolved.manifest;
73
+ enableFsRoutes = resolved.source === "fs";
74
+
75
+ if (manifest.routes.length === 0) {
76
+ printCLIError(CLI_ERROR_CODES.DEV_NO_ROUTES);
77
+ console.log("💡 app/ 폴더에 page.tsx 파일을 생성하세요:");
78
+ console.log("");
79
+ console.log(" app/page.tsx → /");
80
+ console.log(" app/blog/page.tsx → /blog");
81
+ console.log(" app/api/users/route.ts → /api/users");
82
+ console.log("");
83
+ process.exit(1);
84
+ }
85
+
86
+ console.log(`✅ ${manifest.routes.length}개 라우트 발견\n`);
87
+ } catch (error) {
88
+ printCLIError(CLI_ERROR_CODES.DEV_MANIFEST_NOT_FOUND);
89
+ console.error(error instanceof Error ? error.message : error);
90
+ process.exit(1);
91
+ }
92
+ const guardPreset = guardConfigFromFile.preset || "mandu";
93
+ const guardFormat = resolveOutputFormat();
84
94
  const guardConfig: GuardConfig | null =
85
- options.guard === false
95
+ guardConfigFromFile.realtime === false
86
96
  ? null
87
97
  : {
88
98
  preset: guardPreset,
89
- srcDir: "src",
90
- realtime: true,
99
+ srcDir: guardConfigFromFile.srcDir || "src",
100
+ realtime: guardConfigFromFile.realtime ?? true,
101
+ exclude: guardConfigFromFile.exclude,
91
102
  realtimeOutput: guardFormat,
92
103
  fsRoutes: enableFsRoutes
93
104
  ? {
@@ -144,161 +155,231 @@ export async function dev(options: DevOptions = {}): Promise<void> {
144
155
 
145
156
  // Layout 경로 추적 (중복 등록 방지)
146
157
  const registeredLayouts = new Set<string>();
147
-
148
- // 핸들러 등록 함수
149
- const registerHandlers = async (manifest: RoutesManifest, isReload = false) => {
150
- // 리로드 시 레이아웃 캐시 클리어
151
- if (isReload) {
152
- registeredLayouts.clear();
153
- }
154
-
155
- for (const route of manifest.routes) {
156
- if (route.kind === "api") {
157
- const modulePath = path.resolve(rootDir, route.module);
158
- try {
159
- // 캐시 무효화 (HMR용)
160
- delete require.cache[modulePath];
161
- const module = await import(modulePath);
162
- registerApiHandler(route.id, module.default || module.handler || module);
163
- console.log(` 📡 API: ${route.pattern} -> ${route.id}`);
164
- } catch (error) {
165
- console.error(` API 핸들러 로드 실패: ${route.id}`, error);
166
- }
167
- } else if (route.kind === "page" && route.componentModule) {
168
- const componentPath = path.resolve(rootDir, route.componentModule);
169
- const isIsland = needsHydration(route);
170
- const hasLayout = route.layoutChain && route.layoutChain.length > 0;
171
-
172
- // Layout 로더 등록
173
- if (route.layoutChain) {
174
- for (const layoutPath of route.layoutChain) {
175
- if (!registeredLayouts.has(layoutPath)) {
176
- const absLayoutPath = path.resolve(rootDir, layoutPath);
177
- registerLayoutLoader(layoutPath, async () => {
178
- // 캐시 무효화 (HMR용)
179
- delete require.cache[absLayoutPath];
180
- return import(absLayoutPath);
181
- });
182
- registeredLayouts.add(layoutPath);
183
- console.log(` 🎨 Layout: ${layoutPath}`);
184
- }
185
- }
186
- }
187
-
188
- // slotModule이 있으면 PageHandler 사용 (filling.loader 지원)
189
- if (route.slotModule) {
190
- registerPageHandler(route.id, async () => {
191
- delete require.cache[componentPath];
192
- const module = await import(componentPath);
193
- return module.default;
194
- });
195
- console.log(` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`);
196
- } else {
197
- registerPageLoader(route.id, () => {
198
- delete require.cache[componentPath];
199
- return import(componentPath);
200
- });
201
- console.log(` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`);
202
- }
203
- }
204
- }
205
- };
206
-
207
- // 초기 핸들러 등록
208
- await registerHandlers(manifest);
209
- console.log("");
210
-
211
- const port = options.port || Number(process.env.PORT) || 3000;
212
-
213
- // HMR 서버 시작 (클라이언트 슬롯이 있는 경우)
214
- let hmrServer: ReturnType<typeof createHMRServer> | null = null;
215
- let devBundler: Awaited<ReturnType<typeof startDevBundler>> | null = null;
216
-
158
+
159
+ // 핸들러 등록 함수
160
+ const registerHandlers = async (manifest: RoutesManifest, isReload = false) => {
161
+ // 리로드 시 레이아웃 캐시 클리어
162
+ if (isReload) {
163
+ registeredLayouts.clear();
164
+ }
165
+
166
+ for (const route of manifest.routes) {
167
+ if (route.kind === "api") {
168
+ const modulePath = path.resolve(rootDir, route.module);
169
+ try {
170
+ // 캐시 무효화 (HMR용)
171
+ const module = await importFresh(modulePath);
172
+ let handler = module.default || module.handler || module;
173
+
174
+ // ManduFilling 인스턴스를 핸들러 함수로 래핑
175
+ if (handler && typeof handler.handle === 'function') {
176
+ console.log(` 🔄 ManduFilling 래핑: ${route.id}`);
177
+ const filling = handler;
178
+ handler = async (req: Request, params?: Record<string, string>) => {
179
+ return filling.handle(req, params);
180
+ };
181
+ } else {
182
+ console.log(` ⚠️ 핸들러 타입: ${typeof handler}, handle: ${typeof handler?.handle}`);
183
+ }
184
+
185
+ registerApiHandler(route.id, handler);
186
+ console.log(` 📡 API: ${route.pattern} -> ${route.id}`);
187
+ } catch (error) {
188
+ console.error(` ❌ API 핸들러 로드 실패: ${route.id}`, error);
189
+ }
190
+ } else if (route.kind === "page" && route.componentModule) {
191
+ const componentPath = path.resolve(rootDir, route.componentModule);
192
+ const isIsland = needsHydration(route);
193
+ const hasLayout = route.layoutChain && route.layoutChain.length > 0;
194
+
195
+ // Layout 로더 등록
196
+ if (route.layoutChain) {
197
+ for (const layoutPath of route.layoutChain) {
198
+ if (!registeredLayouts.has(layoutPath)) {
199
+ const absLayoutPath = path.resolve(rootDir, layoutPath);
200
+ registerLayoutLoader(layoutPath, async () => {
201
+ // 캐시 무효화 (HMR용)
202
+ return importFresh(absLayoutPath);
203
+ });
204
+ registeredLayouts.add(layoutPath);
205
+ console.log(` 🎨 Layout: ${layoutPath}`);
206
+ }
207
+ }
208
+ }
209
+
210
+ // slotModule이 있으면 PageHandler 사용 (filling.loader 지원)
211
+ if (route.slotModule) {
212
+ registerPageHandler(route.id, async () => {
213
+ const module = await importFresh(componentPath);
214
+ return module.default;
215
+ });
216
+ console.log(` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`);
217
+ } else {
218
+ registerPageLoader(route.id, () => importFresh(componentPath));
219
+ console.log(` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`);
220
+ }
221
+ }
222
+ }
223
+ };
224
+
225
+ // 초기 핸들러 등록
226
+ await registerHandlers(manifest);
227
+ console.log("");
228
+
229
+ const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
230
+ const desiredPort =
231
+ options.port ??
232
+ (envPort && Number.isFinite(envPort) ? envPort : undefined) ??
233
+ serverConfig.port ??
234
+ 3333;
235
+
217
236
  const hasIslands = manifest.routes.some(
218
237
  (r) => r.kind === "page" && r.clientModule && needsHydration(r)
219
238
  );
220
-
221
- if (hasIslands && !options.noHmr) {
222
- // HMR 서버 시작
223
- hmrServer = createHMRServer(port);
224
-
225
- // Dev 번들러 시작 (파일 감시)
226
- devBundler = await startDevBundler({
227
- rootDir,
228
- manifest,
229
- onRebuild: (result) => {
230
- if (result.success) {
231
- if (result.routeId === "*") {
232
- hmrServer?.broadcast({
233
- type: "reload",
234
- data: {
235
- timestamp: Date.now(),
236
- },
237
- });
238
- } else {
239
- hmrServer?.broadcast({
240
- type: "island-update",
241
- data: {
242
- routeId: result.routeId,
243
- timestamp: Date.now(),
244
- },
245
- });
246
- }
247
- } else {
248
- hmrServer?.broadcast({
249
- type: "error",
250
- data: {
251
- routeId: result.routeId,
252
- message: result.error,
253
- },
254
- });
255
- }
256
- },
257
- onError: (error, routeId) => {
258
- hmrServer?.broadcast({
259
- type: "error",
260
- data: {
261
- routeId,
262
- message: error.message,
263
- },
264
- });
265
- },
266
- });
267
- }
268
-
269
- // 메인 서버 시작
270
- const server = startServer(manifest, {
271
- port,
272
- rootDir,
273
- isDev: true,
274
- hmrPort: hmrServer ? port : undefined,
275
- bundleManifest: devBundler?.initialBuild.manifest,
276
- });
277
-
239
+ const hmrEnabled = devConfig.hmr ?? true;
240
+
241
+ const { port } = await resolveAvailablePort(desiredPort, {
242
+ hostname: serverConfig.hostname,
243
+ offsets: hasIslands && hmrEnabled ? [0, HMR_OFFSET] : [0],
244
+ });
245
+
246
+ if (port !== desiredPort) {
247
+ console.warn(`⚠️ Port ${desiredPort} is in use. Using ${port} instead.`);
248
+ }
249
+
250
+ // HMR 서버 시작 (클라이언트 슬롯이 있는 경우)
251
+ let hmrServer: ReturnType<typeof createHMRServer> | null = null;
252
+ let devBundler: Awaited<ReturnType<typeof startDevBundler>> | null = null;
253
+ let cssWatcher: CSSWatcher | null = null;
254
+
255
+ // CSS 빌드 시작 (Tailwind v4 감지 시에만)
256
+ const hasTailwind = await isTailwindProject(rootDir);
257
+ if (hasTailwind) {
258
+ cssWatcher = await startCSSWatch({
259
+ rootDir,
260
+ watch: true,
261
+ onBuild: (result) => {
262
+ if (result.success && hmrServer) {
263
+ // cssWatcher.serverPath 사용 (경로 일관성)
264
+ hmrServer.broadcast({
265
+ type: "css-update",
266
+ data: {
267
+ cssPath: cssWatcher?.serverPath || "/.mandu/client/globals.css",
268
+ timestamp: Date.now(),
269
+ },
270
+ });
271
+ }
272
+ },
273
+ onError: (error) => {
274
+ if (hmrServer) {
275
+ hmrServer.broadcast({
276
+ type: "error",
277
+ data: {
278
+ message: `CSS Error: ${error.message}`,
279
+ },
280
+ });
281
+ }
282
+ },
283
+ });
284
+ }
285
+
286
+ if (hasIslands && hmrEnabled) {
287
+ // HMR 서버 시작
288
+ hmrServer = createHMRServer(port);
289
+
290
+ // Dev 번들러 시작 (파일 감시)
291
+ devBundler = await startDevBundler({
292
+ rootDir,
293
+ manifest,
294
+ watchDirs: devConfig.watchDirs,
295
+ onRebuild: (result) => {
296
+ if (result.success) {
297
+ if (result.routeId === "*") {
298
+ hmrServer?.broadcast({
299
+ type: "reload",
300
+ data: {
301
+ timestamp: Date.now(),
302
+ },
303
+ });
304
+ } else {
305
+ hmrServer?.broadcast({
306
+ type: "island-update",
307
+ data: {
308
+ routeId: result.routeId,
309
+ timestamp: Date.now(),
310
+ },
311
+ });
312
+ }
313
+ } else {
314
+ hmrServer?.broadcast({
315
+ type: "error",
316
+ data: {
317
+ routeId: result.routeId,
318
+ message: result.error,
319
+ },
320
+ });
321
+ }
322
+ },
323
+ onError: (error, routeId) => {
324
+ hmrServer?.broadcast({
325
+ type: "error",
326
+ data: {
327
+ routeId,
328
+ message: error.message,
329
+ },
330
+ });
331
+ },
332
+ });
333
+ }
334
+
335
+ // 메인 서버 시작
336
+ const server = startServer(manifest, {
337
+ port,
338
+ hostname: serverConfig.hostname,
339
+ rootDir,
340
+ isDev: true,
341
+ hmrPort: hmrServer ? port : undefined,
342
+ bundleManifest: devBundler?.initialBuild.manifest,
343
+ cors: serverConfig.cors,
344
+ streaming: serverConfig.streaming,
345
+ // Tailwind 감지 시에만 CSS 링크 주입
346
+ cssPath: hasTailwind ? cssWatcher?.serverPath : false,
347
+ });
348
+
349
+ const actualPort = server.server.port ?? port;
350
+ if (actualPort !== port) {
351
+ if (hmrServer) {
352
+ hmrServer.close();
353
+ hmrServer = createHMRServer(actualPort);
354
+ server.registry.settings.hmrPort = actualPort;
355
+ console.log(`🔁 HMR port updated: ${actualPort + HMR_OFFSET}`);
356
+ }
357
+ }
358
+
278
359
  // FS Routes 실시간 감시
279
360
  const routesWatcher = await watchFSRoutes(rootDir, {
280
361
  skipLegacy: true,
281
362
  onChange: async (result) => {
282
- const timestamp = new Date().toLocaleTimeString();
283
- console.log(`\n🔄 [${timestamp}] 라우트 변경 감지`);
284
-
285
- // 레지스트리 클리어 (layout 캐시 포함)
286
- clearDefaultRegistry();
287
-
288
- // 새 매니페스트로 서버 업데이트
289
- manifest = result.manifest;
290
- console.log(` 📋 라우트: ${manifest.routes.length}개`);
291
-
292
- // 라우트 재등록 (isReload = true)
293
- await registerHandlers(manifest, true);
294
-
295
- // HMR 브로드캐스트 (전체 리로드)
296
- if (hmrServer) {
297
- hmrServer.broadcast({
298
- type: "reload",
299
- data: { timestamp: Date.now() },
300
- });
301
- }
363
+ const timestamp = new Date().toLocaleTimeString();
364
+ console.log(`\n🔄 [${timestamp}] 라우트 변경 감지`);
365
+
366
+ // 레지스트리 클리어 (layout 캐시 포함)
367
+ clearDefaultRegistry();
368
+
369
+ // 새 매니페스트로 서버 업데이트
370
+ manifest = result.manifest;
371
+ console.log(` 📋 라우트: ${manifest.routes.length}개`);
372
+
373
+ // 라우트 재등록 (isReload = true)
374
+ await registerHandlers(manifest, true);
375
+
376
+ // HMR 브로드캐스트 (전체 리로드)
377
+ if (hmrServer) {
378
+ hmrServer.broadcast({
379
+ type: "reload",
380
+ data: { timestamp: Date.now() },
381
+ });
382
+ }
302
383
  },
303
384
  });
304
385
 
@@ -312,6 +393,7 @@ export async function dev(options: DevOptions = {}): Promise<void> {
312
393
  server.stop();
313
394
  devBundler?.close();
314
395
  hmrServer?.close();
396
+ cssWatcher?.close();
315
397
  routesWatcher.close();
316
398
  archGuardWatcher?.close();
317
399
  process.exit(0);
@@ -15,20 +15,22 @@ import {
15
15
  getBrain,
16
16
  } from "../../../core/src/index";
17
17
  import { resolveFromCwd, getRootDir } from "../util/fs";
18
- import path from "path";
18
+ import path from "path";
19
19
  import fs from "fs/promises";
20
20
 
21
- export interface DoctorOptions {
22
- /** Output format: console, json, or markdown */
23
- format?: "console" | "json" | "markdown";
21
+ export interface DoctorOptions {
22
+ /** Output format: console, json, or markdown */
23
+ format?: "console" | "json" | "markdown";
24
24
  /** Whether to use LLM for enhanced analysis */
25
25
  useLLM?: boolean;
26
26
  /** Output file path (for json/markdown formats) */
27
- output?: string;
28
- }
29
-
30
- export async function doctor(options: DoctorOptions = {}): Promise<boolean> {
31
- const { format = "console", useLLM = true, output } = options;
27
+ output?: string;
28
+ }
29
+
30
+ export async function doctor(options: DoctorOptions = {}): Promise<boolean> {
31
+ const { format, useLLM = true, output } = options;
32
+ const inferredFormat = format ?? (output ? (path.extname(output).toLowerCase() === ".json" ? "json" : "markdown") : undefined);
33
+ const resolvedFormat = inferredFormat ?? "console";
32
34
 
33
35
  const specPath = resolveFromCwd("spec/routes.manifest.json");
34
36
  const rootDir = getRootDir();
@@ -80,12 +82,12 @@ export async function doctor(options: DoctorOptions = {}): Promise<boolean> {
80
82
  });
81
83
 
82
84
  // Output based on format
83
- switch (format) {
84
- case "console":
85
- printDoctorReport(analysis);
86
- break;
87
-
88
- case "json": {
85
+ switch (resolvedFormat) {
86
+ case "console":
87
+ printDoctorReport(analysis);
88
+ break;
89
+
90
+ case "json": {
89
91
  const json = JSON.stringify(
90
92
  {
91
93
  summary: analysis.summary,
@@ -104,10 +106,10 @@ export async function doctor(options: DoctorOptions = {}): Promise<boolean> {
104
106
  } else {
105
107
  console.log(json);
106
108
  }
107
- break;
108
- }
109
-
110
- case "markdown": {
109
+ break;
110
+ }
111
+
112
+ case "markdown": {
111
113
  const md = generateDoctorMarkdownReport(analysis);
112
114
 
113
115
  if (output) {
@@ -116,9 +118,9 @@ export async function doctor(options: DoctorOptions = {}): Promise<boolean> {
116
118
  } else {
117
119
  console.log(md);
118
120
  }
119
- break;
120
- }
121
- }
122
-
123
- return false;
124
- }
121
+ break;
122
+ }
123
+ }
124
+
125
+ return false;
126
+ }