@mandujs/cli 0.9.24 → 0.9.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/cli",
3
- "version": "0.9.24",
3
+ "version": "0.9.42",
4
4
  "description": "Agent-Native Fullstack Framework - 에이전트가 코딩해도 아키텍처가 무너지지 않는 개발 OS",
5
5
  "type": "module",
6
6
  "main": "./src/main.ts",
@@ -4,7 +4,7 @@
4
4
  * Hydration이 필요한 Island들을 번들링합니다.
5
5
  */
6
6
 
7
- import { loadManifest, buildClientBundles, printBundleStats } from "@mandujs/core";
7
+ import { loadManifest, buildClientBundles, printBundleStats, validateAndReport } from "@mandujs/core";
8
8
  import path from "path";
9
9
  import fs from "fs/promises";
10
10
 
@@ -19,14 +19,20 @@ export interface BuildOptions {
19
19
  outDir?: string;
20
20
  }
21
21
 
22
- export async function build(options: BuildOptions = {}): Promise<boolean> {
23
- const cwd = process.cwd();
24
- const specPath = path.join(cwd, "spec", "routes.manifest.json");
25
-
26
- console.log("📦 Mandu Build - Client Bundle Builder\n");
27
-
28
- // 1. Spec 로드
29
- const specResult = await loadManifest(specPath);
22
+ export async function build(options: BuildOptions = {}): Promise<boolean> {
23
+ const cwd = process.cwd();
24
+ const specPath = path.join(cwd, "spec", "routes.manifest.json");
25
+
26
+ console.log("📦 Mandu Build - Client Bundle Builder\n");
27
+
28
+ const config = await validateAndReport(cwd);
29
+ if (!config) {
30
+ return false;
31
+ }
32
+ const buildConfig = config.build ?? {};
33
+
34
+ // 1. Spec 로드
35
+ const specResult = await loadManifest(specPath);
30
36
  if (!specResult.success) {
31
37
  console.error("❌ Spec 로드 실패:");
32
38
  for (const error of specResult.errors) {
@@ -58,13 +64,13 @@ export async function build(options: BuildOptions = {}): Promise<boolean> {
58
64
  console.log(` - ${route.id} (${hydration.strategy}, ${hydration.priority || "visible"})`);
59
65
  }
60
66
 
61
- // 3. 번들 빌드
62
- const startTime = performance.now();
63
- const result = await buildClientBundles(manifest, cwd, {
64
- minify: options.minify,
65
- sourcemap: options.sourcemap,
66
- outDir: options.outDir,
67
- });
67
+ // 3. 번들 빌드
68
+ const startTime = performance.now();
69
+ const result = await buildClientBundles(manifest, cwd, {
70
+ minify: options.minify ?? buildConfig.minify,
71
+ sourcemap: options.sourcemap ?? buildConfig.sourcemap,
72
+ outDir: options.outDir ?? buildConfig.outDir,
73
+ });
68
74
 
69
75
  // 4. 결과 출력
70
76
  console.log("");
@@ -1,9 +1,9 @@
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,
@@ -17,6 +17,7 @@ import {
17
17
  formatReportForAgent,
18
18
  formatReportAsAgentJSON,
19
19
  getPreset,
20
+ validateAndReport,
20
21
  type RoutesManifest,
21
22
  type GuardConfig,
22
23
  type GuardPreset,
@@ -24,8 +25,10 @@ import {
24
25
  } from "@mandujs/core";
25
26
  import { isDirectory, resolveFromCwd } from "../util/fs";
26
27
  import { resolveOutputFormat, type OutputFormat } from "../util/output";
27
- import path from "path";
28
-
28
+ import { CLI_ERROR_CODES, printCLIError } from "../errors";
29
+ import { importFresh } from "../util/bun";
30
+ import path from "path";
31
+
29
32
  export interface DevOptions {
30
33
  port?: number;
31
34
  /** HMR 비활성화 */
@@ -39,55 +42,64 @@ export interface DevOptions {
39
42
  /** Guard 출력 형식 */
40
43
  guardFormat?: OutputFormat;
41
44
  }
42
-
45
+
43
46
  export async function dev(options: DevOptions = {}): Promise<void> {
44
47
  const rootDir = resolveFromCwd(".");
48
+ const config = await validateAndReport(rootDir);
49
+
50
+ if (!config) {
51
+ printCLIError(CLI_ERROR_CODES.CONFIG_VALIDATION_FAILED);
52
+ process.exit(1);
53
+ }
54
+
55
+ const serverConfig = config.server ?? {};
56
+ const devConfig = config.dev ?? {};
57
+ const guardConfigFromFile = config.guard ?? {};
45
58
 
46
59
  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
-
60
+
61
+ // .env 파일 로드
62
+ const envResult = await loadEnv({
63
+ rootDir,
64
+ env: "development",
65
+ });
66
+
67
+ if (envResult.loaded.length > 0) {
68
+ console.log(`🔐 환경 변수 로드: ${envResult.loaded.join(", ")}`);
69
+ }
70
+
71
+ // FS Routes 스캔
72
+ console.log(`📂 app/ 폴더 스캔 중...`);
73
+
74
+ const result = await generateManifest(rootDir, {
75
+ skipLegacy: true,
76
+ });
77
+
78
+ if (result.manifest.routes.length === 0) {
79
+ printCLIError(CLI_ERROR_CODES.DEV_NO_ROUTES);
80
+ console.log("💡 app/ 폴더에 page.tsx 파일을 생성하세요:");
81
+ console.log("");
82
+ console.log(" app/page.tsx /");
83
+ console.log(" app/blog/page.tsx → /blog");
84
+ console.log(" app/api/users/route.ts → /api/users");
85
+ console.log("");
86
+ process.exit(1);
87
+ }
88
+
78
89
  let manifest = result.manifest;
79
90
  console.log(`✅ ${manifest.routes.length}개 라우트 발견\n`);
80
91
 
81
92
  const enableFsRoutes = !options.legacy && await isDirectory(path.resolve(rootDir, "app"));
82
- const guardPreset = options.guardPreset || "mandu";
93
+ const guardPreset = options.guardPreset || guardConfigFromFile.preset || "mandu";
83
94
  const guardFormat = resolveOutputFormat(options.guardFormat);
84
95
  const guardConfig: GuardConfig | null =
85
96
  options.guard === false
86
97
  ? null
87
98
  : {
88
99
  preset: guardPreset,
89
- srcDir: "src",
90
- realtime: true,
100
+ srcDir: guardConfigFromFile.srcDir || "src",
101
+ realtime: guardConfigFromFile.realtime ?? true,
102
+ exclude: guardConfigFromFile.exclude,
91
103
  realtimeOutput: guardFormat,
92
104
  fsRoutes: enableFsRoutes
93
105
  ? {
@@ -144,161 +156,178 @@ export async function dev(options: DevOptions = {}): Promise<void> {
144
156
 
145
157
  // Layout 경로 추적 (중복 등록 방지)
146
158
  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
-
159
+
160
+ // 핸들러 등록 함수
161
+ const registerHandlers = async (manifest: RoutesManifest, isReload = false) => {
162
+ // 리로드 시 레이아웃 캐시 클리어
163
+ if (isReload) {
164
+ registeredLayouts.clear();
165
+ }
166
+
167
+ for (const route of manifest.routes) {
168
+ if (route.kind === "api") {
169
+ const modulePath = path.resolve(rootDir, route.module);
170
+ try {
171
+ // 캐시 무효화 (HMR용)
172
+ const module = await importFresh(modulePath);
173
+ let handler = module.default || module.handler || module;
174
+
175
+ // ManduFilling 인스턴스를 핸들러 함수로 래핑
176
+ if (handler && typeof handler.handle === 'function') {
177
+ console.log(` 🔄 ManduFilling 래핑: ${route.id}`);
178
+ const filling = handler;
179
+ handler = async (req: Request, params?: Record<string, string>) => {
180
+ return filling.handle(req, params);
181
+ };
182
+ } else {
183
+ console.log(` ⚠️ 핸들러 타입: ${typeof handler}, handle: ${typeof handler?.handle}`);
184
+ }
185
+
186
+ registerApiHandler(route.id, handler);
187
+ console.log(` 📡 API: ${route.pattern} -> ${route.id}`);
188
+ } catch (error) {
189
+ console.error(` ❌ API 핸들러 로드 실패: ${route.id}`, error);
190
+ }
191
+ } else if (route.kind === "page" && route.componentModule) {
192
+ const componentPath = path.resolve(rootDir, route.componentModule);
193
+ const isIsland = needsHydration(route);
194
+ const hasLayout = route.layoutChain && route.layoutChain.length > 0;
195
+
196
+ // Layout 로더 등록
197
+ if (route.layoutChain) {
198
+ for (const layoutPath of route.layoutChain) {
199
+ if (!registeredLayouts.has(layoutPath)) {
200
+ const absLayoutPath = path.resolve(rootDir, layoutPath);
201
+ registerLayoutLoader(layoutPath, async () => {
202
+ // 캐시 무효화 (HMR용)
203
+ return importFresh(absLayoutPath);
204
+ });
205
+ registeredLayouts.add(layoutPath);
206
+ console.log(` 🎨 Layout: ${layoutPath}`);
207
+ }
208
+ }
209
+ }
210
+
211
+ // slotModule이 있으면 PageHandler 사용 (filling.loader 지원)
212
+ if (route.slotModule) {
213
+ registerPageHandler(route.id, async () => {
214
+ const module = await importFresh(componentPath);
215
+ return module.default;
216
+ });
217
+ console.log(` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`);
218
+ } else {
219
+ registerPageLoader(route.id, () => importFresh(componentPath));
220
+ console.log(` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`);
221
+ }
222
+ }
223
+ }
224
+ };
225
+
226
+ // 초기 핸들러 등록
227
+ await registerHandlers(manifest);
228
+ console.log("");
229
+
230
+ const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
231
+ const port =
232
+ options.port ??
233
+ (envPort && Number.isFinite(envPort) ? envPort : undefined) ??
234
+ serverConfig.port ??
235
+ 3333;
236
+
237
+ // HMR 서버 시작 (클라이언트 슬롯이 있는 경우)
238
+ let hmrServer: ReturnType<typeof createHMRServer> | null = null;
239
+ let devBundler: Awaited<ReturnType<typeof startDevBundler>> | null = null;
240
+
217
241
  const hasIslands = manifest.routes.some(
218
242
  (r) => r.kind === "page" && r.clientModule && needsHydration(r)
219
243
  );
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
-
244
+
245
+ const hmrEnabled = options.noHmr ? false : (devConfig.hmr ?? true);
246
+ if (hasIslands && hmrEnabled) {
247
+ // HMR 서버 시작
248
+ hmrServer = createHMRServer(port);
249
+
250
+ // Dev 번들러 시작 (파일 감시)
251
+ devBundler = await startDevBundler({
252
+ rootDir,
253
+ manifest,
254
+ watchDirs: devConfig.watchDirs,
255
+ onRebuild: (result) => {
256
+ if (result.success) {
257
+ if (result.routeId === "*") {
258
+ hmrServer?.broadcast({
259
+ type: "reload",
260
+ data: {
261
+ timestamp: Date.now(),
262
+ },
263
+ });
264
+ } else {
265
+ hmrServer?.broadcast({
266
+ type: "island-update",
267
+ data: {
268
+ routeId: result.routeId,
269
+ timestamp: Date.now(),
270
+ },
271
+ });
272
+ }
273
+ } else {
274
+ hmrServer?.broadcast({
275
+ type: "error",
276
+ data: {
277
+ routeId: result.routeId,
278
+ message: result.error,
279
+ },
280
+ });
281
+ }
282
+ },
283
+ onError: (error, routeId) => {
284
+ hmrServer?.broadcast({
285
+ type: "error",
286
+ data: {
287
+ routeId,
288
+ message: error.message,
289
+ },
290
+ });
291
+ },
292
+ });
293
+ }
294
+
295
+ // 메인 서버 시작
296
+ const server = startServer(manifest, {
297
+ port,
298
+ hostname: serverConfig.hostname,
299
+ rootDir,
300
+ isDev: true,
301
+ hmrPort: hmrServer ? port : undefined,
302
+ bundleManifest: devBundler?.initialBuild.manifest,
303
+ cors: serverConfig.cors,
304
+ streaming: serverConfig.streaming,
305
+ });
306
+
278
307
  // FS Routes 실시간 감시
279
308
  const routesWatcher = await watchFSRoutes(rootDir, {
280
309
  skipLegacy: true,
281
310
  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
- }
311
+ const timestamp = new Date().toLocaleTimeString();
312
+ console.log(`\n🔄 [${timestamp}] 라우트 변경 감지`);
313
+
314
+ // 레지스트리 클리어 (layout 캐시 포함)
315
+ clearDefaultRegistry();
316
+
317
+ // 새 매니페스트로 서버 업데이트
318
+ manifest = result.manifest;
319
+ console.log(` 📋 라우트: ${manifest.routes.length}개`);
320
+
321
+ // 라우트 재등록 (isReload = true)
322
+ await registerHandlers(manifest, true);
323
+
324
+ // HMR 브로드캐스트 (전체 리로드)
325
+ if (hmrServer) {
326
+ hmrServer.broadcast({
327
+ type: "reload",
328
+ data: { timestamp: Date.now() },
329
+ });
330
+ }
302
331
  },
303
332
  });
304
333
 
@@ -19,6 +19,7 @@ import {
19
19
  calculateLayerStatistics,
20
20
  generateGuardMarkdownReport,
21
21
  generateHTMLReport,
22
+ validateAndReport,
22
23
  type GuardConfig,
23
24
  type GuardPreset,
24
25
  } from "@mandujs/core";
@@ -53,21 +54,18 @@ export interface GuardArchOptions {
53
54
  }
54
55
 
55
56
  export async function guardArch(options: GuardArchOptions = {}): Promise<boolean> {
57
+ const rootDir = resolveFromCwd(".");
56
58
  const {
57
- preset = "mandu",
58
59
  watch = false,
59
60
  ci = false,
60
61
  format,
61
62
  quiet = false,
62
- srcDir = "src",
63
63
  listPresets: showPresets = false,
64
64
  output,
65
65
  reportFormat = "markdown",
66
66
  saveStats = false,
67
67
  showTrend = false,
68
68
  } = options;
69
-
70
- const rootDir = resolveFromCwd(".");
71
69
  const resolvedFormat = resolveOutputFormat(format);
72
70
  const enableFsRoutes = await isDirectory(path.resolve(rootDir, "app"));
73
71
 
@@ -90,6 +88,13 @@ export async function guardArch(options: GuardArchOptions = {}): Promise<boolean
90
88
  return true;
91
89
  }
92
90
 
91
+ const fileConfig = await validateAndReport(rootDir);
92
+ if (!fileConfig) return false;
93
+ const guardConfigFromFile = fileConfig.guard ?? {};
94
+
95
+ const preset = options.preset ?? guardConfigFromFile.preset ?? "mandu";
96
+ const srcDir = options.srcDir ?? guardConfigFromFile.srcDir ?? "src";
97
+
93
98
  if (resolvedFormat === "console") {
94
99
  console.log("");
95
100
  console.log("🛡️ Mandu Guard - Architecture Checker");
@@ -101,11 +106,12 @@ export async function guardArch(options: GuardArchOptions = {}): Promise<boolean
101
106
  }
102
107
 
103
108
  // Guard 설정
104
- const config: GuardConfig = {
109
+ const guardConfig: GuardConfig = {
105
110
  preset,
106
111
  srcDir,
107
112
  realtime: watch,
108
113
  realtimeOutput: resolvedFormat,
114
+ exclude: guardConfigFromFile.exclude,
109
115
  fsRoutes: enableFsRoutes
110
116
  ? {
111
117
  noPageToPage: true,
@@ -152,7 +158,7 @@ export async function guardArch(options: GuardArchOptions = {}): Promise<boolean
152
158
  }
153
159
 
154
160
  const watcher = createGuardWatcher({
155
- config,
161
+ config: guardConfig,
156
162
  rootDir,
157
163
  onViolation: (violation) => {
158
164
  // 실시간 위반 출력은 watcher 내부에서 처리됨
@@ -185,7 +191,7 @@ export async function guardArch(options: GuardArchOptions = {}): Promise<boolean
185
191
  console.log("🔍 Scanning for architecture violations...\n");
186
192
  }
187
193
 
188
- const report = await checkDirectory(config, rootDir);
194
+ const report = await checkDirectory(guardConfig, rootDir);
189
195
  const presetDef = getPreset(preset);
190
196
 
191
197
  // 출력 형식에 따른 리포트 출력
@@ -1,5 +1,6 @@
1
- import path from "path";
2
- import fs from "fs/promises";
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import { CLI_ERROR_CODES, printCLIError } from "../errors";
3
4
 
4
5
  export type CSSFramework = "tailwind" | "panda" | "none";
5
6
  export type UILibrary = "shadcn" | "ark" | "none";
@@ -162,8 +163,8 @@ export async function init(options: InitOptions = {}): Promise<boolean> {
162
163
  // Check if target directory exists
163
164
  try {
164
165
  await fs.access(targetDir);
165
- console.error(`❌ 디렉토리가 이미 존재합니다: ${targetDir}`);
166
- return false;
166
+ printCLIError(CLI_ERROR_CODES.INIT_DIR_EXISTS, { path: targetDir });
167
+ return false;
167
168
  } catch {
168
169
  // Directory doesn't exist, good to proceed
169
170
  }
@@ -175,9 +176,9 @@ export async function init(options: InitOptions = {}): Promise<boolean> {
175
176
  try {
176
177
  await fs.access(templateDir);
177
178
  } catch {
178
- console.error(`❌ 템플릿을 찾을 수 없습니다: ${template}`);
179
- console.error(` 사용 가능한 템플릿: default`);
180
- return false;
179
+ printCLIError(CLI_ERROR_CODES.INIT_TEMPLATE_NOT_FOUND, { template });
180
+ console.error(` 사용 가능한 템플릿: default`);
181
+ return false;
181
182
  }
182
183
 
183
184
  console.log(`📋 템플릿 복사 중...`);
@@ -9,6 +9,7 @@ import {
9
9
  generateManifest,
10
10
  formatRoutesForCLI,
11
11
  watchFSRoutes,
12
+ validateAndReport,
12
13
  type GenerateOptions,
13
14
  type FSScannerConfig,
14
15
  } from "@mandujs/core";
@@ -46,11 +47,14 @@ export interface RoutesWatchOptions {
46
47
  */
47
48
  export async function routesGenerate(options: RoutesGenerateOptions = {}): Promise<boolean> {
48
49
  const rootDir = resolveFromCwd(".");
50
+ const config = await validateAndReport(rootDir);
51
+ if (!config) return false;
49
52
 
50
53
  console.log("🥟 Mandu FS Routes Generate\n");
51
54
 
52
55
  try {
53
56
  const generateOptions: GenerateOptions = {
57
+ scanner: config.fsRoutes,
54
58
  outputPath: options.output ?? ".mandu/routes.manifest.json",
55
59
  skipLegacy: true, // 레거시 병합 비활성화
56
60
  };
@@ -93,11 +97,13 @@ export async function routesGenerate(options: RoutesGenerateOptions = {}): Promi
93
97
  */
94
98
  export async function routesList(options: RoutesListOptions = {}): Promise<boolean> {
95
99
  const rootDir = resolveFromCwd(".");
100
+ const config = await validateAndReport(rootDir);
101
+ if (!config) return false;
96
102
 
97
103
  console.log("🥟 Mandu Routes List\n");
98
104
 
99
105
  try {
100
- const result = await scanRoutes(rootDir);
106
+ const result = await scanRoutes(rootDir, config.fsRoutes);
101
107
 
102
108
  if (result.errors.length > 0) {
103
109
  console.log("⚠️ 스캔 경고:");
@@ -164,6 +170,8 @@ export async function routesList(options: RoutesListOptions = {}): Promise<boole
164
170
  */
165
171
  export async function routesWatch(options: RoutesWatchOptions = {}): Promise<boolean> {
166
172
  const rootDir = resolveFromCwd(".");
173
+ const config = await validateAndReport(rootDir);
174
+ if (!config) return false;
167
175
 
168
176
  console.log("🥟 Mandu FS Routes Watch\n");
169
177
  console.log("👀 라우트 변경 감시 중... (Ctrl+C로 종료)\n");
@@ -171,6 +179,7 @@ export async function routesWatch(options: RoutesWatchOptions = {}): Promise<boo
171
179
  try {
172
180
  // 초기 스캔
173
181
  const initialResult = await generateManifest(rootDir, {
182
+ scanner: config.fsRoutes,
174
183
  outputPath: options.output ?? ".mandu/routes.manifest.json",
175
184
  });
176
185
 
@@ -178,6 +187,7 @@ export async function routesWatch(options: RoutesWatchOptions = {}): Promise<boo
178
187
 
179
188
  // 감시 시작
180
189
  const watcher = await watchFSRoutes(rootDir, {
190
+ scanner: config.fsRoutes,
181
191
  outputPath: options.output ?? ".mandu/routes.manifest.json",
182
192
  onChange: (result) => {
183
193
  const timestamp = new Date().toLocaleTimeString();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * CLI error codes
3
+ */
4
+ export const CLI_ERROR_CODES = {
5
+ // Init errors (E001-E009)
6
+ INIT_DIR_EXISTS: "CLI_E001",
7
+ INIT_BUN_NOT_FOUND: "CLI_E002",
8
+ INIT_TEMPLATE_NOT_FOUND: "CLI_E003",
9
+
10
+ // Dev errors (E010-E019)
11
+ DEV_PORT_IN_USE: "CLI_E010",
12
+ DEV_MANIFEST_NOT_FOUND: "CLI_E011",
13
+ DEV_NO_ROUTES: "CLI_E012",
14
+
15
+ // Guard errors (E020-E029)
16
+ GUARD_CONFIG_INVALID: "CLI_E020",
17
+ GUARD_PRESET_NOT_FOUND: "CLI_E021",
18
+ GUARD_VIOLATION_FOUND: "CLI_E022",
19
+
20
+ // Build errors (E030-E039)
21
+ BUILD_ENTRY_NOT_FOUND: "CLI_E030",
22
+ BUILD_BUNDLE_FAILED: "CLI_E031",
23
+ BUILD_OUTDIR_NOT_WRITABLE: "CLI_E032",
24
+
25
+ // Config errors (E040-E049)
26
+ CONFIG_PARSE_FAILED: "CLI_E040",
27
+ CONFIG_VALIDATION_FAILED: "CLI_E041",
28
+
29
+ // CLI usage errors (E100+)
30
+ UNKNOWN_COMMAND: "CLI_E100",
31
+ UNKNOWN_SUBCOMMAND: "CLI_E101",
32
+ MISSING_ARGUMENT: "CLI_E102",
33
+ } as const;
34
+
35
+ export type CLIErrorCode = typeof CLI_ERROR_CODES[keyof typeof CLI_ERROR_CODES];
@@ -0,0 +1,2 @@
1
+ export { CLI_ERROR_CODES, type CLIErrorCode } from "./codes";
2
+ export { CLIError, formatCLIError, handleCLIError, printCLIError } from "./messages";
@@ -0,0 +1,143 @@
1
+ import { CLI_ERROR_CODES, type CLIErrorCode } from "./codes";
2
+
3
+ interface ErrorInfo {
4
+ message: string;
5
+ suggestion?: string;
6
+ docLink?: string;
7
+ }
8
+
9
+ export const ERROR_MESSAGES: Record<CLIErrorCode, ErrorInfo> = {
10
+ [CLI_ERROR_CODES.INIT_DIR_EXISTS]: {
11
+ message: "Directory already exists: {path}",
12
+ suggestion: "Choose a different project name or remove the existing directory.",
13
+ },
14
+ [CLI_ERROR_CODES.INIT_BUN_NOT_FOUND]: {
15
+ message: "Bun runtime not found.",
16
+ suggestion: "Install Bun and ensure it is available in your PATH.",
17
+ },
18
+ [CLI_ERROR_CODES.INIT_TEMPLATE_NOT_FOUND]: {
19
+ message: "Template not found: {template}",
20
+ suggestion: "Use a valid template name (default).",
21
+ },
22
+ [CLI_ERROR_CODES.DEV_PORT_IN_USE]: {
23
+ message: "Port {port} is already in use.",
24
+ suggestion: "Use --port to pick a different port or stop the process using this port.",
25
+ },
26
+ [CLI_ERROR_CODES.DEV_MANIFEST_NOT_FOUND]: {
27
+ message: "Routes manifest not found.",
28
+ suggestion: "Run `mandu routes generate` or create app/ routes before dev.",
29
+ },
30
+ [CLI_ERROR_CODES.DEV_NO_ROUTES]: {
31
+ message: "No routes were found in app/.",
32
+ suggestion: "Create app/page.tsx or app/api/*/route.ts to get started.",
33
+ },
34
+ [CLI_ERROR_CODES.GUARD_CONFIG_INVALID]: {
35
+ message: "Invalid guard configuration.",
36
+ suggestion: "Check your mandu.config and guard settings.",
37
+ },
38
+ [CLI_ERROR_CODES.GUARD_PRESET_NOT_FOUND]: {
39
+ message: "Unknown architecture preset: {preset}",
40
+ suggestion: "Available presets: mandu, fsd, clean, hexagonal, atomic.",
41
+ },
42
+ [CLI_ERROR_CODES.GUARD_VIOLATION_FOUND]: {
43
+ message: "{count} architecture violation(s) found.",
44
+ suggestion: "Fix violations above or run with --format agent for AI-friendly output.",
45
+ },
46
+ [CLI_ERROR_CODES.BUILD_ENTRY_NOT_FOUND]: {
47
+ message: "Build entry not found: {entry}",
48
+ suggestion: "Check your routes manifest or build inputs.",
49
+ },
50
+ [CLI_ERROR_CODES.BUILD_BUNDLE_FAILED]: {
51
+ message: "Bundle build failed for '{target}'.",
52
+ suggestion: "Review build errors above for missing deps or syntax errors.",
53
+ },
54
+ [CLI_ERROR_CODES.BUILD_OUTDIR_NOT_WRITABLE]: {
55
+ message: "Output directory is not writable: {path}",
56
+ suggestion: "Ensure the directory exists and you have write permissions.",
57
+ },
58
+ [CLI_ERROR_CODES.CONFIG_PARSE_FAILED]: {
59
+ message: "Failed to parse mandu.config.",
60
+ suggestion: "Fix syntax errors in the config file.",
61
+ },
62
+ [CLI_ERROR_CODES.CONFIG_VALIDATION_FAILED]: {
63
+ message: "Configuration validation failed.",
64
+ suggestion: "Review validation errors above and fix your config.",
65
+ },
66
+ [CLI_ERROR_CODES.UNKNOWN_COMMAND]: {
67
+ message: "Unknown command: {command}",
68
+ suggestion: "Run with --help to see available commands.",
69
+ },
70
+ [CLI_ERROR_CODES.UNKNOWN_SUBCOMMAND]: {
71
+ message: "Unknown subcommand '{subcommand}' for {command}.",
72
+ suggestion: "Run the command with --help to see available subcommands.",
73
+ },
74
+ [CLI_ERROR_CODES.MISSING_ARGUMENT]: {
75
+ message: "Missing required argument: {argument}",
76
+ suggestion: "Provide the required argument and try again.",
77
+ },
78
+ };
79
+
80
+ function interpolate(text: string, context?: Record<string, string | number>): string {
81
+ if (!context) return text;
82
+ let result = text;
83
+ for (const [key, value] of Object.entries(context)) {
84
+ result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
85
+ }
86
+ return result;
87
+ }
88
+
89
+ export function formatCLIError(
90
+ code: CLIErrorCode,
91
+ context?: Record<string, string | number>
92
+ ): string {
93
+ const info = ERROR_MESSAGES[code];
94
+ const message = interpolate(info?.message ?? "Unknown error", context);
95
+ const suggestion = info?.suggestion ? interpolate(info.suggestion, context) : undefined;
96
+
97
+ const lines = ["", `❌ Error [${code}]`, ` ${message}`];
98
+ if (suggestion) {
99
+ lines.push("", `💡 ${suggestion}`);
100
+ }
101
+ if (info?.docLink) {
102
+ lines.push(`📖 ${info.docLink}`);
103
+ }
104
+ lines.push("");
105
+ return lines.join("\n");
106
+ }
107
+
108
+ export class CLIError extends Error {
109
+ readonly code: CLIErrorCode;
110
+ readonly context?: Record<string, string | number>;
111
+
112
+ constructor(code: CLIErrorCode, context?: Record<string, string | number>) {
113
+ super(formatCLIError(code, context));
114
+ this.code = code;
115
+ this.context = context;
116
+ this.name = "CLIError";
117
+ }
118
+ }
119
+
120
+ export function printCLIError(
121
+ code: CLIErrorCode,
122
+ context?: Record<string, string | number>
123
+ ): void {
124
+ console.error(formatCLIError(code, context));
125
+ }
126
+
127
+ export function handleCLIError(error: unknown): never {
128
+ if (error instanceof CLIError) {
129
+ console.error(error.message);
130
+ process.exit(1);
131
+ }
132
+
133
+ if (error instanceof Error) {
134
+ console.error(`\n❌ Unexpected error: ${error.message}\n`);
135
+ if (process.env.DEBUG) {
136
+ console.error(error.stack);
137
+ }
138
+ process.exit(1);
139
+ }
140
+
141
+ console.error("\n❌ Unknown error occurred\n");
142
+ process.exit(1);
143
+ }
package/src/main.ts CHANGED
@@ -23,6 +23,7 @@ import { watch } from "./commands/watch";
23
23
  import { brainSetup, brainStatus } from "./commands/brain";
24
24
  import { routesGenerate, routesList, routesWatch } from "./commands/routes";
25
25
  import { monitor } from "./commands/monitor";
26
+ import { CLI_ERROR_CODES, handleCLIError, printCLIError } from "./errors";
26
27
 
27
28
  const HELP_TEXT = `
28
29
  🥟 Mandu CLI - Agent-Native Fullstack Framework
@@ -265,7 +266,10 @@ async function main(): Promise<void> {
265
266
  break;
266
267
  default:
267
268
  if (hasSubCommand) {
268
- console.error(`❌ Unknown guard subcommand: ${subCommand}`);
269
+ printCLIError(CLI_ERROR_CODES.UNKNOWN_SUBCOMMAND, {
270
+ command: "guard",
271
+ subcommand,
272
+ });
269
273
  console.log("\nUsage: bunx mandu guard <arch|legacy>");
270
274
  process.exit(1);
271
275
  }
@@ -320,7 +324,10 @@ async function main(): Promise<void> {
320
324
  verbose: options.verbose === "true",
321
325
  });
322
326
  } else {
323
- console.error(`❌ Unknown routes subcommand: ${subCommand}`);
327
+ printCLIError(CLI_ERROR_CODES.UNKNOWN_SUBCOMMAND, {
328
+ command: "routes",
329
+ subcommand,
330
+ });
324
331
  console.log("\nUsage: bunx mandu routes <generate|list|watch>");
325
332
  process.exit(1);
326
333
  }
@@ -334,7 +341,7 @@ async function main(): Promise<void> {
334
341
  case "create": {
335
342
  const routeId = args[2] || options._positional;
336
343
  if (!routeId) {
337
- console.error("❌ Route ID is required");
344
+ printCLIError(CLI_ERROR_CODES.MISSING_ARGUMENT, { argument: "routeId" });
338
345
  console.log("\nUsage: bunx mandu contract create <routeId>");
339
346
  process.exit(1);
340
347
  }
@@ -356,7 +363,10 @@ async function main(): Promise<void> {
356
363
  });
357
364
  break;
358
365
  default:
359
- console.error(`❌ Unknown contract subcommand: ${subCommand}`);
366
+ printCLIError(CLI_ERROR_CODES.UNKNOWN_SUBCOMMAND, {
367
+ command: "contract",
368
+ subcommand,
369
+ });
360
370
  console.log("\nUsage: bunx mandu contract <create|validate|build|diff>");
361
371
  process.exit(1);
362
372
  }
@@ -379,7 +389,10 @@ async function main(): Promise<void> {
379
389
  });
380
390
  break;
381
391
  default:
382
- console.error(`❌ Unknown openapi subcommand: ${subCommand}`);
392
+ printCLIError(CLI_ERROR_CODES.UNKNOWN_SUBCOMMAND, {
393
+ command: "openapi",
394
+ subcommand,
395
+ });
383
396
  console.log("\nUsage: bunx mandu openapi <generate|serve>");
384
397
  process.exit(1);
385
398
  }
@@ -410,7 +423,10 @@ async function main(): Promise<void> {
410
423
  });
411
424
  break;
412
425
  default:
413
- console.error(`❌ Unknown change subcommand: ${subCommand}`);
426
+ printCLIError(CLI_ERROR_CODES.UNKNOWN_SUBCOMMAND, {
427
+ command: "change",
428
+ subcommand,
429
+ });
414
430
  console.log(`\nUsage: bunx mandu change <begin|commit|rollback|status|list|prune>`);
415
431
  process.exit(1);
416
432
  }
@@ -458,7 +474,10 @@ async function main(): Promise<void> {
458
474
  });
459
475
  break;
460
476
  default:
461
- console.error(`❌ Unknown brain subcommand: ${subCommand}`);
477
+ printCLIError(CLI_ERROR_CODES.UNKNOWN_SUBCOMMAND, {
478
+ command: "brain",
479
+ subcommand,
480
+ });
462
481
  console.log("\nUsage: bunx mandu brain <setup|status>");
463
482
  process.exit(1);
464
483
  }
@@ -466,7 +485,7 @@ async function main(): Promise<void> {
466
485
  }
467
486
 
468
487
  default:
469
- console.error(`❌ Unknown command: ${command}`);
488
+ printCLIError(CLI_ERROR_CODES.UNKNOWN_COMMAND, { command });
470
489
  console.log(HELP_TEXT);
471
490
  process.exit(1);
472
491
  }
@@ -476,7 +495,4 @@ async function main(): Promise<void> {
476
495
  }
477
496
  }
478
497
 
479
- main().catch((error) => {
480
- console.error("❌ 예상치 못한 오류:", error);
481
- process.exit(1);
482
- });
498
+ main().catch((error) => handleCLIError(error));
@@ -0,0 +1,6 @@
1
+ export function importFresh<T = unknown>(modulePath: string): Promise<T> {
2
+ const url = Bun.pathToFileURL(modulePath);
3
+ const cacheBusted = new URL(url.href);
4
+ cacheBusted.searchParams.set("t", Date.now().toString());
5
+ return import(cacheBusted.href) as Promise<T>;
6
+ }