sh-ui-cli 0.76.0 → 0.78.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.
Files changed (115) hide show
  1. package/data/changelog/versions.json +58 -0
  2. package/data/registry/react/components/code-editor/index.module.tsx +7 -1
  3. package/data/registry/react/components/code-editor/index.tailwind.tsx +5 -2
  4. package/data/registry/react/components/code-editor/index.tsx +7 -1
  5. package/data/registry/react/components/markdown-editor/index.module.tsx +1 -1
  6. package/data/registry/react/components/markdown-editor/index.tailwind.tsx +1 -1
  7. package/data/registry/react/components/markdown-editor/index.tsx +1 -1
  8. package/data/registry/react/components/select/index.module.tsx +20 -9
  9. package/data/registry/react/components/select/index.tailwind.tsx +21 -8
  10. package/data/registry/react/components/select/index.tsx +28 -9
  11. package/data/registry/react/components/sidebar/index.module.tsx +10 -6
  12. package/data/registry/react/components/sidebar/index.tailwind.tsx +10 -6
  13. package/data/registry/react/components/sidebar/index.tsx +20 -4
  14. package/data/registry/react/components/switch/index.tailwind.tsx +1 -1
  15. package/data/registry/react/components/switch/styles.css +6 -0
  16. package/data/registry/react/components/switch/styles.module.css +6 -0
  17. package/data/registry/react/registry.json +2 -1
  18. package/data/registry/react/tokens-used.json +3 -1
  19. package/data/tokens/src/semantic.json +16 -2
  20. package/package.json +1 -1
  21. package/src/create/architectures/index.js +2 -1
  22. package/src/create/architectures/mes.js +53 -0
  23. package/src/create/generator.js +61 -8
  24. package/src/create/plugins/authJwt.js +10 -0
  25. package/src/create/plugins/nextIntl.js +36 -2
  26. package/src/mcp.mjs +66 -1
  27. package/templates/monorepo/packages/eslint-config/mes.js +82 -0
  28. package/templates/monorepo/packages/eslint-config/package.json +2 -1
  29. package/templates/monorepo/packages/ui/ui-core/package.json +1 -1
  30. package/templates/nextjs-app/_arch/flat/components/layouts/RootLayout.tsx +6 -0
  31. package/templates/nextjs-app/_arch/fsd/src/app/layouts/RootLayout.tsx +11 -0
  32. package/templates/nextjs-app/_arch/mes/app/api/proxy/[...path]/route.ts +112 -0
  33. package/templates/nextjs-app/_arch/mes/app/layout.tsx +16 -0
  34. package/templates/nextjs-app/_arch/mes/app/sign-in/page.tsx +1 -0
  35. package/templates/nextjs-app/_arch/mes/eslint.config.js +10 -0
  36. package/templates/nextjs-app/_arch/mes/src/components/common/.gitkeep +0 -0
  37. package/templates/nextjs-app/_arch/mes/src/components/common/FallbackBoundary/index.tsx +89 -0
  38. package/templates/nextjs-app/_arch/mes/src/components/common/PrefetchBoundary/index.tsx +35 -0
  39. package/templates/nextjs-app/_arch/mes/src/components/layouts/RootLayout.tsx +19 -0
  40. package/templates/nextjs-app/_arch/mes/src/components/providers/GlobalProvider/index.tsx +23 -0
  41. package/templates/nextjs-app/_arch/mes/src/components/providers/index.tsx +1 -0
  42. package/templates/nextjs-app/_arch/mes/src/components/providers/tanstack/QueryClientProvider.tsx +14 -0
  43. package/templates/nextjs-app/_arch/mes/src/components/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
  44. package/templates/nextjs-app/_arch/mes/src/components/providers/theme/ThemeProvider.tsx +27 -0
  45. package/templates/nextjs-app/_arch/mes/src/hooks/.gitkeep +0 -0
  46. package/templates/nextjs-app/_arch/mes/src/hooks/useAppMutation.ts +59 -0
  47. package/templates/nextjs-app/_arch/mes/src/lib/api/.gitkeep +0 -0
  48. package/templates/nextjs-app/_arch/mes/src/lib/api/apiTypes.ts +21 -0
  49. package/templates/nextjs-app/_arch/mes/src/lib/api/clientFetch.ts +40 -0
  50. package/templates/nextjs-app/_arch/mes/src/lib/api/error.ts +12 -0
  51. package/templates/nextjs-app/_arch/mes/src/lib/api/errorMessages.ts +37 -0
  52. package/templates/nextjs-app/_arch/mes/src/lib/api/http.ts +13 -0
  53. package/templates/nextjs-app/_arch/mes/src/lib/api/observability.ts +20 -0
  54. package/templates/nextjs-app/_arch/mes/src/lib/api/queryClient.ts +30 -0
  55. package/templates/nextjs-app/_arch/mes/src/lib/api/serverFetch.ts +59 -0
  56. package/templates/nextjs-app/_arch/mes/src/lib/config/.gitkeep +0 -0
  57. package/templates/nextjs-app/_arch/mes/src/lib/test/createTestQueryClient.ts +18 -0
  58. package/templates/nextjs-app/_arch/mes/src/lib/test/index.ts +2 -0
  59. package/templates/nextjs-app/_arch/mes/src/lib/test/renderWithProviders.tsx +65 -0
  60. package/templates/nextjs-app/_arch/mes/src/lib/utils/.gitkeep +0 -0
  61. package/templates/nextjs-app/_arch/mes/src/lib/utils/formatDate.ts +26 -0
  62. package/templates/nextjs-app/_arch/mes/src/lib/utils/formatPrice.ts +18 -0
  63. package/templates/nextjs-app/_arch/mes/src/lib/utils/getQueryClient.ts +14 -0
  64. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/api.ts +3 -0
  65. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/components/.gitkeep +0 -0
  66. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/hooks.ts +3 -0
  67. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/index.tsx +14 -0
  68. package/templates/nextjs-app/_arch/mes/src/pages/sign-in/schema.ts +2 -0
  69. package/templates/nextjs-app/_arch/mes/tsconfig.json +24 -0
  70. package/templates/nextjs-standalone/_arch/flat/app/globals.css +5 -0
  71. package/templates/nextjs-standalone/_arch/flat/components/layouts/RootLayout.tsx +6 -0
  72. package/templates/nextjs-standalone/_arch/fsd/src/app/layouts/RootLayout.tsx +6 -0
  73. package/templates/nextjs-standalone/_arch/mes/app/api/proxy/[...path]/route.ts +112 -0
  74. package/templates/nextjs-standalone/_arch/mes/app/globals.css +54 -0
  75. package/templates/nextjs-standalone/_arch/mes/app/layout.tsx +16 -0
  76. package/templates/nextjs-standalone/_arch/mes/app/sign-in/page.tsx +1 -0
  77. package/templates/nextjs-standalone/_arch/mes/eslint.config.js +137 -0
  78. package/templates/nextjs-standalone/_arch/mes/sh-ui.config.json +22 -0
  79. package/templates/nextjs-standalone/_arch/mes/src/components/common/.gitkeep +0 -0
  80. package/templates/nextjs-standalone/_arch/mes/src/components/common/FallbackBoundary/index.tsx +89 -0
  81. package/templates/nextjs-standalone/_arch/mes/src/components/common/PrefetchBoundary/index.tsx +35 -0
  82. package/templates/nextjs-standalone/_arch/mes/src/components/layouts/RootLayout.tsx +19 -0
  83. package/templates/nextjs-standalone/_arch/mes/src/components/providers/GlobalProvider/index.tsx +23 -0
  84. package/templates/nextjs-standalone/_arch/mes/src/components/providers/index.tsx +1 -0
  85. package/templates/nextjs-standalone/_arch/mes/src/components/providers/tanstack/QueryClientProvider.tsx +14 -0
  86. package/templates/nextjs-standalone/_arch/mes/src/components/providers/tanstack/TanstackDevtoolsProvider.tsx +13 -0
  87. package/templates/nextjs-standalone/_arch/mes/src/components/providers/theme/ThemeProvider.tsx +27 -0
  88. package/templates/nextjs-standalone/_arch/mes/src/hooks/.gitkeep +0 -0
  89. package/templates/nextjs-standalone/_arch/mes/src/hooks/useAppMutation.ts +59 -0
  90. package/templates/nextjs-standalone/_arch/mes/src/lib/api/.gitkeep +0 -0
  91. package/templates/nextjs-standalone/_arch/mes/src/lib/api/apiTypes.ts +21 -0
  92. package/templates/nextjs-standalone/_arch/mes/src/lib/api/clientFetch.ts +40 -0
  93. package/templates/nextjs-standalone/_arch/mes/src/lib/api/error.ts +12 -0
  94. package/templates/nextjs-standalone/_arch/mes/src/lib/api/errorMessages.ts +37 -0
  95. package/templates/nextjs-standalone/_arch/mes/src/lib/api/http.ts +13 -0
  96. package/templates/nextjs-standalone/_arch/mes/src/lib/api/observability.ts +20 -0
  97. package/templates/nextjs-standalone/_arch/mes/src/lib/api/queryClient.ts +30 -0
  98. package/templates/nextjs-standalone/_arch/mes/src/lib/api/serverFetch.ts +59 -0
  99. package/templates/nextjs-standalone/_arch/mes/src/lib/config/.gitkeep +0 -0
  100. package/templates/nextjs-standalone/_arch/mes/src/lib/styles/tokens.css +170 -0
  101. package/templates/nextjs-standalone/_arch/mes/src/lib/test/createTestQueryClient.ts +18 -0
  102. package/templates/nextjs-standalone/_arch/mes/src/lib/test/index.ts +2 -0
  103. package/templates/nextjs-standalone/_arch/mes/src/lib/test/renderWithProviders.tsx +65 -0
  104. package/templates/nextjs-standalone/_arch/mes/src/lib/utils/formatDate.ts +26 -0
  105. package/templates/nextjs-standalone/_arch/mes/src/lib/utils/formatPrice.ts +18 -0
  106. package/templates/nextjs-standalone/_arch/mes/src/lib/utils/getQueryClient.ts +14 -0
  107. package/templates/nextjs-standalone/_arch/mes/src/lib/utils/utils.ts +6 -0
  108. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/api.ts +3 -0
  109. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/components/.gitkeep +0 -0
  110. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/hooks.ts +3 -0
  111. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/index.tsx +14 -0
  112. package/templates/nextjs-standalone/_arch/mes/src/pages/sign-in/schema.ts +2 -0
  113. package/templates/nextjs-standalone/_arch/mes/tsconfig.json +39 -0
  114. package/templates/nextjs-standalone/app/globals.css +5 -0
  115. package/templates/ui-app-template/src/styles/globals.css +5 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * MES (Backoffice) 아키텍처 디스크립터.
3
+ *
4
+ * 스마트팩토리 MES·ERP·관리자 도구처럼 **페이지 간 상호작용이 거의 없고**
5
+ * 페이지마다 자기 컬럼/스키마/다이얼로그를 다시 정의하는 CRUD-heavy 앱을 위한
6
+ * 페이지 격리 구조.
7
+ *
8
+ * 핵심 컨벤션:
9
+ * - `app/<route>/page.tsx` 는 한 줄짜리 위임 (`export { default } from "@/pages/<name>"`)
10
+ * - 페이지 본체는 `src/pages/<name>/` 에 자기완결로 거주 — index.tsx, components/,
11
+ * api.ts, hooks.ts, schema.ts, columns.ts.
12
+ * - 두 페이지 이상에서 같은 코드가 보이기 시작하면 그때 `src/components/` `src/hooks/`
13
+ * `src/lib/` 로 승격. **두 번째 쓰임이 나타나기 전엔 공용 만들지 않기.**
14
+ *
15
+ * tsconfig 의 `paths` 는 FSD 처럼 catch-all `@/*` 를 쓰되 매핑 대상이 `./src/*` —
16
+ * 즉 모든 import 가 `@/pages/...`, `@/components/...`, `@/lib/...` 처럼 `src/` 루트
17
+ * 기준으로 짧게 정리된다.
18
+ */
19
+ export const mesArch = {
20
+ name: 'mes',
21
+ label: 'MES (Backoffice)',
22
+ description:
23
+ '페이지 격리 구조 (src/pages/<name>/ 자기완결). 페이지 간 상호작용이 적은 CRUD-heavy 관리자 도구·MES 류에 적합.',
24
+ platforms: ['next'],
25
+
26
+ paths: {
27
+ layouts: 'src/components/layouts',
28
+ providers: 'src/components/providers',
29
+ api: 'src/lib/api',
30
+ config: 'src/lib/config',
31
+ hooks: 'src/hooks',
32
+ utils: 'src/lib/utils',
33
+ ui: 'src/components/common',
34
+ test: 'src/lib/test',
35
+ },
36
+
37
+ aliases: {
38
+ layouts: '@/components/layouts',
39
+ providers: '@/components/providers',
40
+ api: '@/lib/api',
41
+ config: '@/lib/config',
42
+ hooks: '@/hooks',
43
+ utils: '@/lib/utils',
44
+ ui: '@/components/common',
45
+ test: '@/lib/test',
46
+ },
47
+
48
+ // Catch-all `@/*` → `./src/*`. FSD 와 같은 결의 명명이지만 src/ 루트가 바뀜.
49
+ // `@/pages/customers`, `@/components/...`, `@/lib/api/...` 모두 자연스럽게 풀린다.
50
+ tsconfigPaths: {
51
+ '@/*': ['./src/*'],
52
+ },
53
+ };
@@ -152,6 +152,41 @@ function assertNoTtyFlag(value, flagLabel) {
152
152
  }
153
153
  }
154
154
 
155
+ // 프로젝트/앱 이름을 디렉토리명으로 안전하게 쓸 수 있는지 검증.
156
+ // MCP (sh_ui_create_project / sh_ui_add_app) 는 LLM/외부 호출자가 임의의 문자열을 넘길 수
157
+ // 있는데, 그 값이 그대로 `path.resolve(parent, name)` 에 들어간다. 검증이 없으면 '../'
158
+ // 시퀀스로 부모 디렉토리를 벗어나 임의 위치에 파일 쓰기 / 기존 디렉토리 삭제(force+yes 경로)
159
+ // 까지 가능. 진입부에서 한 번 막아 두면 CLI 와 MCP 양쪽 모두 자동 차단.
160
+ export function validateProjectName(name, label = 'name') {
161
+ if (typeof name !== 'string' || name.length === 0) {
162
+ throw new Error(`${label} 가 비어있습니다.`);
163
+ }
164
+ if (name.length > 214) {
165
+ throw new Error(`${label} 가 너무 깁니다 (최대 214자).`);
166
+ }
167
+ // 선행 '.' 차단 → '..', '.ssh', '.git' 등 숨김/특수 디렉토리 봉쇄.
168
+ // 영숫자 + '.' + '_' + '-' 만 허용 → 경로 구분자('/', '\\'), NUL, 셸 메타문자 일괄 차단.
169
+ if (name.startsWith('.') || !/^[a-zA-Z0-9._-]+$/.test(name)) {
170
+ throw new Error(
171
+ `${label} '${name}' 가 유효하지 않습니다 — ` +
172
+ `영숫자, '_', '-', '.' 만 허용하며 '.' 로 시작할 수 없습니다 (디렉토리 이름만 허용, 경로 불가).`,
173
+ );
174
+ }
175
+ return name;
176
+ }
177
+
178
+ // 방어 가드 — validateProjectName 이 이미 traversal 을 차단하지만, 파괴적 작업(`fs.remove`)
179
+ // 직전에 한 번 더 봉쇄해 향후 다른 진입점이 추가되거나 검증을 우회하는 코드 패스가 생겨도
180
+ // 부모 디렉토리 밖을 건드리지 않도록 보장. parent 의 진짜 하위가 아니면 throw.
181
+ function assertWithin(parent, child) {
182
+ const rel = path.relative(parent, child);
183
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
184
+ throw new Error(
185
+ `safety: 대상 경로 '${child}' 가 부모 '${parent}' 외부 또는 부모 그 자체입니다 — 처리 거부.`,
186
+ );
187
+ }
188
+ }
189
+
155
190
  export async function createProject(options = {}) {
156
191
  if (!process.stdin.isTTY) {
157
192
  assertNoTtyFlag(options.name, '<project-name> (positional)');
@@ -161,10 +196,13 @@ export async function createProject(options = {}) {
161
196
  }
162
197
  }
163
198
 
164
- const projectName = options.name ?? await input({
165
- message: '프로젝트 이름:',
166
- default: 'my-app',
167
- });
199
+ const projectName = validateProjectName(
200
+ options.name ?? await input({
201
+ message: '프로젝트 이름:',
202
+ default: 'my-app',
203
+ }),
204
+ '프로젝트 이름',
205
+ );
168
206
 
169
207
  const platform = options.platform ?? await select({
170
208
  message: '플랫폼:',
@@ -247,6 +285,12 @@ export async function createProject(options = {}) {
247
285
  ? await fs.mkdtemp(path.join(os.tmpdir(), 'sh-ui-dry-'))
248
286
  : path.resolve(process.cwd(), projectName);
249
287
 
288
+ // 방어 가드 — projectName 검증을 이미 통과했어도 `fs.remove` 직전에 한 번 더 확인.
289
+ // dry-run 은 tmpdir 이라 parent 가 cwd 가 아니므로 스킵.
290
+ if (!options.dryRun) {
291
+ assertWithin(process.cwd(), targetDir);
292
+ }
293
+
250
294
  if (!options.dryRun && await fs.pathExists(targetDir)) {
251
295
  if (options.yes) {
252
296
  await fs.remove(targetDir);
@@ -361,10 +405,13 @@ export async function addApp(options = {}) {
361
405
  throw new Error('비대화형 환경(TTY 없음)에서는 name 이 필요합니다.');
362
406
  }
363
407
 
364
- const appName = options.name ?? await input({
365
- message: '앱 이름:',
366
- default: 'web',
367
- });
408
+ const appName = validateProjectName(
409
+ options.name ?? await input({
410
+ message: '앱 이름:',
411
+ default: 'web',
412
+ }),
413
+ '앱 이름',
414
+ );
368
415
 
369
416
  const port = options.port ?? (process.stdin.isTTY
370
417
  ? await input({ message: '포트 번호:', default: '3000' })
@@ -1504,8 +1551,13 @@ const stripLocalePrefix = (pathname: string): string => {
1504
1551
  * - \`/\` + HOME_REDIRECT 설정 → 해당 경로로 리다이렉트 (인증 가드보다 먼저)
1505
1552
  * - intl 이 로케일 prefix 처리 + NEXT_LOCALE 쿠키 set
1506
1553
  * - 그 위에 인증 가드 — 토큰 없고 인증 라우트도 아니면 /sign-in 으로 redirect
1554
+ * - dev + \`NEXT_PUBLIC_DEV_AUTH_BYPASS=true\` → 가드 전체 우회 (개발용)
1507
1555
  * - AT 만료 검사나 refresh 는 하지 않는다 (BFF 가 처리)
1508
1556
  */
1557
+ const DEV_BYPASS =
1558
+ process.env.NODE_ENV !== 'production' &&
1559
+ process.env.NEXT_PUBLIC_DEV_AUTH_BYPASS === 'true';
1560
+
1509
1561
  export default function proxy(req: NextRequest) {
1510
1562
  const intlRes = intl(req);
1511
1563
  const pathname = stripLocalePrefix(req.nextUrl.pathname);
@@ -1516,6 +1568,7 @@ export default function proxy(req: NextRequest) {
1516
1568
  return NextResponse.redirect(new URL(HOME_REDIRECT, req.url));
1517
1569
  }
1518
1570
 
1571
+ if (DEV_BYPASS) return intlRes;
1519
1572
  if (isAuthRoute) return intlRes;
1520
1573
  if (!hasToken) return NextResponse.redirect(new URL('/sign-in', req.url));
1521
1574
 
@@ -16,10 +16,14 @@ export const authJwtPlugin = {
16
16
  envVars: [
17
17
  '# Auth (auth-jwt)',
18
18
  'COOKIE_SECURE=false',
19
+ '# Dev 시 인증 가드 우회 — proxy.ts 가 이 flag 를 보면 /sign-in 으로 redirect 안 함.',
20
+ '# 실제 백엔드 연동 후엔 반드시 비워야 함 (또는 NODE_ENV 가 production 이면 무시).',
21
+ 'NEXT_PUBLIC_DEV_AUTH_BYPASS=false',
19
22
  ],
20
23
 
21
24
  turboEnvVars: [
22
25
  'COOKIE_SECURE',
26
+ 'NEXT_PUBLIC_DEV_AUTH_BYPASS',
23
27
  ],
24
28
 
25
29
  providerImports: [],
@@ -101,10 +105,15 @@ const HOME_REDIRECT = '';
101
105
  * - \`/\` + HOME_REDIRECT 설정 → 해당 경로로 리다이렉트
102
106
  * - AT 쿠키 없음 + 인증 라우트 아님 → /sign-in 으로 리다이렉트
103
107
  * - AT 쿠키 있음 또는 인증 라우트 → 통과
108
+ * - dev + \`NEXT_PUBLIC_DEV_AUTH_BYPASS=true\` → 가드 전체 우회 (개발용)
104
109
  *
105
110
  * AT 가 만료된 채 통과한 요청은 BFF (/api/proxy) 가 401 을 받아
106
111
  * refreshSession 으로 갱신을 시도한다.
107
112
  */
113
+ const DEV_BYPASS =
114
+ process.env.NODE_ENV !== 'production' &&
115
+ process.env.NEXT_PUBLIC_DEV_AUTH_BYPASS === 'true';
116
+
108
117
  export default function proxy(req: NextRequest) {
109
118
  const { pathname } = req.nextUrl;
110
119
  const hasToken = !!req.cookies.get('accessToken')?.value;
@@ -114,6 +123,7 @@ export default function proxy(req: NextRequest) {
114
123
  return NextResponse.redirect(new URL(HOME_REDIRECT, req.url));
115
124
  }
116
125
 
126
+ if (DEV_BYPASS) return NextResponse.next();
117
127
  if (isAuthRoute) return NextResponse.next();
118
128
  if (!hasToken) return NextResponse.redirect(new URL('/sign-in', req.url));
119
129
 
@@ -271,8 +271,25 @@ export const { Link, redirect, usePathname, useRouter, getPathname } =
271
271
  "save": "저장",
272
272
  "delete": "삭제",
273
273
  "edit": "수정",
274
+ "create": "만들기",
274
275
  "search": "검색",
275
- "back": "뒤로"
276
+ "back": "뒤로",
277
+ "name": "이름",
278
+ "description": "설명",
279
+ "empty": "아직 항목이 없습니다."
280
+ },
281
+ "nav": {
282
+ "home": "홈",
283
+ "settings": "설정"
284
+ },
285
+ "app": {
286
+ "title": "App"
287
+ },
288
+ "form": {
289
+ "required": "필수 항목입니다.",
290
+ "invalid": "올바른 값을 입력하세요.",
291
+ "submit": "제출",
292
+ "reset": "초기화"
276
293
  },
277
294
  "error": {
278
295
  "title": "오류가 발생했습니다",
@@ -302,8 +319,25 @@ export const { Link, redirect, usePathname, useRouter, getPathname } =
302
319
  "save": "Save",
303
320
  "delete": "Delete",
304
321
  "edit": "Edit",
322
+ "create": "Create",
305
323
  "search": "Search",
306
- "back": "Back"
324
+ "back": "Back",
325
+ "name": "Name",
326
+ "description": "Description",
327
+ "empty": "No items yet."
328
+ },
329
+ "nav": {
330
+ "home": "Home",
331
+ "settings": "Settings"
332
+ },
333
+ "app": {
334
+ "title": "App"
335
+ },
336
+ "form": {
337
+ "required": "This field is required.",
338
+ "invalid": "Please enter a valid value.",
339
+ "submit": "Submit",
340
+ "reset": "Reset"
307
341
  },
308
342
  "error": {
309
343
  "title": "Something went wrong",
package/src/mcp.mjs CHANGED
@@ -32,7 +32,7 @@ import { list } from "./list.mjs";
32
32
  import { remove } from "./remove.mjs";
33
33
  import { renameApp } from "./rename-app.mjs";
34
34
  import { migrateToV065 } from "./migrate-v065.mjs";
35
- import { createProject, addApp } from "./create/generator.js";
35
+ import { createProject, addApp, validateProjectName } from "./create/generator.js";
36
36
  import {
37
37
  getRegistryRoot,
38
38
  getSummariesPath,
@@ -263,6 +263,59 @@ CLI \`sh-ui add <name>\` 은 monorepo 의 어느 디렉토리에서든 (apps/web
263
263
  - 다이얼로그 cancel 버튼을 \`<Button onClick={() => setOpen(false)}>\` 로 우회 → 정석은 \`<DialogClose render={<Button>취소</Button>} />\` (Base UI render prop)
264
264
  - table 외관의 카드 그리드를 raw \`<div>\` 로 → \`Card\` / \`CardHeader\` / \`CardContent\` / \`CardFooter\` 사용
265
265
 
266
+ ## Base UI 합성 함정 (Next.js App Router)
267
+
268
+ Base UI 위에 빌드된 sh-ui 컴포넌트 (\`DropdownMenu\` / \`Select\` / \`Dialog\` / \`Popover\` / \`Tooltip\` / \`Combobox\`) 두 가지 알려진 패턴:
269
+
270
+ ### 1. SSR hydration warning (auto-id)
271
+
272
+ Base UI 의 \`useId()\` 가 서버/클라이언트 ID 가 다를 수 있어 hydration mismatch 경고. 동작은 무해하지만 콘솔 노이즈.
273
+
274
+ **회피 패턴 — mounted gate** (sidebar header 등 항상 보이는 trigger 에 적용):
275
+
276
+ \`\`\`tsx
277
+ const [mounted, setMounted] = useState(false);
278
+ useEffect(() => setMounted(true), []);
279
+
280
+ if (!mounted) {
281
+ // trigger 외형만 그대로, dropdown wrapping 없이 placeholder 렌더
282
+ return <div className={TRIGGER_CLS}>…</div>;
283
+ }
284
+ return (
285
+ <DropdownMenu>
286
+ <DropdownMenuTrigger className={TRIGGER_CLS}>…</DropdownMenuTrigger>
287
+ <DropdownMenuContent>…</DropdownMenuContent>
288
+ </DropdownMenu>
289
+ );
290
+ \`\`\`
291
+
292
+ ### 2. \`DropdownMenuItem\` 안에 \`DialogTrigger\` render 시 dialog 안 열림
293
+
294
+ 두 Base UI primitive 의 render-prop 체인이 onClick / onSelect 충돌.
295
+
296
+ **잘못된 패턴**:
297
+ \`\`\`tsx
298
+ <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
299
+ <DialogTrigger render={<Button>…</Button>} /> // 안 열림
300
+ </DropdownMenuItem>
301
+ \`\`\`
302
+
303
+ **정석 패턴 — controlled dialog 를 sibling 으로**:
304
+ \`\`\`tsx
305
+ const [open, setOpen] = useState(false);
306
+ return (
307
+ <>
308
+ <DropdownMenu>
309
+
310
+ <DropdownMenuItem onClick={() => setOpen(true)}>…</DropdownMenuItem>
311
+ </DropdownMenu>
312
+ <MyDialog open={open} onOpenChange={setOpen} />
313
+ </>
314
+ );
315
+ \`\`\`
316
+
317
+ 다이얼로그 컴포넌트는 controlled (\`open\`/\`onOpenChange\`) 모드를 지원하도록 작성해 둘 것.
318
+
266
319
  ## 앱 이름 변경 (monorepo)
267
320
 
268
321
  사용자가 "apps/web 을 apps/dashboard 로 바꿔줘" 같이 모노레포 앱 이름 변경을 요청하면 \`sh_ui_rename_app\` 사용 — 손으로 6~10 군데 (디렉토리, package.json name, tsconfig paths, Dockerfile WORKDIR, next.config transpilePackages, sh-ui.config aliases, README, .github/workflows) 갈아엎지 않도록 자동화. \`dryRun: true\` 로 먼저 변경 매트릭스 보여주고 사용자 확인 후 실행 권장.
@@ -354,6 +407,13 @@ export async function startMcpServer() {
354
407
  ],
355
408
  };
356
409
  }
410
+ // path traversal 차단 — existsSync probe / fs.remove 흐름이 임의 경로로
411
+ // 흘러가지 않도록 입력 검증을 가장 먼저.
412
+ try {
413
+ validateProjectName(input.name, "name");
414
+ } catch (e) {
415
+ return { isError: true, content: [{ type: "text", text: e.message }] };
416
+ }
357
417
  const targetParent = resolveCwd(input);
358
418
  const targetDir = resolve(targetParent, input.name);
359
419
  if (existsSync(targetDir) && !input.force) {
@@ -413,6 +473,11 @@ export async function startMcpServer() {
413
473
  },
414
474
  },
415
475
  async (input) => {
476
+ try {
477
+ validateProjectName(input.name, "name");
478
+ } catch (e) {
479
+ return { isError: true, content: [{ type: "text", text: e.message }] };
480
+ }
416
481
  const text = await captureConsole(() =>
417
482
  addApp({
418
483
  name: input.name,
@@ -0,0 +1,82 @@
1
+ import boundaries from "eslint-plugin-boundaries"
2
+ import checkFile from "eslint-plugin-check-file"
3
+
4
+ /**
5
+ * MES (Backoffice) ESLint configuration.
6
+ *
7
+ * 페이지 격리 + 단방향 의존을 강제:
8
+ *
9
+ * - `src/lib/*` — lib 끼리만 (UI/페이지 모름)
10
+ * - `src/hooks/*` — hooks / lib 만
11
+ * - `src/components/*` — components / hooks / lib 만
12
+ * - `src/pages/*` — components / hooks / lib 만. **다른 페이지 import 금지** (격리)
13
+ * - `app/` — pages / components / hooks / lib 모두 OK (한 줄 위임)
14
+ *
15
+ * @type {import("eslint").Linter.Config[]}
16
+ */
17
+ export const mesConfig = [
18
+ // ── boundaries ──
19
+ {
20
+ plugins: { boundaries },
21
+ settings: {
22
+ "import/resolver": {
23
+ typescript: { alwaysTryTypes: true },
24
+ },
25
+ "boundaries/elements": [
26
+ { type: "lib", pattern: ["src/lib/*"], mode: "folder" },
27
+ { type: "hooks", pattern: ["src/hooks"], mode: "folder" },
28
+ { type: "components", pattern: ["src/components/*"], mode: "folder" },
29
+ { type: "pages", pattern: ["src/pages/*"], mode: "folder" },
30
+ { type: "app", pattern: ["app"], mode: "folder" },
31
+ ],
32
+ "boundaries/ignore": ["**/*.test.*", "**/*.spec.*"],
33
+ },
34
+ rules: {
35
+ "boundaries/element-types": [
36
+ "warn",
37
+ {
38
+ default: "disallow",
39
+ rules: [
40
+ { from: "app", allow: ["pages", "components", "hooks", "lib"] },
41
+ // pages 끼리는 import 금지 — 페이지 격리 원칙
42
+ { from: "pages", allow: ["components", "hooks", "lib"] },
43
+ { from: "components", allow: ["components", "hooks", "lib"] },
44
+ { from: "hooks", allow: ["hooks", "lib"] },
45
+ { from: "lib", allow: ["lib"] },
46
+ ],
47
+ },
48
+ ],
49
+ },
50
+ },
51
+
52
+ // ── check-file (MES 는 .tsx PASCAL, .ts CAMEL) ──
53
+ {
54
+ plugins: { "check-file": checkFile },
55
+ rules: {
56
+ "check-file/filename-naming-convention": [
57
+ "error",
58
+ {
59
+ "**/components/**/*.tsx": "PASCAL_CASE",
60
+ "**/pages/**/components/**/*.tsx": "PASCAL_CASE",
61
+ "**/lib/**/*.ts": "CAMEL_CASE",
62
+ "**/hooks/**/*.ts": "CAMEL_CASE",
63
+ },
64
+ { ignoreMiddleExtensions: true },
65
+ ],
66
+ },
67
+ },
68
+ {
69
+ files: [
70
+ "**/index.tsx", "**/index.ts",
71
+ "**/layout.tsx", "**/page.tsx",
72
+ "**/error.tsx", "**/not-found.tsx",
73
+ "**/routing.ts", "**/navigation.ts", "**/request.ts",
74
+ // MES 페이지 concern 파일들
75
+ "**/pages/**/api.ts", "**/pages/**/schema.ts",
76
+ "**/pages/**/columns.ts", "**/pages/**/hooks.ts",
77
+ ],
78
+ rules: {
79
+ "check-file/filename-naming-convention": "off",
80
+ },
81
+ },
82
+ ]
@@ -8,7 +8,8 @@
8
8
  "./next-js": "./next.js",
9
9
  "./react-internal": "./react-internal.js",
10
10
  "./fsd": "./fsd.js",
11
- "./flat": "./flat.js"
11
+ "./flat": "./flat.js",
12
+ "./mes": "./mes.js"
12
13
  },
13
14
  "devDependencies": {
14
15
  "@eslint/js": "^9.39.2",
@@ -28,7 +28,7 @@
28
28
  "typescript": "^5.9.3"
29
29
  },
30
30
  "exports": {
31
- "./components/*": "./src/components/*.tsx",
31
+ "./components/*": "./src/components/*/index.tsx",
32
32
  "./hooks/*": "./src/hooks/*.ts",
33
33
  "./lib/*": "./src/lib/*.ts"
34
34
  }
@@ -1,10 +1,16 @@
1
1
  import { GlobalProvider } from '@/components/providers';
2
2
 
3
+ /** FOUC 차단 — next-themes mount 전에 첫 paint 에 dark/light class 박기. */
4
+ const themeInitScript = `try{var t=localStorage.getItem('theme');var c=document.documentElement.classList;if(t==='dark'||(!t&&matchMedia('(prefers-color-scheme:dark)').matches)){c.add('dark');}else if(t==='light'){c.add('light');}}catch(e){}`;
5
+
3
6
  export function RootLayout({ children }: { children: React.ReactNode }) {
4
7
  // `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
5
8
  // next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
6
9
  return (
7
10
  <html lang='ko' suppressHydrationWarning>
11
+ <head>
12
+ <script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
13
+ </head>
8
14
  <body>
9
15
  <GlobalProvider>{children}</GlobalProvider>
10
16
  </body>
@@ -1,10 +1,21 @@
1
1
  import { GlobalProvider } from '@/src/app/providers';
2
2
 
3
+ /**
4
+ * 첫 paint 전에 localStorage 의 theme 값을 읽어 <html> 에 class 를 박는
5
+ * FOUC 차단 inline script. next-themes 의 ThemeProvider 가 client mount 후
6
+ * 동일 작업을 하지만, mount 전 한 frame 동안 light/dark 깜빡임이 생긴다.
7
+ * 이걸 막으려고 SSR 응답 head 안쪽에 동기 실행 script 박음.
8
+ */
9
+ const themeInitScript = `try{var t=localStorage.getItem('theme');var c=document.documentElement.classList;if(t==='dark'||(!t&&matchMedia('(prefers-color-scheme:dark)').matches)){c.add('dark');}else if(t==='light'){c.add('light');}}catch(e){}`;
10
+
3
11
  export function RootLayout({ children }: { children: React.ReactNode }) {
4
12
  // `lang` 은 앱의 주 언어. 영어 등 다른 언어 우선이면 'en' 으로 바꾸거나
5
13
  // next-intl 플러그인을 활성화해 라우트 기반 자동 분기로 전환하세요.
6
14
  return (
7
15
  <html lang='ko' suppressHydrationWarning>
16
+ <head>
17
+ <script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
18
+ </head>
8
19
  <body>
9
20
  <GlobalProvider>{children}</GlobalProvider>
10
21
  </body>
@@ -0,0 +1,112 @@
1
+ import { cookies } from 'next/headers';
2
+ import { NextResponse, type NextRequest } from 'next/server';
3
+
4
+ import {
5
+ captureApiError,
6
+ logApiError,
7
+ } from '@/lib/api/observability';
8
+
9
+ const API_URL = process.env.API_URL ?? 'http://localhost:8080/api';
10
+ const ACCESS_TOKEN_COOKIE = 'accessToken';
11
+ const LOCALE_COOKIE = 'NEXT_LOCALE';
12
+
13
+ const proxyRequest = async (
14
+ request: NextRequest,
15
+ ctx: { params: Promise<{ path: string[] }> },
16
+ method: string,
17
+ ) => {
18
+ const { path } = await ctx.params;
19
+ const apiPath = path.join('/');
20
+ const url = new URL(`${API_URL}/${apiPath}`);
21
+
22
+ request.nextUrl.searchParams.forEach((value, key) => {
23
+ url.searchParams.set(key, value);
24
+ });
25
+
26
+ const cookieStore = await cookies();
27
+ const accessToken = cookieStore.get(ACCESS_TOKEN_COOKIE)?.value;
28
+ const locale =
29
+ cookieStore.get(LOCALE_COOKIE)?.value ??
30
+ request.headers.get('Accept-Language') ??
31
+ undefined;
32
+
33
+ const headers: Record<string, string> = {};
34
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
35
+ if (locale) headers['Accept-Language'] = locale;
36
+
37
+ let body: BodyInit | undefined;
38
+ if (method !== 'GET' && method !== 'HEAD') {
39
+ const contentType = request.headers.get('Content-Type');
40
+ if (contentType?.includes('multipart/form-data')) {
41
+ body = await request.formData();
42
+ } else {
43
+ headers['Content-Type'] = 'application/json';
44
+ body = await request.text();
45
+ }
46
+ }
47
+
48
+ let response: Response;
49
+ try {
50
+ response = await fetch(url.toString(), { method, headers, body });
51
+ } catch (error) {
52
+ console.error(`[PROXY] ${method} ${url.toString()} —`, error);
53
+ return NextResponse.json(
54
+ {
55
+ result: 'ERROR',
56
+ data: null,
57
+ error: {
58
+ code: 'NETWORK_ERROR',
59
+ message: 'Failed to reach upstream server.',
60
+ },
61
+ },
62
+ { status: 502 },
63
+ );
64
+ }
65
+
66
+ const data = await response.json();
67
+
68
+ if (!response.ok) {
69
+ logApiError('PROXY', {
70
+ url: url.toString(),
71
+ method,
72
+ status: response.status,
73
+ requestBody: typeof body === 'string' ? body : undefined,
74
+ responseBody: data,
75
+ });
76
+
77
+ captureApiError({
78
+ url: url.toString(),
79
+ apiPath,
80
+ method,
81
+ status: response.status,
82
+ responseBody: data,
83
+ });
84
+ }
85
+
86
+ return NextResponse.json(data, { status: response.status });
87
+ };
88
+
89
+ export const GET = (
90
+ req: NextRequest,
91
+ ctx: { params: Promise<{ path: string[] }> },
92
+ ) => proxyRequest(req, ctx, 'GET');
93
+
94
+ export const POST = (
95
+ req: NextRequest,
96
+ ctx: { params: Promise<{ path: string[] }> },
97
+ ) => proxyRequest(req, ctx, 'POST');
98
+
99
+ export const PUT = (
100
+ req: NextRequest,
101
+ ctx: { params: Promise<{ path: string[] }> },
102
+ ) => proxyRequest(req, ctx, 'PUT');
103
+
104
+ export const PATCH = (
105
+ req: NextRequest,
106
+ ctx: { params: Promise<{ path: string[] }> },
107
+ ) => proxyRequest(req, ctx, 'PATCH');
108
+
109
+ export const DELETE = (
110
+ req: NextRequest,
111
+ ctx: { params: Promise<{ path: string[] }> },
112
+ ) => proxyRequest(req, ctx, 'DELETE');
@@ -0,0 +1,16 @@
1
+ import type { Metadata } from 'next';
2
+ import '@workspace/ui-app-name/globals.css';
3
+ import { RootLayout } from '@/components/layouts/RootLayout';
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'sh-ui app',
7
+ description: 'sh-ui 기반 앱 — metadata 를 변경하세요.',
8
+ };
9
+
10
+ export default function Layout({
11
+ children,
12
+ }: Readonly<{
13
+ children: React.ReactNode;
14
+ }>) {
15
+ return <RootLayout>{children}</RootLayout>;
16
+ }
@@ -0,0 +1 @@
1
+ export { default } from '@/pages/sign-in';
@@ -0,0 +1,10 @@
1
+ import { nextJsConfig } from "@workspace/eslint-config/next-js"
2
+ import { mesConfig } from "@workspace/eslint-config/mes"
3
+
4
+ export default [
5
+ {
6
+ ignores: [".next/**", "dist/**", "node_modules/**"],
7
+ },
8
+ ...nextJsConfig,
9
+ ...mesConfig,
10
+ ]