@mandujs/cli 0.9.42 → 0.9.44

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/README.ko.md CHANGED
@@ -223,7 +223,7 @@ bun test --watch # 감시 모드
223
223
  ## 요구 사항
224
224
 
225
225
  - Bun >= 1.0.0
226
- - React >= 18.0.0
226
+ - React >= 19.0.0
227
227
 
228
228
  ## 관련 패키지
229
229
 
package/README.md CHANGED
@@ -43,7 +43,9 @@ bun run dev
43
43
  bunx mandu dev
44
44
  ```
45
45
 
46
- Your app is now running at `http://localhost:3000`.
46
+ Your app is now running at `http://localhost:3333`.
47
+
48
+ To change the port, set `PORT` or use `server.port` in `mandu.config`. If the port is in use, Mandu will pick the next available port.
47
49
 
48
50
  ### 3. Create Pages in `app/` Directory
49
51
 
@@ -254,7 +256,6 @@ bunx mandu guard --preset fsd
254
256
 
255
257
  | Option | Description |
256
258
  |--------|-------------|
257
- | `--port <n>` | Server port (default: 3000) |
258
259
  | `--guard` | Enable Guard watching |
259
260
  | `--guard-preset <p>` | Guard preset (default: mandu) |
260
261
 
@@ -307,7 +308,6 @@ bunx mandu guard --preset fsd
307
308
  bunx @mandujs/cli init my-app
308
309
 
309
310
  # Development
310
- bunx mandu dev --port 3000
311
311
  bunx mandu dev --guard
312
312
 
313
313
  # Routes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/cli",
3
- "version": "0.9.42",
3
+ "version": "0.9.44",
4
4
  "description": "Agent-Native Fullstack Framework - 에이전트가 코딩해도 아키텍처가 무너지지 않는 개발 OS",
5
5
  "type": "module",
6
6
  "main": "./src/main.ts",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@mandujs/core": "0.9.39"
35
+ "@mandujs/core": "workspace:*"
36
36
  },
37
37
  "engines": {
38
38
  "bun": ">=1.0.0"
@@ -2,11 +2,13 @@
2
2
  * mandu build - 클라이언트 번들 빌드
3
3
  *
4
4
  * Hydration이 필요한 Island들을 번들링합니다.
5
+ * Tailwind v4 프로젝트는 CSS도 함께 빌드합니다.
5
6
  */
6
7
 
7
- import { loadManifest, buildClientBundles, printBundleStats, validateAndReport } from "@mandujs/core";
8
+ import { buildClientBundles, printBundleStats, validateAndReport, isTailwindProject, buildCSS, type RoutesManifest } from "@mandujs/core";
8
9
  import path from "path";
9
10
  import fs from "fs/promises";
11
+ import { resolveManifest } from "../util/manifest";
10
12
 
11
13
  export interface BuildOptions {
12
14
  /** 코드 압축 (기본: production에서 true) */
@@ -19,30 +21,28 @@ export interface BuildOptions {
19
21
  outDir?: string;
20
22
  }
21
23
 
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);
36
- if (!specResult.success) {
37
- console.error("❌ Spec 로드 실패:");
38
- for (const error of specResult.errors) {
39
- console.error(` ${error}`);
40
- }
24
+ export async function build(options: BuildOptions = {}): Promise<boolean> {
25
+ const cwd = process.cwd();
26
+
27
+ console.log("📦 Mandu Build - Client Bundle Builder\n");
28
+
29
+ const config = await validateAndReport(cwd);
30
+ if (!config) {
41
31
  return false;
42
32
  }
33
+ const buildConfig = config.build ?? {};
43
34
 
44
- const manifest = specResult.data!;
45
- console.log(`✅ Spec 로드 완료: ${manifest.routes.length}개 라우트`);
35
+ // 1. 라우트 매니페스트 로드 (FS Routes 우선)
36
+ let manifest: Awaited<ReturnType<typeof resolveManifest>>["manifest"];
37
+ try {
38
+ const resolved = await resolveManifest(cwd, { fsRoutes: config.fsRoutes });
39
+ manifest = resolved.manifest;
40
+ console.log(`✅ 라우트 로드 완료 (${resolved.source}): ${manifest.routes.length}개 라우트`);
41
+ } catch (error) {
42
+ console.error("❌ 라우트 로드 실패:");
43
+ console.error(` ${error instanceof Error ? error.message : error}`);
44
+ return false;
45
+ }
46
46
 
47
47
  // 2. Hydration이 필요한 라우트 확인
48
48
  const hydratedRoutes = manifest.routes.filter(
@@ -64,15 +64,34 @@ export async function build(options: BuildOptions = {}): Promise<boolean> {
64
64
  console.log(` - ${route.id} (${hydration.strategy}, ${hydration.priority || "visible"})`);
65
65
  }
66
66
 
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
- });
67
+ // 3. Tailwind CSS 빌드 (감지 시에만)
68
+ const hasTailwind = await isTailwindProject(cwd);
69
+ if (hasTailwind) {
70
+ console.log(`\n🎨 Tailwind CSS v4 빌드 중...`);
71
+ const cssResult = await buildCSS({
72
+ rootDir: cwd,
73
+ minify: options.minify ?? true,
74
+ });
75
+
76
+ if (!cssResult.success) {
77
+ console.error(`\n❌ CSS 빌드 실패: ${cssResult.error}`);
78
+ return false;
79
+ }
80
+
81
+ console.log(` ✅ CSS 빌드 완료 (${cssResult.buildTime?.toFixed(0)}ms)`);
82
+ console.log(` 출력: ${cssResult.outputPath}`);
83
+ }
74
84
 
75
- // 4. 결과 출력
85
+ // 4. 번들 빌드
86
+ const startTime = performance.now();
87
+ const resolvedBuildOptions: BuildOptions = {
88
+ minify: options.minify ?? buildConfig.minify,
89
+ sourcemap: options.sourcemap ?? buildConfig.sourcemap,
90
+ outDir: options.outDir ?? buildConfig.outDir,
91
+ };
92
+ const result = await buildClientBundles(manifest, cwd, resolvedBuildOptions);
93
+
94
+ // 5. 결과 출력
76
95
  console.log("");
77
96
  printBundleStats(result);
78
97
 
@@ -84,13 +103,16 @@ export async function build(options: BuildOptions = {}): Promise<boolean> {
84
103
  const elapsed = (performance.now() - startTime).toFixed(0);
85
104
  console.log(`\n✅ 빌드 완료 (${elapsed}ms)`);
86
105
  console.log(` 출력: .mandu/client/`);
106
+ if (hasTailwind) {
107
+ console.log(` CSS: .mandu/client/globals.css`);
108
+ }
87
109
 
88
- // 5. 감시 모드
110
+ // 6. 감시 모드
89
111
  if (options.watch) {
90
112
  console.log("\n👀 파일 감시 모드...");
91
113
  console.log(" Ctrl+C로 종료\n");
92
114
 
93
- await watchAndRebuild(manifest, cwd, options);
115
+ await watchAndRebuild(manifest, cwd, resolvedBuildOptions);
94
116
  }
95
117
 
96
118
  return true;
@@ -100,7 +122,7 @@ export async function build(options: BuildOptions = {}): Promise<boolean> {
100
122
  * 파일 감시 및 재빌드
101
123
  */
102
124
  async function watchAndRebuild(
103
- manifest: Awaited<ReturnType<typeof loadManifest>>["manifest"],
125
+ manifest: RoutesManifest,
104
126
  rootDir: string,
105
127
  options: BuildOptions
106
128
  ): Promise<void> {
@@ -10,32 +10,28 @@ import {
10
10
  checkDirectory,
11
11
  printReport,
12
12
  getPreset,
13
+ validateAndReport,
13
14
  loadManifest,
14
15
  runGuardCheck,
15
16
  buildGuardReport,
16
17
  printReportSummary,
17
18
  type GuardConfig,
18
- type GuardPreset,
19
19
  } from "@mandujs/core";
20
20
  import path from "path";
21
21
  import { resolveFromCwd, isDirectory, pathExists } from "../util/fs";
22
- import { resolveOutputFormat, type OutputFormat } from "../util/output";
23
-
24
- export interface CheckOptions {
25
- preset?: GuardPreset;
26
- format?: OutputFormat;
27
- ci?: boolean;
28
- quiet?: boolean;
29
- legacy?: boolean;
30
- }
22
+ import { resolveOutputFormat } from "../util/output";
31
23
 
32
- export async function check(options: CheckOptions = {}): Promise<boolean> {
24
+ export async function check(): Promise<boolean> {
33
25
  const rootDir = resolveFromCwd(".");
34
- const preset = options.preset ?? "mandu";
35
- const format = resolveOutputFormat(options.format);
36
- const quiet = options.quiet === true;
37
- const strictWarnings = options.ci === true;
38
- const enableFsRoutes = !options.legacy && await isDirectory(path.resolve(rootDir, "app"));
26
+ const config = await validateAndReport(rootDir);
27
+ if (!config) return false;
28
+
29
+ const guardConfigFromFile = config.guard ?? {};
30
+ const preset = guardConfigFromFile.preset ?? "mandu";
31
+ const format = resolveOutputFormat();
32
+ const quiet = false;
33
+ const strictWarnings = process.env.CI === "true";
34
+ const enableFsRoutes = await isDirectory(path.resolve(rootDir, "app"));
39
35
  const specPath = resolveFromCwd("spec/routes.manifest.json");
40
36
  const hasSpec = await pathExists(specPath);
41
37
 
@@ -70,6 +66,7 @@ export async function check(options: CheckOptions = {}): Promise<boolean> {
70
66
  try {
71
67
  if (format === "console") {
72
68
  const result = await generateManifest(rootDir, {
69
+ scanner: config.fsRoutes,
73
70
  outputPath: ".mandu/routes.manifest.json",
74
71
  skipLegacy: true,
75
72
  });
@@ -95,7 +92,7 @@ export async function check(options: CheckOptions = {}): Promise<boolean> {
95
92
  log("");
96
93
  }
97
94
  } else {
98
- const scan = await scanRoutes(rootDir);
95
+ const scan = await scanRoutes(rootDir, config.fsRoutes);
99
96
  routesSummary.count = scan.routes.length;
100
97
  routesSummary.warnings = scan.errors.map((e) => `${e.type}: ${e.message}`);
101
98
  }
@@ -119,7 +116,8 @@ export async function check(options: CheckOptions = {}): Promise<boolean> {
119
116
  // 2) Architecture Guard 검사
120
117
  const guardConfig: GuardConfig = {
121
118
  preset,
122
- srcDir: "src",
119
+ srcDir: guardConfigFromFile.srcDir ?? "src",
120
+ exclude: guardConfigFromFile.exclude,
123
121
  fsRoutes: enableFsRoutes
124
122
  ? {
125
123
  noPageToPage: true,
@@ -4,16 +4,17 @@
4
4
  */
5
5
 
6
6
  import {
7
- loadManifest,
8
7
  runContractGuardCheck,
9
8
  generateContractTemplate,
10
9
  buildContractRegistry,
11
10
  writeContractRegistry,
12
11
  readContractRegistry,
13
12
  diffContractRegistry,
13
+ validateAndReport,
14
14
  } from "@mandujs/core";
15
15
  import path from "path";
16
16
  import fs from "fs/promises";
17
+ import { resolveManifest } from "../util/manifest";
17
18
 
18
19
  interface ContractCreateOptions {
19
20
  routeId: string;
@@ -33,24 +34,32 @@ interface ContractDiffOptions {
33
34
  output?: string;
34
35
  json?: boolean;
35
36
  }
37
+
38
+ async function loadRoutesManifest(rootDir: string) {
39
+ const config = await validateAndReport(rootDir);
40
+ if (!config) {
41
+ throw new Error("Invalid mandu.config");
42
+ }
43
+ const resolved = await resolveManifest(rootDir, { fsRoutes: config.fsRoutes });
44
+ return resolved.manifest;
45
+ }
36
46
 
37
47
  /**
38
48
  * Create a new contract file for a route
39
49
  */
40
- export async function contractCreate(options: ContractCreateOptions): Promise<boolean> {
41
- const rootDir = process.cwd();
42
- const manifestPath = path.join(rootDir, "spec/routes.manifest.json");
43
-
44
- console.log(`\n📜 Creating contract for route: ${options.routeId}\n`);
45
-
46
- // Load manifest
47
- const manifestResult = await loadManifest(manifestPath);
48
- if (!manifestResult.success) {
49
- console.error("❌ Failed to load manifest:", manifestResult.errors);
50
- return false;
51
- }
52
-
53
- const manifest = manifestResult.data!;
50
+ export async function contractCreate(options: ContractCreateOptions): Promise<boolean> {
51
+ const rootDir = process.cwd();
52
+
53
+ console.log(`\n📜 Creating contract for route: ${options.routeId}\n`);
54
+
55
+ // Load manifest
56
+ let manifest;
57
+ try {
58
+ manifest = await loadRoutesManifest(rootDir);
59
+ } catch (error) {
60
+ console.error("❌ Failed to load manifest:", error instanceof Error ? error.message : error);
61
+ return false;
62
+ }
54
63
 
55
64
  // Find the route
56
65
  const route = manifest.routes.find((r) => r.id === options.routeId);
@@ -105,22 +114,21 @@ export async function contractCreate(options: ContractCreateOptions): Promise<bo
105
114
  * Validate all contracts against their slot implementations
106
115
  */
107
116
  export async function contractValidate(options: ContractValidateOptions = {}): Promise<boolean> {
108
- const rootDir = process.cwd();
109
- const manifestPath = path.join(rootDir, "spec/routes.manifest.json");
110
-
111
- console.log(`\n🔍 Validating contracts...\n`);
112
-
113
- // Load manifest
114
- const manifestResult = await loadManifest(manifestPath);
115
- if (!manifestResult.success) {
116
- console.error("❌ Failed to load manifest:", manifestResult.errors);
117
- return false;
118
- }
119
-
120
- const manifest = manifestResult.data!;
121
-
122
- // Run contract guard check
123
- const violations = await runContractGuardCheck(manifest, rootDir);
117
+ const rootDir = process.cwd();
118
+
119
+ console.log(`\n🔍 Validating contracts...\n`);
120
+
121
+ // Load manifest
122
+ let manifest;
123
+ try {
124
+ manifest = await loadRoutesManifest(rootDir);
125
+ } catch (error) {
126
+ console.error("❌ Failed to load manifest:", error instanceof Error ? error.message : error);
127
+ return false;
128
+ }
129
+
130
+ // Run contract guard check
131
+ const violations = await runContractGuardCheck(manifest, rootDir);
124
132
 
125
133
  if (violations.length === 0) {
126
134
  console.log(`✅ All contracts are valid!\n`);
@@ -175,18 +183,17 @@ export async function contractValidate(options: ContractValidateOptions = {}): P
175
183
  */
176
184
  export async function contractBuild(options: ContractBuildOptions = {}): Promise<boolean> {
177
185
  const rootDir = process.cwd();
178
- const manifestPath = path.join(rootDir, "spec/routes.manifest.json");
179
186
  const outputPath = options.output || path.join(rootDir, ".mandu", "contracts.json");
180
187
 
181
188
  console.log(`\n📦 Building contract registry...\n`);
182
189
 
183
- const manifestResult = await loadManifest(manifestPath);
184
- if (!manifestResult.success) {
185
- console.error("❌ Failed to load manifest:", manifestResult.errors);
190
+ let manifest;
191
+ try {
192
+ manifest = await loadRoutesManifest(rootDir);
193
+ } catch (error) {
194
+ console.error("❌ Failed to load manifest:", error instanceof Error ? error.message : error);
186
195
  return false;
187
196
  }
188
-
189
- const manifest = manifestResult.data!;
190
197
  const { registry, warnings } = await buildContractRegistry(manifest, rootDir);
191
198
 
192
199
  if (warnings.length > 0) {
@@ -211,7 +218,6 @@ export async function contractBuild(options: ContractBuildOptions = {}): Promise
211
218
  */
212
219
  export async function contractDiff(options: ContractDiffOptions = {}): Promise<boolean> {
213
220
  const rootDir = process.cwd();
214
- const manifestPath = path.join(rootDir, "spec/routes.manifest.json");
215
221
  const fromPath = options.from || path.join(rootDir, ".mandu", "contracts.json");
216
222
 
217
223
  console.log(`\n🔍 Diffing contracts...\n`);
@@ -226,12 +232,14 @@ export async function contractDiff(options: ContractDiffOptions = {}): Promise<b
226
232
  let toRegistry = options.to ? await readContractRegistry(options.to) : null;
227
233
 
228
234
  if (!toRegistry) {
229
- const manifestResult = await loadManifest(manifestPath);
230
- if (!manifestResult.success) {
231
- console.error("❌ Failed to load manifest:", manifestResult.errors);
235
+ let manifest;
236
+ try {
237
+ manifest = await loadRoutesManifest(rootDir);
238
+ } catch (error) {
239
+ console.error("❌ Failed to load manifest:", error instanceof Error ? error.message : error);
232
240
  return false;
233
241
  }
234
- const { registry } = await buildContractRegistry(manifestResult.data!, rootDir);
242
+ const { registry } = await buildContractRegistry(manifest, rootDir);
235
243
  toRegistry = registry;
236
244
  }
237
245
 
@@ -8,7 +8,6 @@ import {
8
8
  createHMRServer,
9
9
  needsHydration,
10
10
  loadEnv,
11
- generateManifest,
12
11
  watchFSRoutes,
13
12
  clearDefaultRegistry,
14
13
  createGuardWatcher,
@@ -18,29 +17,23 @@ import {
18
17
  formatReportAsAgentJSON,
19
18
  getPreset,
20
19
  validateAndReport,
20
+ isTailwindProject,
21
+ startCSSWatch,
21
22
  type RoutesManifest,
22
23
  type GuardConfig,
23
- type GuardPreset,
24
24
  type Violation,
25
+ type CSSWatcher,
25
26
  } from "@mandujs/core";
26
- import { isDirectory, resolveFromCwd } from "../util/fs";
27
- import { resolveOutputFormat, type OutputFormat } from "../util/output";
27
+ import { resolveFromCwd } from "../util/fs";
28
+ import { resolveOutputFormat } from "../util/output";
28
29
  import { CLI_ERROR_CODES, printCLIError } from "../errors";
29
30
  import { importFresh } from "../util/bun";
31
+ import { resolveManifest } from "../util/manifest";
32
+ import { resolveAvailablePort } from "../util/port";
30
33
  import path from "path";
31
34
 
32
35
  export interface DevOptions {
33
36
  port?: number;
34
- /** HMR 비활성화 */
35
- noHmr?: boolean;
36
- /** FS Routes 비활성화 (레거시 모드) */
37
- legacy?: boolean;
38
- /** Architecture Guard 활성화 */
39
- guard?: boolean;
40
- /** Guard 프리셋 */
41
- guardPreset?: GuardPreset;
42
- /** Guard 출력 형식 */
43
- guardFormat?: OutputFormat;
44
37
  }
45
38
 
46
39
  export async function dev(options: DevOptions = {}): Promise<void> {
@@ -55,8 +48,9 @@ export async function dev(options: DevOptions = {}): Promise<void> {
55
48
  const serverConfig = config.server ?? {};
56
49
  const devConfig = config.dev ?? {};
57
50
  const guardConfigFromFile = config.guard ?? {};
51
+ const HMR_OFFSET = 1;
58
52
 
59
- console.log(`🥟 Mandu Dev Server (FS Routes)`);
53
+ console.log(`🥟 Mandu Dev Server`);
60
54
 
61
55
  // .env 파일 로드
62
56
  const envResult = await loadEnv({
@@ -68,32 +62,37 @@ export async function dev(options: DevOptions = {}): Promise<void> {
68
62
  console.log(`🔐 환경 변수 로드: ${envResult.loaded.join(", ")}`);
69
63
  }
70
64
 
71
- // FS Routes 스캔
72
- console.log(`📂 app/ 폴더 스캔 중...`);
73
-
74
- const result = await generateManifest(rootDir, {
75
- skipLegacy: true,
76
- });
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
+ }
77
85
 
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
+ 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);
86
90
  process.exit(1);
87
91
  }
88
-
89
- let manifest = result.manifest;
90
- console.log(`✅ ${manifest.routes.length}개 라우트 발견\n`);
91
-
92
- const enableFsRoutes = !options.legacy && await isDirectory(path.resolve(rootDir, "app"));
93
- const guardPreset = options.guardPreset || guardConfigFromFile.preset || "mandu";
94
- const guardFormat = resolveOutputFormat(options.guardFormat);
92
+ const guardPreset = guardConfigFromFile.preset || "mandu";
93
+ const guardFormat = resolveOutputFormat();
95
94
  const guardConfig: GuardConfig | null =
96
- options.guard === false
95
+ guardConfigFromFile.realtime === false
97
96
  ? null
98
97
  : {
99
98
  preset: guardPreset,
@@ -228,21 +227,62 @@ export async function dev(options: DevOptions = {}): Promise<void> {
228
227
  console.log("");
229
228
 
230
229
  const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
231
- const port =
230
+ const desiredPort =
232
231
  options.port ??
233
232
  (envPort && Number.isFinite(envPort) ? envPort : undefined) ??
234
233
  serverConfig.port ??
235
234
  3333;
236
235
 
236
+ const hasIslands = manifest.routes.some(
237
+ (r) => r.kind === "page" && r.clientModule && needsHydration(r)
238
+ );
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
+
237
250
  // HMR 서버 시작 (클라이언트 슬롯이 있는 경우)
238
251
  let hmrServer: ReturnType<typeof createHMRServer> | null = null;
239
252
  let devBundler: Awaited<ReturnType<typeof startDevBundler>> | null = null;
253
+ let cssWatcher: CSSWatcher | null = null;
240
254
 
241
- const hasIslands = manifest.routes.some(
242
- (r) => r.kind === "page" && r.clientModule && needsHydration(r)
243
- );
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
+ }
244
285
 
245
- const hmrEnabled = options.noHmr ? false : (devConfig.hmr ?? true);
246
286
  if (hasIslands && hmrEnabled) {
247
287
  // HMR 서버 시작
248
288
  hmrServer = createHMRServer(port);
@@ -302,8 +342,20 @@ export async function dev(options: DevOptions = {}): Promise<void> {
302
342
  bundleManifest: devBundler?.initialBuild.manifest,
303
343
  cors: serverConfig.cors,
304
344
  streaming: serverConfig.streaming,
345
+ // Tailwind 감지 시에만 CSS 링크 주입
346
+ cssPath: hasTailwind ? cssWatcher?.serverPath : false,
305
347
  });
306
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
+
307
359
  // FS Routes 실시간 감시
308
360
  const routesWatcher = await watchFSRoutes(rootDir, {
309
361
  skipLegacy: true,
@@ -341,6 +393,7 @@ export async function dev(options: DevOptions = {}): Promise<void> {
341
393
  server.stop();
342
394
  devBundler?.close();
343
395
  hmrServer?.close();
396
+ cssWatcher?.close();
344
397
  routesWatcher.close();
345
398
  archGuardWatcher?.close();
346
399
  process.exit(0);